From ac7db27b04ecba674b7231439837fa5d5fabb488 Mon Sep 17 00:00:00 2001 From: Matt Lebrun Date: Fri, 11 Sep 2026 18:36:02 +0800 Subject: [PATCH 1/3] fix/hydration - deduplicate concurrent session-start hydrations with an in-flight guard * Share one hydration promise per session state across session.created and system.transform triggers (4 dialectic chat calls -> 2) * Clear the in-flight marker in finally so a failed hydration is retried by the next trigger, not cached * Add regression tests: overlapping triggers share one dialectic fan-out, and a failed hydration is retried instead of cached (27/27 in container) --- src/index.ts | 21 +++++++++- tests/context-injection.test.js | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1333e10..bf8b1f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,10 @@ type SessionState = { // mid-session change to the system prompt. 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[] @@ -1060,6 +1064,7 @@ const createSessionState = (): SessionState => ({ stableContext: null, systemContext: null, systemContextSealed: false, + stableContextHydration: null, cachedPromptContext: null, lastInjectedContext: null, recentConclusions: [], @@ -1384,6 +1389,18 @@ export const createHonchoRuntimePlugin = ) } + 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)) { @@ -1458,7 +1475,7 @@ export const createHonchoRuntimePlugin = void ensureHonchoSkillInstalled() await withRuntime(payload, 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(payload)) }, undefined) return @@ -1568,7 +1585,7 @@ export const createHonchoRuntimePlugin = if (!state.systemContextSealed) { if (!state.stableContext) { await withRuntime(input, async (runtime) => { - await hydrateSessionStartContext(runtime, state) + await ensureStableContextHydration(runtime, state) }, undefined) } state.systemContext = state.stableContext ?? "" diff --git a/tests/context-injection.test.js b/tests/context-injection.test.js index b87aecc..e042563 100644 --- a/tests/context-injection.test.js +++ b/tests/context-injection.test.js @@ -243,6 +243,78 @@ 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() + } + + // Both triggers await one shared hydration (2 dialectic chat calls). + // Without the in-flight guard each trigger hydrates on its own + // (4 dialectic chat calls). + 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) + + // Hydration failed completely, so nothing stable was sealed... + expect(output.system).toHaveLength(1) + // ...but the second trigger retried instead of inheriting the failed + // in-flight promise. + expect(chatCalls()).toHaveLength(4) + }, { failStableHydration: true }) +}) + test("chat.message skips recall for trivial prompt text", async () => { await runWithHarness(async ({ hooks, fetch }) => { const chatOutput = { From f18af13552fc3ab2f8496e08e1fda53b83304dd4 Mon Sep 17 00:00:00 2001 From: Matt Lebrun Date: Sun, 18 Oct 2026 16:13:34 +0800 Subject: [PATCH 2/3] fix: keep the stable context unsealed after a failed hydration so the next turn retries --- src/index.ts | 24 ++++++++++---- tests/context-injection.test.js | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 94543f6..fe8d5f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -112,9 +112,10 @@ 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 @@ -1537,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)) { @@ -1550,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 ensureStableContextHydration(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 diff --git a/tests/context-injection.test.js b/tests/context-injection.test.js index e042563..69aa497 100644 --- a/tests/context-injection.test.js +++ b/tests/context-injection.test.js @@ -315,6 +315,64 @@ test("failed hydration is retried by the next trigger instead of cached", async }, { 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) + + // Hydration failed completely, so nothing stable was sealed... + expect(firstOutput.system).toHaveLength(1) + expect(chatCalls()).toHaveLength(2) + + const secondOutput = { system: [] } + await hooks["experimental.chat.system.transform"](systemInput(), secondOutput) + + // ...and the second turn retried instead of inheriting the sealed + // failed state. + 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.") + + // Sealed now: the third turn reuses the snapshot without re-hydrating. + 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 = { From db6d4e55b127568a638d6952907c4d1d0d0a617f Mon Sep 17 00:00:00 2001 From: Matt Lebrun Date: Sun, 18 Oct 2026 16:33:42 +0800 Subject: [PATCH 3/3] chore: trim process-noise comments so they describe what is --- src/core.ts | 5 ++--- src/honcho-client.ts | 2 +- src/import.ts | 2 +- src/index.ts | 12 ++++++------ tests/context-injection.test.js | 10 ---------- 5 files changed, 10 insertions(+), 21 deletions(-) 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 fe8d5f5..0ad988d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -577,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 @@ -2115,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 69aa497..3777a13 100644 --- a/tests/context-injection.test.js +++ b/tests/context-injection.test.js @@ -281,9 +281,6 @@ test("overlapping session.created and system.transform hydrations share one dial await tick() } - // Both triggers await one shared hydration (2 dialectic chat calls). - // Without the in-flight guard each trigger hydrates on its own - // (4 dialectic chat calls). expect(chatCalls()).toHaveLength(2) for (const release of releases.splice(0)) release() await created @@ -307,10 +304,7 @@ test("failed hydration is retried by the next trigger instead of cached", async const output = { system: [] } await hooks["experimental.chat.system.transform"](systemInput(), output) - // Hydration failed completely, so nothing stable was sealed... expect(output.system).toHaveLength(1) - // ...but the second trigger retried instead of inheriting the failed - // in-flight promise. expect(chatCalls()).toHaveLength(4) }, { failStableHydration: true }) }) @@ -323,15 +317,12 @@ test("failed system transform hydration stays unsealed so the next turn retries" const firstOutput = { system: [] } await hooks["experimental.chat.system.transform"](systemInput(), firstOutput) - // Hydration failed completely, so nothing stable was sealed... expect(firstOutput.system).toHaveLength(1) expect(chatCalls()).toHaveLength(2) const secondOutput = { system: [] } await hooks["experimental.chat.system.transform"](systemInput(), secondOutput) - // ...and the second turn retried instead of inheriting the sealed - // failed state. expect(secondOutput.system).toHaveLength(1) expect(chatCalls()).toHaveLength(4) }, { failStableHydration: true }) @@ -363,7 +354,6 @@ test("a later successful hydration seals the stable context after an earlier fai expect(secondOutput.system).toHaveLength(2) expect(secondOutput.system[1]).toContain("The user prefers concise engineering analysis.") - // Sealed now: the third turn reuses the snapshot without re-hydrating. const thirdOutput = { system: [] } await hooks["experimental.chat.system.transform"](systemInput(), thirdOutput) expect(thirdOutput.system).toEqual(secondOutput.system)