Compare commits

...
Author SHA1 Message Date
DK09876andClaude Opus 4.6 b22f4bf258 fix(opencode): fix message parsing, shared state, and post-compaction retain
Three bugs fixed:
1. msg.role → msg.info.role: OpenCode SDK wraps role inside info, so all
   messages were silently filtered out, breaking retain and recall (#941)
2. Move PluginState to module level so it persists across sessions instead
   of being recreated per plugin instantiation
3. Reset lastRetainedTurn after compaction so idle-retain resumes when the
   message list shrinks

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-04-13 09:04:11 -07:00
4 changed files with 66 additions and 7 deletions
@@ -232,6 +232,25 @@ describe('compacting hook', () => {
expect(opts.documentId).toMatch(/^sess-1-\d+$/);
});
it('resets lastRetainedTurn so idle-retain resumes after compaction', async () => {
const client = makeClient();
client.recall.mockResolvedValue({ results: [] });
const messages = [
{ info: { role: 'user' }, parts: [{ type: 'text', text: 'Hello' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'Hi' }] },
];
const state = makeState();
// Simulate prior retain at turn 10
state.lastRetainedTurn.set('sess-1', 10);
const output = { context: [] as string[] };
const hooks = createHooks(client, 'bank', makeConfig(), state, makeOpencodeClient(messages));
await hooks['experimental.session.compacting']({ sessionID: 'sess-1' }, output);
// After compaction, lastRetainedTurn should be cleared so idle-retain works again
expect(state.lastRetainedTurn.has('sess-1')).toBe(false);
});
it('does not throw on error', async () => {
const client = makeClient();
client.recall.mockRejectedValue(new Error('Failed'));
@@ -260,6 +260,9 @@ export function createHooks(
if (messages.length && config.autoRetain) {
try {
await retainSession(input.sessionID, messages);
// Reset turn tracking — after compaction the message list shrinks,
// so the old lastRetainedTurn value would block future idle retains.
state.lastRetainedTurn.delete(input.sessionID);
debugLog(config, 'Pre-compaction retain completed');
} catch (e) {
debugLog(config, 'Pre-compaction retain failed:', e);
+9 -7
View File
@@ -25,6 +25,15 @@ import { createTools } from './tools.js';
import { createHooks, type PluginState } from './hooks.js';
import { debugLog } from './config.js';
// Module-level state persists across sessions (plugin is instantiated per session,
// but the module is loaded once per OpenCode server process).
const state: PluginState = {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
const HindsightPlugin: Plugin = async (input, options) => {
const config = loadConfig(options);
@@ -46,13 +55,6 @@ const HindsightPlugin: Plugin = async (input, options) => {
const bankId = deriveBankId(config, input.directory);
debugLog(config, `Initialized with bank: ${bankId}, API: ${apiUrl}`);
const state: PluginState = {
turnCount: 0,
missionsSet: new Set(),
recalledSessions: new Set(),
lastRetainedTurn: new Map(),
};
const tools = createTools(client, bankId, config, state.missionsSet);
const hooks = createHooks(client, bankId, config, state, input.client as unknown as Parameters<typeof createHooks>[4]);
@@ -92,6 +92,41 @@ describe('HindsightPlugin', () => {
});
});
describe('HindsightPlugin state sharing', () => {
beforeEach(() => {
for (const key of Object.keys(process.env)) {
if (key.startsWith('HINDSIGHT_')) delete process.env[key];
}
vi.clearAllMocks();
});
it('shares state across multiple plugin instantiations (sessions)', async () => {
process.env.HINDSIGHT_API_URL = 'http://localhost:8888';
// Simulate two sessions calling the plugin (OpenCode instantiates per session)
const result1 = await HindsightPlugin(mockPluginInput as any);
const result2 = await HindsightPlugin(mockPluginInput as any);
// Trigger session.created on session 1 — should track 'sess-A'
await result1.event!({
event: { type: 'session.created', properties: { info: { id: 'sess-A' } } },
});
// Session 2's system transform should see 'sess-A' because state is shared
const output = { system: [] as string[] };
await result2['experimental.chat.system.transform']!(
{ sessionID: 'sess-A', model: {} },
output,
);
// The recall was attempted (state was shared — sess-A was found in recalledSessions).
// If state were per-instance, result2 would have an empty recalledSessions and skip recall.
// result2 uses the second HindsightClient instance (index 1).
const clientInstance = (HindsightClient as any).mock.instances[1];
expect(clientInstance.recall).toHaveBeenCalled();
});
});
describe('PluginModule default export', () => {
it('exports correct module shape', async () => {
const mod = await import('./index.js');