Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}

Expand Down
2 changes: 1 addition & 1 deletion src/honcho-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
55 changes: 41 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> | null
cachedPromptContext: string | null
lastInjectedContext: string | null
recentConclusions: string[]
Expand Down Expand Up @@ -572,9 +577,9 @@ const applyRawLayer = (target: HonchoSettings, raw: Record<string, unknown>) =>
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<string, unknown>)[key] =
value === true || (typeof value === "string" && value.trim().toLowerCase() === "true")
continue
Expand Down Expand Up @@ -1091,6 +1096,7 @@ const createSessionState = (): SessionState => ({
stableContext: null,
systemContext: null,
systemContextSealed: false,
stableContextHydration: null,
cachedPromptContext: null,
lastInjectedContext: null,
recentConclusions: [],
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1477,7 +1495,7 @@ export const createHonchoCore = (host: HostAdapter, configPath?: string) => {
const hydrateSession = (input: Record<string, unknown>) =>
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)

Expand Down Expand Up @@ -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<string, unknown>): Promise<string[]> => {
const handle = await deriveRuntimeHandle(host, input, configPath)
if (!hasConfiguredAuth(handle.config)) {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions tests/context-injection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading