feat(chat): preserve pinned messages across compaction

Add pin and unpin actions for user and assistant text messages, with clear compaction-survival labels, localized tooltips, status-info active styling, and VS Code gating where the server runtime is unavailable.

Persist pinned message IDs, creation timestamps, and roles under the OpenChamber session metadata namespace using fresh-read merge updates so goal, review, and other metadata remain intact.

Introduce a server runtime that reacts to OpenCode's dedicated session.compacted event, fetches pinned messages by ID, extracts and chronologically orders their text parts, and injects them as hidden synthetic context through prompt_async. The restoration prompt tells the agent to use the context silently while work remains and limits idle summaries to one short paragraph.

Track the last handled compaction summary to avoid replay duplication, tolerate individually missing pinned messages, integrate runtime shutdown, document ownership and limitations, and cover metadata round trips plus compaction injection behavior with focused tests.
This commit is contained in:
Bohdan Triapitsyn
2026-07-17 10:30:45 +03:00
parent 53d2dde87a
commit bd68e303d4
20 changed files with 457 additions and 2 deletions
+7
View File
@@ -74,6 +74,7 @@ import { createSessionRuntime } from './lib/opencode/session-runtime.js';
import { createOpenCodeWatcherRuntime } from './lib/opencode/watcher.js';
import { createSessionAssistRuntime } from './lib/session-assist/runtime.js';
import { createSessionGoalRuntime } from './lib/session-goal/runtime.js';
import { createContextObligatoryRuntime } from './lib/context-obligatory/runtime.js';
import { createScheduledTasksRuntime } from './lib/scheduled-tasks/runtime.js';
import { createServerStartupRuntime } from './lib/opencode/server-startup-runtime.js';
import { createTunnelWiringRuntime } from './lib/opencode/tunnel-wiring-runtime.js';
@@ -768,6 +769,10 @@ const sessionGoalRuntime = createSessionGoalRuntime({
});
},
});
const contextObligatoryRuntime = createContextObligatoryRuntime({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
});
const globalMessageStreamHub = createGlobalMessageStreamHub({
buildOpenCodeUrl,
@@ -813,6 +818,7 @@ globalMessageStreamHub.subscribeEvent((event) => {
: '';
sessionAssistRuntime.processPayload(payload, directory);
sessionGoalRuntime.processPayload(payload, directory);
contextObligatoryRuntime.processPayload(payload, directory);
});
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
@@ -1130,6 +1136,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
openCodeWatcherRuntime,
sessionAssistRuntime,
sessionGoalRuntime,
contextObligatoryRuntime,
sessionRuntime,
getHealthCheckInterval: () => healthCheckInterval,
clearHealthCheckInterval: (value) => clearInterval(value),
@@ -0,0 +1,19 @@
# Context Obligatory Messages
Messages explicitly pinned by the user are stored under
`session.metadata.openchamber.context_obligatory_messages` as `{ id, createdAt,
role }`. The UI uses a fresh-read metadata merge when pinning or unpinning.
The server runtime listens for OpenCode's dedicated `session.compacted` event.
It fetches every pinned message by ID, keeps non-empty text parts, sorts them
by the stored creation time, and immediately sends one synthetic user part
through `prompt_async`. OpenCode's session runner serializes this with its own
post-compaction continuation. Missing individual messages are skipped without
discarding the remaining context. Ordinary idle events perform no work and
make no requests.
After a successful send, the runtime merge-writes
`context_obligatory_last_compaction_message_id`. This cursor prevents a
replayed compaction event from reinjecting the same summary. The runtime is
owned by the OpenChamber web backend and therefore is not available in
extension-only VS Code mode.
@@ -0,0 +1,140 @@
const FETCH_TIMEOUT_MS = 15_000;
const MESSAGE_FETCH_LIMIT = 20;
const isRecord = (value) => Boolean(value && typeof value === 'object' && !Array.isArray(value));
const readContextState = (session) => {
const metadata = isRecord(session?.metadata) ? session.metadata : {};
const openchamber = isRecord(metadata.openchamber) ? metadata.openchamber : {};
const messages = Array.isArray(openchamber.context_obligatory_messages)
? openchamber.context_obligatory_messages.filter((item) =>
isRecord(item)
&& typeof item.id === 'string'
&& typeof item.createdAt === 'number'
&& (item.role === 'user' || item.role === 'assistant'))
: [];
return { metadata, openchamber, messages };
};
const buildContextPrompt = (entries) => {
const timeline = entries.map(({ pinned, text }) => {
const timestamp = new Date(pinned.createdAt).toISOString();
return `## ${pinned.role}${timestamp}\n\n${text}`;
}).join('\n\n---\n\n');
return [
'The following messages are from the compacted conversation. The user explicitly marked them as important and required in your context. Pay close attention to them; they may have been sent by either the user or you before compaction.',
'Use them while continuing the pre-compaction work. Do not treat this context restoration as a new standalone task.',
'If any tasks or next steps remain, do not acknowledge, summarize, or mention this restored context in a separate response. Simply continue the work and use it silently as background context. Do not append a recap of it after completing those tasks. Only if no tasks or next steps remain, give the user a very brief summary of the important restored context in no more than one short paragraph, without lists or a detailed recap.',
'',
timeline,
].join('\n');
};
export const createContextObligatoryRuntime = ({
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
}) => {
const inflight = new Set();
let stopped = false;
const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => {
const params = new URLSearchParams(query || {});
if (directory) params.set('directory', directory);
const search = params.toString();
const response = await fetch(`${buildOpenCodeUrl(fetchPath, '')}${search ? `?${search}` : ''}`, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...getOpenCodeAuthHeaders(),
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`OpenCode ${method} ${fetchPath} failed with ${response.status}`);
return response.json().catch(() => null);
};
const tick = async (sessionId, directory) => {
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
if (session?.parentID) return;
const state = readContextState(session);
if (state.messages.length === 0) return;
const recent = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, {
directory,
query: { limit: String(MESSAGE_FETCH_LIMIT) },
});
if (!Array.isArray(recent) || recent.length === 0) return;
const summary = recent.toReversed().find((message) =>
message?.info?.role === 'assistant' && message.info.summary === true)?.info;
if (!summary?.id || !summary?.time?.completed) return;
if (state.openchamber.context_obligatory_last_compaction_message_id === summary.id) return;
const fetched = await Promise.allSettled(state.messages.map(async (pinned) => {
const message = await openCodeFetch(
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(pinned.id)}`,
{ directory },
);
const text = Array.isArray(message?.parts)
? message.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string')
.map((part) => part.text.trim()).filter(Boolean).join('\n\n')
: '';
return { pinned, text };
}));
const entries = fetched
.filter((result) => result.status === 'fulfilled' && result.value.text)
.map((result) => result.value)
.sort((left, right) => left.pinned.createdAt - right.pinned.createdAt);
if (entries.length === 0) return;
const executionInfo = recent.toReversed().find((message) =>
message?.info?.role === 'assistant' && message.info.summary !== true)?.info;
const providerID = typeof executionInfo?.providerID === 'string' ? executionInfo.providerID : '';
const modelID = typeof executionInfo?.modelID === 'string' ? executionInfo.modelID : '';
if (!providerID || !modelID) throw new Error('no pre-compaction assistant provider/model');
const agent = typeof executionInfo.agent === 'string' ? executionInfo.agent : executionInfo.mode;
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, {
directory,
method: 'POST',
body: {
model: { providerID, modelID },
...(typeof agent === 'string' && agent ? { agent } : {}),
parts: [{ type: 'text', text: buildContextPrompt(entries), synthetic: true }],
},
});
const fresh = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory });
const freshState = readContextState(fresh);
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
directory,
method: 'PATCH',
body: {
metadata: {
...freshState.metadata,
openchamber: {
...freshState.openchamber,
context_obligatory_last_compaction_message_id: summary.id,
},
},
},
});
};
const processPayload = (payload, directoryHint = '') => {
if (stopped || payload?.type !== 'session.compacted') return;
const sessionId = payload?.properties?.sessionID;
if (typeof sessionId !== 'string' || inflight.has(sessionId)) return;
const directory = payload?.properties?.directory || directoryHint;
inflight.add(sessionId);
return tick(sessionId, directory)
.catch((error) => console.warn('[context-obligatory] injection failed:', error?.message || error))
.finally(() => inflight.delete(sessionId));
};
const stop = () => {
stopped = true;
};
return { processPayload, stop };
};
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createContextObligatoryRuntime } from './runtime.js';
const json = (body) => new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
describe('context obligatory runtime', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('injects pinned text in chronological order after compaction and records the summary cursor', async () => {
const requests = [];
let sessionReads = 0;
vi.stubGlobal('fetch', vi.fn(async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url);
requests.push({ path: url.pathname, method: init.method ?? 'GET', body: init.body });
if (url.pathname === '/session/ses_1' && init.method === 'PATCH') return json({});
if (url.pathname === '/session/ses_1') {
sessionReads += 1;
return json({
id: 'ses_1',
metadata: { openchamber: { context_obligatory_messages: [
{ id: 'msg_2', createdAt: 20, role: 'assistant' },
{ id: 'msg_1', createdAt: 10, role: 'user' },
] } },
});
}
if (url.pathname === '/session/ses_1/message') return json([
{ info: { id: 'msg_agent', role: 'assistant', providerID: 'provider', modelID: 'model', agent: 'build' } },
{ info: { id: 'msg_summary', role: 'assistant', summary: true, time: { completed: 30 } } },
]);
if (url.pathname === '/session/ses_1/message/msg_1') return json({ parts: [{ type: 'text', text: 'First' }] });
if (url.pathname === '/session/ses_1/message/msg_2') return json({ parts: [{ type: 'text', text: 'Second' }] });
if (url.pathname === '/session/ses_1/prompt_async') return json({});
throw new Error(`Unexpected ${url.pathname}`);
}));
const runtime = createContextObligatoryRuntime({
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
getOpenCodeAuthHeaders: () => ({}),
});
await runtime.processPayload({ type: 'session.compacted', properties: { sessionID: 'ses_1' } });
const prompt = requests.find((request) => request.path.endsWith('/prompt_async'));
const payload = JSON.parse(prompt.body);
expect(payload).toMatchObject({
model: { providerID: 'provider', modelID: 'model' },
agent: 'build',
parts: [{ type: 'text', synthetic: true }],
});
expect(payload.parts[0].text.indexOf('First')).toBeLessThan(payload.parts[0].text.indexOf('Second'));
expect(payload.parts[0].text).toContain('continuing the pre-compaction work');
expect(payload.parts[0].text).toContain('use it silently as background context');
expect(payload.parts[0].text).toContain('Only if no tasks or next steps remain');
expect(payload.parts[0].text).toContain('no more than one short paragraph');
const patch = requests.find((request) => request.method === 'PATCH');
expect(JSON.parse(patch.body).metadata.openchamber.context_obligatory_last_compaction_message_id).toBe('msg_summary');
expect(sessionReads).toBe(2);
runtime.stop();
});
it('ignores ordinary idle events without making requests', async () => {
const fetchImpl = vi.fn();
vi.stubGlobal('fetch', fetchImpl);
const runtime = createContextObligatoryRuntime({
buildOpenCodeUrl: (path) => `http://opencode.test${path}`,
getOpenCodeAuthHeaders: () => ({}),
});
await runtime.processPayload({ type: 'session.status', properties: { sessionID: 'ses_1', status: { type: 'idle' } } });
expect(fetchImpl).not.toHaveBeenCalled();
runtime.stop();
});
});
@@ -10,6 +10,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
sessionRuntime,
sessionAssistRuntime,
sessionGoalRuntime,
contextObligatoryRuntime,
scheduledTasksRuntime,
getHealthCheckInterval,
clearHealthCheckInterval,
@@ -45,6 +46,7 @@ export const createGracefulShutdownRuntime = (dependencies) => {
sessionRuntime.dispose();
sessionAssistRuntime?.stop?.();
sessionGoalRuntime?.stop?.();
contextObligatoryRuntime?.stop?.();
scheduledTasksRuntime?.stop?.();
const healthCheckInterval = getHealthCheckInterval();