From 9aab0085ad7d3c511ae423d787749feac85e6341 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sun, 23 Aug 2026 14:16:52 +0900 Subject: [PATCH 1/3] 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/changes.md | 20 +++ .../builtin/claude-sdk-oauth/session-sync.ts | 51 ++++++ ...sdk-oauth-volatile-hook-continuity.test.ts | 158 ++++++++++++++++++ 3 files changed, 229 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/changes.md b/packages/coding-agent/src/core/extensions/builtin/claude-sdk-oauth/changes.md index f3ec8e3443..52969bcfcb 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 hooks in sent-stream continuity hashes + +### What changed + +- `session-sync.ts`: `isTransmittedMessage` excludes omo-memory notices, goal-continuation, mindy-team context blocks, and senpi-task usage injections. Detection uses customType/provenance when present and content signatures after `convertToLlm` strips customType. +- `test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts` locks rewrite/prepend as reattach-equivalent (delta) while a genuine user rewrite still reports `sent_stream_diverged`. + +### Why + +Those hooks are rewritten or prepended every turn. Hashing their converted user-role bodies makes `decideFromBinding` report `sent_stream_diverged` and flatten to a cold seed, burning the Anthropic prompt cache even when the real conversation prefix is intact. Same class as the content-less user-message exclusion (PR #791). + +### Why an extension could not handle it + +Continuity hashes are computed inside this provider before the request is serialized. An extension cannot change `isTransmittedMessage`. + +### Expected merge conflict zones + +- `session-sync.ts` around `isTransmittedMessage` / `isContentlessUserMessage`. + + ## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy ### What changed 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 bb9d04e3d7..c8696cf8ed 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 @@ -58,6 +58,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); } @@ -69,6 +119,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 3fd8bde19c78b110db4c683928514c6bbabcf0c3 Mon Sep 17 00:00:00 2001 From: Code_G Date: Sun, 23 Aug 2026 14:33:25 +0900 Subject: [PATCH 2/3] 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 | 13 ++++---- .../builtin/claude-sdk-oauth/session-sync.ts | 33 +++++++++++-------- ...sdk-oauth-volatile-hook-continuity.test.ts | 23 ++++++------- 3 files changed, 38 insertions(+), 31 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 52969bcfcb..bad8a33b17 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,23 +1,24 @@ # claude-sdk-oauth -## 2026-08-23 - Ignore volatile top hooks in sent-stream continuity hashes +## 2026-08-23 - Ignore volatile top-hook content in continuity hashes ### What changed -- `session-sync.ts`: `isTransmittedMessage` excludes omo-memory notices, goal-continuation, mindy-team context blocks, and senpi-task usage injections. Detection uses customType/provenance when present and content signatures after `convertToLlm` strips customType. -- `test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts` locks rewrite/prepend as reattach-equivalent (delta) while a genuine user rewrite still reports `sent_stream_diverged`. +- `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 or prepended every turn. Hashing their converted user-role bodies makes `decideFromBinding` report `sent_stream_diverged` and flatten to a cold seed, burning the Anthropic prompt cache even when the real conversation prefix is intact. Same class as the content-less user-message exclusion (PR #791). +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 `isTransmittedMessage`. +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 `isTransmittedMessage` / `isContentlessUserMessage`. +- `session-sync.ts` around `sentMessageHashes` / `isTransmittedMessage`. ## 2026-08-21 - Cache provider settings loads by mtime+size to cut lock convoy 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 c8696cf8ed..202e716d65 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 @@ -93,19 +93,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[] { @@ -119,7 +123,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); } @@ -128,8 +131,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 } : { @@ -138,8 +143,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 cf0e5d8a20720854d34546b43be591898ff32502 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/3] 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 202e716d65..2e0e3ecb61 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 @@ -63,6 +63,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")])); });