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 1eb05543da..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,5 +1,24 @@ # claude-sdk-oauth +## 2026-09-03 - Ignore rewrite-in-place hook bodies in session continuity hashes + +### What changed + +- `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 + +- 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 + +- `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 + +- LOW: `session-sync.ts` around `sentMessageHashes` and hook-kind detection. + ## 2026-09-03 - Never resume an SDK session id the SDK never acknowledged ### What changed @@ -24,6 +43,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 d255bfb6b1..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 @@ -59,6 +59,61 @@ function isContentlessUserMessage(message: SentMessage): boolean { return Array.isArray(message.content) && message.content.length === 0; } +const VOLATILE_HOOK_CUSTOM_TYPES = new Set([ + "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", +]); + +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. 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 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); + if (customType && VOLATILE_HOOK_CUSTOM_TYPES.has(customType)) return customType; + const text = messageText(message); + 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"; + return undefined; +} + export function sentMessages(context: Context): SentMessage[] { return context.messages.filter(isTransmittedMessage); } @@ -78,8 +133,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 } : { @@ -88,8 +145,8 @@ export function sentMessageHashes(messages: readonly SentMessage[]): string[] { toolName: message.toolName, content: message.content, }, - ), - ); + ); + }); return hashes; } 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 new file mode 100644 index 0000000000..c9c49796c6 --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/claude-sdk-oauth-volatile-hook-continuity.test.ts @@ -0,0 +1,176 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +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"; +import { convertToLlm } from "../../../src/core/messages.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 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 notice(n: number) { + return convertedCustom( + "omo-memory:notice", + `\n- ${n} previous messages between you and the user are stored in recall memory\n`, + ); +} + +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", + sdkSessionIdConfirmed: true, + 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 }; + +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 = [...prior, userMessage("task2"), notice(999)]; + const realEdit = [userMessage("task1-edited"), notice(8), toolResult("t1")]; + + 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 a structural prepend or 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: 3, + reason: "registry_miss", + }); + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(prependedNotice) })).toEqual({ + kind: "flatten", + reason: "sent_stream_diverged", + }); + expect(decideNativeContinuity({ ...input, currentHashes: hashesOf(appended) })).toEqual({ + kind: "reattach", + sdkSessionId: "sdk-1", + from: 3, + 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: 3, + }); + 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"); + }); +});