From 375d2b2acd48b01d5830a9237bb3456200254333 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sun, 23 Aug 2026 14:16:52 +0900 Subject: [PATCH 1/4] fix(claude-sdk-oauth): ignore volatile top hooks in continuity hashes Top-of-turn injections (memory notice, goal continuation, rule/task blocks) convert to user-role bodies. Hashing them makes a rewrite or prepend look like sent_stream_diverged and flatten to a cold seed. Exclude them from isTransmittedMessage the same way content-less user messages are excluded (PR #791). Genuine user rewrites stay fail-closed. --- .../builtin/claude-sdk-oauth/session-sync.ts | 51 ++++++ ...sdk-oauth-volatile-hook-continuity.test.ts | 158 ++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts index d255bfb6b1..95f43db47d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts @@ -59,6 +59,56 @@ function isContentlessUserMessage(message: SentMessage): boolean { return Array.isArray(message.content) && message.content.length === 0; } +const VOLATILE_HOOK_CUSTOM_TYPES = new Set([ + "omo-memory:notice", + "goal-continuation", + "mindy-team:context-block", + "senpi-task.usage", +]); + +type HookInspectable = { + role: string; + content?: unknown; + customType?: unknown; + __piContextProvenance?: { customType?: unknown }; +}; + +function messageText(message: HookInspectable): string { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => + block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block + ? String(block.text) + : "", + ) + .join(""); +} + +function hookCustomType(message: HookInspectable): string | undefined { + if (typeof message.customType === "string") return message.customType; + const provenance = message.__piContextProvenance; + if (provenance && typeof provenance.customType === "string") return provenance.customType; + return undefined; +} + +/** + * Top-of-turn hook injections convert to user-role bodies. Hashing them makes a + * rewrite or prepend look like `sent_stream_diverged` and flatten to a cold + * seed. Exclude them the same way content-less user messages are excluded. + * convertToLlm drops customType, so content signatures are the live detector. + */ +function isVolatileHookMessage(message: HookInspectable): boolean { + const customType = hookCustomType(message); + if (customType && VOLATILE_HOOK_CUSTOM_TYPES.has(customType)) return true; + const text = messageText(message); + if (text.startsWith("")) return true; + if (text.startsWith("\n") || text.startsWith("\r\n")) return true; + if (text.startsWith("")) return true; + return text.startsWith("Continue working toward the active thread goal.") && text.includes(""); +} + export function sentMessages(context: Context): SentMessage[] { return context.messages.filter(isTransmittedMessage); } @@ -70,6 +120,7 @@ export function sentMessages(context: Context): SentMessage[] { */ export function isTransmittedMessage(message: { role: string }): message is SentMessage { if (message.role !== "user" && message.role !== "toolResult") return false; + if (isVolatileHookMessage(message)) return false; return !isContentlessUserMessage(message as SentMessage); } diff --git a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts new file mode 100644 index 0000000000..1d671882f6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { + type ContinuityBindingSnapshot, + type ContinuityEntrySnapshot, + decideNativeContinuity, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-continuity.ts"; +import { + isTransmittedMessage, + sentHashPrefixDigest, + sentMessageHashes, +} from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; + +const ACCOUNT = "default"; +const MODEL = "claude-opus-5"; +const SYSTEM_PROMPT_HASH = "system-prompt-hash"; +const TOOLSET_HASH = "toolset-hash"; + +function userMessage(text: string) { + return { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; +} + +function notice(n: number) { + return userMessage( + `\n- ${n} previous messages between you and the user are stored in recall memory\n`, + ); +} + +function goalContinuation(tokens: number) { + return userMessage( + `Continue working toward the active thread goal.\n\n\nship it\n\n\nUsage so far:\n- Tokens used: ${tokens}`, + ); +} + +function toolResult(id: string) { + return { + role: "toolResult" as const, + toolCallId: id, + toolName: "bash", + content: [{ type: "text" as const, text: "ok" }], + timestamp: 1, + }; +} + +function hashesOf(messages: ReadonlyArray<{ role: string }>): string[] { + return sentMessageHashes(messages.filter(isTransmittedMessage)); +} + +function bindingFrom(messages: ReadonlyArray<{ role: string }>): ContinuityBindingSnapshot { + const hashes = hashesOf(messages); + return { + sdkSessionId: "sdk-1", + accountName: ACCOUNT, + modelId: MODEL, + systemPromptHash: SYSTEM_PROMPT_HASH, + toolsetHash: TOOLSET_HASH, + sentCount: hashes.length, + sentHashes: hashes, + sentPrefixHash: sentHashPrefixDigest(hashes), + lastAssistantUuid: null, + }; +} + +function entryFrom(messages: ReadonlyArray<{ role: string }>): ContinuityEntrySnapshot { + const sentHashes = hashesOf(messages); + return { + sdkSessionId: "sdk-1", + accountName: ACCOUNT, + modelId: MODEL, + systemPromptHash: SYSTEM_PROMPT_HASH, + toolsetHash: TOOLSET_HASH, + sentCount: sentHashes.length, + sentHashes, + lastAssistantUuid: "assistant-uuid", + assistantUuidByIndex: new Map([[1, "assistant-uuid"]]), + pendingForkReason: null, + taintedReason: null, + }; +} + +const fingerprint = { systemPromptHash: SYSTEM_PROMPT_HASH, toolsetHash: TOOLSET_HASH }; + +describe("volatile hook continuity", () => { + const prior = [userMessage("task1"), notice(8), toolResult("t1")]; + const rewrittenNotice = [userMessage("task1"), notice(186), toolResult("t1")]; + const prependedNotice = [notice(280), userMessage("task1"), notice(8), toolResult("t1")]; + const appended = [ + userMessage("task1"), + notice(8), + toolResult("t1"), + userMessage("task2"), + notice(999), + goalContinuation(12), + ]; + const realEdit = [userMessage("task1-edited"), notice(8), toolResult("t1")]; + + it("does not treat converted hook signatures as transmitted", () => { + expect(isTransmittedMessage(notice(8))).toBe(false); + expect(isTransmittedMessage(goalContinuation(1))).toBe(false); + expect(isTransmittedMessage(userMessage("task1"))).toBe(true); + expect(isTransmittedMessage(toolResult("t1"))).toBe(true); + expect(isTransmittedMessage(userMessage("Continue working toward the active thread goal"))).toBe(true); + }); + + it("reattaches after a hook rewrite or prepend, but still diverges on a real user rewrite", () => { + const binding = bindingFrom(prior); + const input = { + entry: undefined, + binding, + accountName: ACCOUNT, + modelId: MODEL, + fingerprint, + transcriptAvailable: true, + idleExpired: false, + }; + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(rewrittenNotice) })).toEqual({ + kind: "reattach", + sdkSessionId: "sdk-1", + from: 2, + reason: "registry_miss", + }); + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(prependedNotice) })).toEqual({ + kind: "reattach", + sdkSessionId: "sdk-1", + from: 2, + reason: "registry_miss", + }); + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(appended) })).toEqual({ + kind: "reattach", + sdkSessionId: "sdk-1", + from: 2, + reason: "registry_miss", + }); + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(realEdit) })).toEqual({ + kind: "flatten", + reason: "sent_stream_diverged", + }); + }); + + it("keeps an in-process hook rewrite as a delta", () => { + const entry = entryFrom(prior); + const input = { + entry, + binding: undefined, + accountName: ACCOUNT, + modelId: MODEL, + fingerprint, + transcriptAvailable: true, + idleExpired: false, + }; + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(rewrittenNotice) })).toEqual({ + kind: "delta", + from: 2, + }); + const edit = decideNativeContinuity({ ...input, currentHashes: hashesOf(realEdit) }); + expect(edit.kind === "flatten" || edit.kind === "fork").toBe(true); + expect("reason" in edit ? edit.reason : undefined).toBe("sent_stream_diverged"); + }); +}); From 87963f00467388cf1f27b350f962309b25b78628 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sun, 23 Aug 2026 14:33:25 +0900 Subject: [PATCH 2/4] fix(claude-sdk-oauth): hash volatile hooks by kind, keep them on the wire Review P1: isTransmittedMessage also feeds buildDeltaPromptBlocks(messages.slice(from)). Dropping hooks there would omit a goal-continuation-only turn. Neutralize hook content inside sentMessageHashes instead so rewrite does not flatten, while from still indexes the full transmitted list. --- .../builtin/claude-sdk-oauth/changes.md | 21 ++++++++++++ .../builtin/claude-sdk-oauth/session-sync.ts | 33 +++++++++++-------- ...sdk-oauth-volatile-hook-continuity.test.ts | 23 ++++++------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index 1eb05543da..2f8e00b234 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,5 +1,25 @@ # claude-sdk-oauth +## 2026-08-23 - Ignore volatile top-hook content in continuity hashes + +### What changed + +- `session-sync.ts`: `sentMessageHashes` digests omo-memory notices, goal-continuation, mindy-team context blocks, and senpi-task usage as `{ role, volatileHook }` instead of their bodies. `isTransmittedMessage` is unchanged so those messages still occupy a slot in `messages.slice(from)`. +- Detection uses customType/provenance when present and content signatures after `convertToLlm` strips customType. +- Regression: hook rewrite reattaches; a structural prepend or a genuine user rewrite still reports `sent_stream_diverged`. + +### Why + +Those hooks are rewritten every turn. Hashing their converted user-role bodies makes `decideFromBinding` report `sent_stream_diverged` and flatten to a cold seed. Dropping them from `isTransmittedMessage` would also drop them from `buildDeltaPromptBlocks(messages.slice(from))`, so a goal-continuation-only turn would send an empty delta. Same class as PR #791, but hash-only. + +### Why an extension could not handle it + +Continuity hashes are computed inside this provider before the request is serialized. An extension cannot change `sentMessageHashes`. + +### Expected merge conflict zones + +- `session-sync.ts` around `sentMessageHashes` / `isTransmittedMessage`. + ## 2026-09-03 - Never resume an SDK session id the SDK never acknowledged ### What changed @@ -24,6 +44,7 @@ - LOW: `session-continuity.ts` `decideNativeContinuity` entry branch, `session-turn-attempt.ts` publish/catch paths, `session-registry-pump.ts` init/claim handling, the `ContinuityReason` union. ## 2026-09-03 - Map malformed content entries to text instead of broken image blocks + ### What changed - `content-blocks.ts`: new shared `appendSdkContentBlocks` mapper. Raw string entries become text blocks, well-formed images keep `media_type`/`data`, and anything else becomes an omission placeholder. diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts index 95f43db47d..79022e7f83 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts @@ -94,19 +94,23 @@ function hookCustomType(message: HookInspectable): string | undefined { } /** - * Top-of-turn hook injections convert to user-role bodies. Hashing them makes a - * rewrite or prepend look like `sent_stream_diverged` and flatten to a cold - * seed. Exclude them the same way content-less user messages are excluded. + * Top-of-turn hook injections convert to user-role bodies. Their *content* + * must not participate in continuity hashes (a rewrite would look like + * sent_stream_diverged and flatten). They still have to stay in the transmitted + * set: `from` indexes that same list when building the SDK delta payload. * convertToLlm drops customType, so content signatures are the live detector. */ -function isVolatileHookMessage(message: HookInspectable): boolean { +function volatileHookKind(message: HookInspectable): string | undefined { const customType = hookCustomType(message); - if (customType && VOLATILE_HOOK_CUSTOM_TYPES.has(customType)) return true; + if (customType && VOLATILE_HOOK_CUSTOM_TYPES.has(customType)) return customType; const text = messageText(message); - if (text.startsWith("")) return true; - if (text.startsWith("\n") || text.startsWith("\r\n")) return true; - if (text.startsWith("")) return true; - return text.startsWith("Continue working toward the active thread goal.") && text.includes(""); + if (text.startsWith("")) return "omo-memory:notice"; + if (text.startsWith("\n") || text.startsWith("\r\n")) return "mindy-team:context-block"; + if (text.startsWith("")) return "senpi-task.usage"; + if (text.startsWith("Continue working toward the active thread goal.") && text.includes("")) { + return "goal-continuation"; + } + return undefined; } export function sentMessages(context: Context): SentMessage[] { @@ -120,7 +124,6 @@ export function sentMessages(context: Context): SentMessage[] { */ export function isTransmittedMessage(message: { role: string }): message is SentMessage { if (message.role !== "user" && message.role !== "toolResult") return false; - if (isVolatileHookMessage(message)) return false; return !isContentlessUserMessage(message as SentMessage); } @@ -129,8 +132,10 @@ export function isTransmittedMessage(message: { role: string }): message is Sent * list that disagrees with another caller's by forgetting the filter. */ export function sentMessageHashes(messages: readonly SentMessage[]): string[] { - const hashes = messages.filter(isTransmittedMessage).map((message) => - digest( + const hashes = messages.filter(isTransmittedMessage).map((message) => { + const hook = volatileHookKind(message); + if (hook) return digest({ role: message.role, volatileHook: hook }); + return digest( message.role === "user" ? { role: message.role, content: message.content } : { @@ -139,8 +144,8 @@ export function sentMessageHashes(messages: readonly SentMessage[]): string[] { toolName: message.toolName, content: message.content, }, - ), - ); + ); + }); return hashes; } diff --git a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts index 1d671882f6..21b96400a7 100644 --- a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts +++ b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts @@ -93,15 +93,18 @@ describe("volatile hook continuity", () => { ]; const realEdit = [userMessage("task1-edited"), notice(8), toolResult("t1")]; - it("does not treat converted hook signatures as transmitted", () => { - expect(isTransmittedMessage(notice(8))).toBe(false); - expect(isTransmittedMessage(goalContinuation(1))).toBe(false); + it("keeps converted hooks in the transmitted set but hashes them by kind, not body", () => { + expect(isTransmittedMessage(notice(8))).toBe(true); + expect(isTransmittedMessage(goalContinuation(1))).toBe(true); expect(isTransmittedMessage(userMessage("task1"))).toBe(true); expect(isTransmittedMessage(toolResult("t1"))).toBe(true); expect(isTransmittedMessage(userMessage("Continue working toward the active thread goal"))).toBe(true); + expect(sentMessageHashes([notice(8)])).toEqual(sentMessageHashes([notice(186)])); + expect(sentMessageHashes([goalContinuation(1)])).toEqual(sentMessageHashes([goalContinuation(99)])); + expect(sentMessageHashes([userMessage("task1")])).not.toEqual(sentMessageHashes([userMessage("task1-edited")])); }); - it("reattaches after a hook rewrite or prepend, but still diverges on a real user rewrite", () => { + it("reattaches after a hook rewrite, but still diverges on prepend or a real user rewrite", () => { const binding = bindingFrom(prior); const input = { entry: undefined, @@ -115,19 +118,17 @@ describe("volatile hook continuity", () => { expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(rewrittenNotice) })).toEqual({ kind: "reattach", sdkSessionId: "sdk-1", - from: 2, + from: 3, reason: "registry_miss", }); expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(prependedNotice) })).toEqual({ - kind: "reattach", - sdkSessionId: "sdk-1", - from: 2, - reason: "registry_miss", + kind: "flatten", + reason: "sent_stream_diverged", }); expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(appended) })).toEqual({ kind: "reattach", sdkSessionId: "sdk-1", - from: 2, + from: 3, reason: "registry_miss", }); expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(realEdit) })).toEqual({ @@ -149,7 +150,7 @@ describe("volatile hook continuity", () => { }; expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(rewrittenNotice) })).toEqual({ kind: "delta", - from: 2, + from: 3, }); const edit = decideNativeContinuity({ ...input, currentHashes: hashesOf(realEdit) }); expect(edit.kind === "flatten" || edit.kind === "fork").toBe(true); From 2a3b3055ffbc06343d1e70d075f6711845c77888 Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:28:51 +0900 Subject: [PATCH 3/4] fix(claude-sdk-oauth): add monitor notification and wake hook to volatile hook types Co-authored-by: Code_G <288527233+codeg-dev@users.noreply.github.com> Signed-off-by: Code_G <288527233+codeg-dev@users.noreply.github.com> --- .../extensions/builtin/claude-sdk-oauth/session-sync.ts | 5 +++++ .../claude-sdk-oauth-volatile-hook-continuity.test.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts index 79022e7f83..91c6912ca4 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts @@ -64,6 +64,11 @@ const VOLATILE_HOOK_CUSTOM_TYPES = new Set([ "goal-continuation", "mindy-team:context-block", "senpi-task.usage", + "senpi-monitor:notification", + "omo-senpi:wake", + "senpi-terminal:notification", + "omo-ultrawork:directive", + "omo-mass-ulw:skill-pointer", ]); type HookInspectable = { diff --git a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts index 21b96400a7..02223f28fd 100644 --- a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts +++ b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts @@ -101,6 +101,12 @@ describe("volatile hook continuity", () => { expect(isTransmittedMessage(userMessage("Continue working toward the active thread goal"))).toBe(true); expect(sentMessageHashes([notice(8)])).toEqual(sentMessageHashes([notice(186)])); expect(sentMessageHashes([goalContinuation(1)])).toEqual(sentMessageHashes([goalContinuation(99)])); + const mon1 = { role: "user" as const, content: [{ type: "text" as const, text: "job 1 finished" }], customType: "senpi-monitor:notification", timestamp: 1 }; + const mon2 = { role: "user" as const, content: [{ type: "text" as const, text: "job 2 finished" }], customType: "senpi-monitor:notification", timestamp: 2 }; + expect(sentMessageHashes([mon1])).toEqual(sentMessageHashes([mon2])); + const wake1 = { role: "user" as const, content: [{ type: "text" as const, text: "wake A" }], customType: "omo-senpi:wake", timestamp: 1 }; + const wake2 = { role: "user" as const, content: [{ type: "text" as const, text: "wake B" }], customType: "omo-senpi:wake", timestamp: 2 }; + expect(sentMessageHashes([wake1])).toEqual(sentMessageHashes([wake2])); expect(sentMessageHashes([userMessage("task1")])).not.toEqual(sentMessageHashes([userMessage("task1-edited")])); }); From 52b484370a7e12c06a836c97df4639d23a9b2b8c Mon Sep 17 00:00:00 2001 From: Code_G <288527233+codeg-dev@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:15:51 +0900 Subject: [PATCH 4/4] fix(claude-sdk-oauth): preserve hook provenance in continuity hashes --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/core/changes.md | 18 +++++ .../builtin/claude-sdk-oauth/changes.md | 13 ++-- .../builtin/claude-sdk-oauth/session-sync.ts | 6 +- packages/coding-agent/src/core/messages.ts | 23 ++++-- ...sdk-oauth-volatile-hook-continuity.test.ts | 71 +++++++++++-------- 6 files changed, 85 insertions(+), 48 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 382f547806..68cf5e3933 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Claude SDK OAuth continuity no longer flattens a resumable session when a converted extension hook rewrites its body; rewrite-in-place hooks remain transmitted and are matched by their request-local custom-message provenance. + ### New Features ### Breaking Changes diff --git a/packages/coding-agent/src/core/changes.md b/packages/coding-agent/src/core/changes.md index b512899e71..9f891a3f57 100644 --- a/packages/coding-agent/src/core/changes.md +++ b/packages/coding-agent/src/core/changes.md @@ -1,5 +1,23 @@ # changes +## 2026-09-03 - Preserve custom message type through LLM conversion provenance + +### What changed + +- `packages/coding-agent/src/core/messages.ts`: the custom-message arm of `convertToLlm` stamps `customType` into request-local `__piContextProvenance` while preserving any existing provenance fields. + +### Why + +- Provider-private continuity logic must distinguish converted extension hooks from ordinary user messages after conversion removes the `role: custom` shape; content-only inference cannot reliably identify monitor, wake, terminal, or directive messages. + +### Why an extension could not handle it + +- `convertToLlm` is the core representation boundary used before provider dispatch. An extension cannot restore source type metadata after that conversion without changing provider-visible content. + +### Expected merge conflict zones + +- LOW: `messages.ts` in the `case "custom"` conversion arm. + ## 2026-09-03 - Make eval-only tool routing unconditional and registry-aware ### What changed diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index 2f8e00b234..ab62240c58 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md @@ -1,24 +1,23 @@ # claude-sdk-oauth -## 2026-08-23 - Ignore volatile top-hook content in continuity hashes +## 2026-09-03 - Ignore rewrite-in-place hook bodies in session continuity hashes ### What changed -- `session-sync.ts`: `sentMessageHashes` digests omo-memory notices, goal-continuation, mindy-team context blocks, and senpi-task usage as `{ role, volatileHook }` instead of their bodies. `isTransmittedMessage` is unchanged so those messages still occupy a slot in `messages.slice(from)`. -- Detection uses customType/provenance when present and content signatures after `convertToLlm` strips customType. -- Regression: hook rewrite reattaches; a structural prepend or a genuine user rewrite still reports `sent_stream_diverged`. +- `session-sync.ts`: `sentMessageHashes` hashes rewrite-in-place custom hooks by their provenance `customType` while leaving them in the transmitted list; memory notices, RULES blocks, and senpi-task notices retain content-prefix compatibility. Append-only goal continuations remain content-significant. +- `test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts`: coverage now hashes actual `convertToLlm` output, confirms reattach bindings, and keeps structural prepend and real-user rewrite cases fail-closed. ### Why -Those hooks are rewritten every turn. Hashing their converted user-role bodies makes `decideFromBinding` report `sent_stream_diverged` and flatten to a cold seed. Dropping them from `isTransmittedMessage` would also drop them from `buildDeltaPromptBlocks(messages.slice(from))`, so a goal-continuation-only turn would send an empty delta. Same class as PR #791, but hash-only. +- Rewritten hook bodies can falsely report `sent_stream_diverged` and flatten a valid SDK session, but removing hooks from the transmitted list would omit hook-only turns from the resident delta payload. ### Why an extension could not handle it -Continuity hashes are computed inside this provider before the request is serialized. An extension cannot change `sentMessageHashes`. +- `session-sync.ts` owns the provider-private continuity ledger and delta index; no extension hook can alter those hashes after provider dispatch. ### Expected merge conflict zones -- `session-sync.ts` around `sentMessageHashes` / `isTransmittedMessage`. +- LOW: `session-sync.ts` around `sentMessageHashes` and hook-kind detection. ## 2026-09-03 - Never resume an SDK session id the SDK never acknowledged diff --git a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts index 91c6912ca4..34b79db253 100644 --- a/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts +++ b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts @@ -61,7 +61,6 @@ function isContentlessUserMessage(message: SentMessage): boolean { const VOLATILE_HOOK_CUSTOM_TYPES = new Set([ "omo-memory:notice", - "goal-continuation", "mindy-team:context-block", "senpi-task.usage", "senpi-monitor:notification", @@ -103,7 +102,7 @@ function hookCustomType(message: HookInspectable): string | undefined { * must not participate in continuity hashes (a rewrite would look like * sent_stream_diverged and flatten). They still have to stay in the transmitted * set: `from` indexes that same list when building the SDK delta payload. - * convertToLlm drops customType, so content signatures are the live detector. + * convertToLlm preserves customType in request-local provenance; content signatures keep compatibility with already-converted rewrite-in-place hooks. */ function volatileHookKind(message: HookInspectable): string | undefined { const customType = hookCustomType(message); @@ -112,9 +111,6 @@ function volatileHookKind(message: HookInspectable): string | undefined { if (text.startsWith("")) return "omo-memory:notice"; if (text.startsWith("\n") || text.startsWith("\r\n")) return "mindy-team:context-block"; if (text.startsWith("")) return "senpi-task.usage"; - if (text.startsWith("Continue working toward the active thread goal.") && text.includes("")) { - return "goal-continuation"; - } return undefined; } diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 80ace6ce60..19974da055 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -6,7 +6,14 @@ */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import { copyContextProvenance, type ImageContent, type Message, type TextContent } from "@earendil-works/pi-ai"; +import { + CONTEXT_PROVENANCE_FIELD, + copyContextProvenance, + getContextProvenance, + type ImageContent, + type Message, + type TextContent, +} from "@earendil-works/pi-ai"; export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: @@ -194,11 +201,15 @@ export function convertToLlm(messages: AgentMessage[]): Message[] { } const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content; - return withContextProvenance(m, { - role: "user", - content, - timestamp: m.timestamp, - }); + const provenance = { ...getContextProvenance(m), customType: m.customType }; + return Object.assign( + { + role: "user" as const, + content, + timestamp: m.timestamp, + }, + { [CONTEXT_PROVENANCE_FIELD]: provenance }, + ); } case "branchSummary": return withContextProvenance(m, { diff --git a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts index 02223f28fd..c9c49796c6 100644 --- a/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts +++ b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import { type ContinuityBindingSnapshot, @@ -9,6 +10,7 @@ import { sentHashPrefixDigest, sentMessageHashes, } from "../../../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts"; +import { convertToLlm } from "../../../src/core/messages.ts"; const ACCOUNT = "default"; const MODEL = "claude-opus-5"; @@ -19,15 +21,20 @@ function userMessage(text: string) { return { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: 1 }; } -function notice(n: number) { - return userMessage( - `\n- ${n} previous messages between you and the user are stored in recall memory\n`, - ); +function customMessage(customType: string, text: string): AgentMessage { + return { role: "custom", customType, content: text, display: false, timestamp: 1 }; +} + +function convertedCustom(customType: string, text: string) { + const [message] = convertToLlm([customMessage(customType, text)]); + if (message?.role !== "user") throw new Error(`custom message ${customType} did not convert to user role`); + return message; } -function goalContinuation(tokens: number) { - return userMessage( - `Continue working toward the active thread goal.\n\n\nship it\n\n\nUsage so far:\n- Tokens used: ${tokens}`, +function notice(n: number) { + return convertedCustom( + "omo-memory:notice", + `\n- ${n} previous messages between you and the user are stored in recall memory\n`, ); } @@ -49,6 +56,7 @@ function bindingFrom(messages: ReadonlyArray<{ role: string }>): ContinuityBindi const hashes = hashesOf(messages); return { sdkSessionId: "sdk-1", + sdkSessionIdConfirmed: true, accountName: ACCOUNT, modelId: MODEL, systemPromptHash: SYSTEM_PROMPT_HASH, @@ -79,38 +87,41 @@ function entryFrom(messages: ReadonlyArray<{ role: string }>): ContinuityEntrySn const fingerprint = { systemPromptHash: SYSTEM_PROMPT_HASH, toolsetHash: TOOLSET_HASH }; +const VOLATILE_CUSTOM_TYPES = [ + "omo-memory:notice", + "mindy-team:context-block", + "senpi-task.usage", + "senpi-monitor:notification", + "omo-senpi:wake", + "senpi-terminal:notification", + "omo-ultrawork:directive", + "omo-mass-ulw:skill-pointer", +] as const; + describe("volatile hook continuity", () => { const prior = [userMessage("task1"), notice(8), toolResult("t1")]; const rewrittenNotice = [userMessage("task1"), notice(186), toolResult("t1")]; const prependedNotice = [notice(280), userMessage("task1"), notice(8), toolResult("t1")]; - const appended = [ - userMessage("task1"), - notice(8), - toolResult("t1"), - userMessage("task2"), - notice(999), - goalContinuation(12), - ]; + const appended = [...prior, userMessage("task2"), notice(999)]; const realEdit = [userMessage("task1-edited"), notice(8), toolResult("t1")]; - it("keeps converted hooks in the transmitted set but hashes them by kind, not body", () => { - expect(isTransmittedMessage(notice(8))).toBe(true); - expect(isTransmittedMessage(goalContinuation(1))).toBe(true); - expect(isTransmittedMessage(userMessage("task1"))).toBe(true); - expect(isTransmittedMessage(toolResult("t1"))).toBe(true); - expect(isTransmittedMessage(userMessage("Continue working toward the active thread goal"))).toBe(true); - expect(sentMessageHashes([notice(8)])).toEqual(sentMessageHashes([notice(186)])); - expect(sentMessageHashes([goalContinuation(1)])).toEqual(sentMessageHashes([goalContinuation(99)])); - const mon1 = { role: "user" as const, content: [{ type: "text" as const, text: "job 1 finished" }], customType: "senpi-monitor:notification", timestamp: 1 }; - const mon2 = { role: "user" as const, content: [{ type: "text" as const, text: "job 2 finished" }], customType: "senpi-monitor:notification", timestamp: 2 }; - expect(sentMessageHashes([mon1])).toEqual(sentMessageHashes([mon2])); - const wake1 = { role: "user" as const, content: [{ type: "text" as const, text: "wake A" }], customType: "omo-senpi:wake", timestamp: 1 }; - const wake2 = { role: "user" as const, content: [{ type: "text" as const, text: "wake B" }], customType: "omo-senpi:wake", timestamp: 2 }; - expect(sentMessageHashes([wake1])).toEqual(sentMessageHashes([wake2])); + it("hashes convertToLlm custom-message output by provenance kind while keeping every hook transmitted", () => { + for (const customType of VOLATILE_CUSTOM_TYPES) { + const first = convertedCustom(customType, `${customType}: first body`); + const second = convertedCustom(customType, `${customType}: rewritten body`); + expect(isTransmittedMessage(first), customType).toBe(true); + expect(sentMessageHashes([first]), customType).toEqual(sentMessageHashes([second])); + } + }); + + it("keeps append-only goal continuations and ordinary user content hash-significant", () => { + const firstGoal = convertedCustom("goal-continuation", "Continue goal: first body"); + const rewrittenGoal = convertedCustom("goal-continuation", "Continue goal: rewritten body"); + expect(sentMessageHashes([firstGoal])).not.toEqual(sentMessageHashes([rewrittenGoal])); expect(sentMessageHashes([userMessage("task1")])).not.toEqual(sentMessageHashes([userMessage("task1-edited")])); }); - it("reattaches after a hook rewrite, but still diverges on prepend or a real user rewrite", () => { + it("reattaches after a hook rewrite, but still diverges on a structural prepend or real user rewrite", () => { const binding = bindingFrom(prior); const input = { entry: undefined,