diff --git a/src/core.ts b/src/core.ts index 82d272a..fbafd2f 100644 --- a/src/core.ts +++ b/src/core.ts @@ -234,9 +234,8 @@ export const honchoSessionKey = ( export const deriveUserPeerId = (peerName: string, removeUserPrefix: boolean) => { const name = peerName.trim() || "user" - // The sibling claude-honcho / hermes-honcho plugins use the peer name - // verbatim (no case folding), so removeUserPrefix=true must skip normalizeId - // to achieve real parity instead of silently lowercasing into a different peer. + // removeUserPrefix=true keeps the peer name verbatim (no case folding); + // lowercasing would silently target a different peer. return removeUserPrefix ? name : normalizeId(`user:${name}`) } diff --git a/src/honcho-client.ts b/src/honcho-client.ts index 2b9b51f..badfa0b 100644 --- a/src/honcho-client.ts +++ b/src/honcho-client.ts @@ -12,7 +12,7 @@ let pluginVersion: string | undefined /** * Plugin version from package.json, which sits one directory above both `src/` and the bundled - * `dist/`. Read lazily and guarded so a bad path can never stop the plugin from loading. + * `dist/`. Read lazily and guarded. */ export const getPluginVersion = (): string => { if (pluginVersion) return pluginVersion diff --git a/src/import.ts b/src/import.ts index bad8f14..e38e853 100644 --- a/src/import.ts +++ b/src/import.ts @@ -237,7 +237,7 @@ export const transcriptSourceFromV2Client = (client: OpenCodeClientV2): Transcri : { order: "desc", limit: V2_PAGE_SIZE, ...(includeSubagents ? {} : { parentID: null }) }, ) // Filter rather than stop early: the list order and `time.updated` have drifted apart in - // some 2.x builds, and listing is cheap next to reading transcripts. + // some 2.x builds. for (const session of page.data) { if (session.time.updated < start) continue sessions.push({ diff --git a/src/index.ts b/src/index.ts index d1f83b0..0ad988d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,11 +112,16 @@ type ActiveRuntime = RuntimeHandle & { type SessionState = { stableContext: string | null - // Snapshot of stableContext frozen on the first system transform of the - // session. Frozen so provider prefix caches are never invalidated by a - // mid-session change to the system prompt. + // Snapshot of stableContext frozen on the first successful system transform + // of the session. Frozen so provider prefix caches are never invalidated by + // a mid-session change to the system prompt. A failed hydration leaves it + // unsealed so the next transform retries instead of pinning the failure. systemContext: string | null systemContextSealed: boolean + // Hydration currently running for this session. Concurrent triggers share + // this promise instead of each starting their own hydration. Cleared in + // finally so a failed hydration can be retried by the next trigger. + stableContextHydration: Promise | null cachedPromptContext: string | null lastInjectedContext: string | null recentConclusions: string[] @@ -572,9 +577,9 @@ const applyRawLayer = (target: HonchoSettings, raw: Record) => continue } if (BOOLEAN_KEYS.has(key)) { - // Coerce booleans at the read boundary: a config (or hand-edit, which the - // README invites) may carry the string "true"/"false". Only true/"true" is - // true, so a stray "false" can't be truthy and silently flip the prefix. + // Coerce booleans at the read boundary: a config may carry the string + // "true"/"false". Only true/"true" is true, so a stray "false" can't be + // truthy and silently flip the prefix. ;(target as Record)[key] = value === true || (typeof value === "string" && value.trim().toLowerCase() === "true") continue @@ -1091,6 +1096,7 @@ const createSessionState = (): SessionState => ({ stableContext: null, systemContext: null, systemContextSealed: false, + stableContextHydration: null, cachedPromptContext: null, lastInjectedContext: null, recentConclusions: [], @@ -1418,6 +1424,18 @@ export const createHonchoCore = (host: HostAdapter, configPath?: string) => { ) } + const ensureStableContextHydration = async (runtime: ActiveRuntime, state: SessionState) => { + if (state.stableContext) { + return true + } + if (!state.stableContextHydration) { + state.stableContextHydration = hydrateSessionStartContext(runtime, state).finally(() => { + state.stableContextHydration = null + }) + } + return state.stableContextHydration + } + const refreshPromptContext = async (runtime: ActiveRuntime, state: SessionState, query: string) => { const topicKey = deriveTopicKey(query) if (!shouldRefreshPromptContext(state, topicKey, INTERNAL_CONTEXT_REFRESH)) { @@ -1477,7 +1495,7 @@ export const createHonchoCore = (host: HostAdapter, configPath?: string) => { const hydrateSession = (input: Record) => withRuntime(input, async (runtime) => { const state = getState(deriveSessionStateKey(runtime)) - await hydrateSessionStartContext(runtime, state) + await ensureStableContextHydration(runtime, state) await log("info", "Honcho session initialized for OpenCode.", await runtimeStatus(input)) }, undefined) @@ -1520,7 +1538,9 @@ export const createHonchoCore = (host: HostAdapter, configPath?: string) => { }, null) // System prompt additions: the Honcho instruction plus the stable memory snapshot, sealed - // on first use so the system prompt stays byte-identical for the rest of the session. + // on the first successful hydration so the system prompt stays byte-identical for the + // rest of the session. A failed hydration leaves the snapshot unsealed so the next + // transform retries instead of pinning the failed state for the whole session. const systemBlocks = async (input: Record): Promise => { const handle = await deriveRuntimeHandle(host, input, configPath) if (!hasConfiguredAuth(handle.config)) { @@ -1533,9 +1553,16 @@ export const createHonchoCore = (host: HostAdapter, configPath?: string) => { const state = getState(deriveSessionStateKey(handle)) if (!state.systemContextSealed) { if (!state.stableContext) { - await withRuntime(input, async (runtime) => { - await hydrateSessionStartContext(runtime, state) - }, undefined) + const hydrated = await withRuntime( + input, + async (runtime) => { + return await ensureStableContextHydration(runtime, state) + }, + false, + ) + if (!hydrated) { + return blocks + } } state.systemContext = state.stableContext ?? "" state.systemContextSealed = true @@ -2088,9 +2115,9 @@ export const createHonchoRuntimePlugin = return } // Prompt-specific recall rides along with the user turn as a - // synthetic part (codex-honcho parity): it persists at the end of - // the conversation, so the system prompt - and with it the - // provider's prefix cache - is never invalidated mid-session. + // synthetic part: it persists at the end of the conversation, so + // the system prompt - and with it the provider's prefix cache - + // is never invalidated mid-session. const block = await core.captureUserPrompt( input, message, diff --git a/tests/context-injection.test.js b/tests/context-injection.test.js index b87aecc..3777a13 100644 --- a/tests/context-injection.test.js +++ b/tests/context-injection.test.js @@ -243,6 +243,126 @@ test("system transform seals the stable context on the first turn", async () => } }) +test("overlapping session.created and system.transform hydrations share one dialectic fan-out", async () => { + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + const waitFor = async (predicate) => { + for (let i = 0; i < 50; i++) { + if (predicate()) return + await tick() + } + } + + await runWithHarness(async ({ hooks, fetch }) => { + const chatCalls = () => + fetch.calls.filter((call) => call.method === "POST" && /\/peers\/[^/]+\/chat$/.test(call.pathname)) + + // Defer the hydration chat responses so both triggers are guaranteed to + // start while the first hydration is still in flight. + const originalFetch = globalThis.fetch + const releases = [] + globalThis.fetch = async (url, init) => { + const target = new URL(typeof url === "string" ? url : url.toString()) + if (init?.method === "POST" && /\/peers\/[^/]+\/chat$/.test(target.pathname)) { + const started = fetch(url, init) + return new Promise((resolve) => releases.push(() => resolve(started))) + } + return fetch(url, init) + } + try { + const created = hooks.event({ + event: { type: "session.created", properties: { sessionID: "ses-test" } }, + }) + await waitFor(() => chatCalls().length >= 2) + + const output = { system: [] } + const transformed = hooks["experimental.chat.system.transform"](systemInput(), output) + await waitFor(() => chatCalls().length > 2 || output.system.length > 1) + for (let i = 0; i < 10; i++) { + await tick() + } + + expect(chatCalls()).toHaveLength(2) + for (const release of releases.splice(0)) release() + await created + await transformed + } finally { + globalThis.fetch = originalFetch + } + }) +}) + +test("failed hydration is retried by the next trigger instead of cached", async () => { + await runWithHarness(async ({ hooks, fetch }) => { + const chatCalls = () => + fetch.calls.filter((call) => call.method === "POST" && /\/peers\/[^/]+\/chat$/.test(call.pathname)) + + await hooks.event({ + event: { type: "session.created", properties: { sessionID: "ses-test" } }, + }) + expect(chatCalls()).toHaveLength(2) + + const output = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), output) + + expect(output.system).toHaveLength(1) + expect(chatCalls()).toHaveLength(4) + }, { failStableHydration: true }) +}) + +test("failed system transform hydration stays unsealed so the next turn retries", async () => { + await runWithHarness(async ({ hooks, fetch }) => { + const chatCalls = () => + fetch.calls.filter((call) => call.method === "POST" && /\/peers\/[^/]+\/chat$/.test(call.pathname)) + + const firstOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), firstOutput) + + expect(firstOutput.system).toHaveLength(1) + expect(chatCalls()).toHaveLength(2) + + const secondOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), secondOutput) + + expect(secondOutput.system).toHaveLength(1) + expect(chatCalls()).toHaveLength(4) + }, { failStableHydration: true }) +}) + +test("a later successful hydration seals the stable context after an earlier failure", async () => { + let fail = true + await runWithHarness(async ({ hooks }) => { + const originalFetch = globalThis.fetch + globalThis.fetch = async (url, init) => { + const target = new URL(typeof url === "string" ? url : url.toString()) + const hydrationCall = + (init?.method === "GET" && /\/peers\/[^/]+\/context$/.test(target.pathname)) || + (init?.method === "GET" && /\/sessions\/[^/]+\/summaries$/.test(target.pathname)) || + (init?.method === "POST" && /\/peers\/[^/]+\/chat$/.test(target.pathname)) + if (hydrationCall && fail) { + return jsonResponse({ message: "unavailable" }, { status: 400 }) + } + return originalFetch(url, init) + } + try { + const firstOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), firstOutput) + expect(firstOutput.system).toHaveLength(1) + + fail = false + const secondOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), secondOutput) + expect(secondOutput.system).toHaveLength(2) + expect(secondOutput.system[1]).toContain("The user prefers concise engineering analysis.") + + const thirdOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), thirdOutput) + expect(thirdOutput.system).toEqual(secondOutput.system) + } finally { + globalThis.fetch = originalFetch + } + }) +}) + test("chat.message skips recall for trivial prompt text", async () => { await runWithHarness(async ({ hooks, fetch }) => { const chatOutput = {