Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<memory_notice>")) return true;
if (text.startsWith("<RULES>\n") || text.startsWith("<RULES>\r\n")) return true;
if (text.startsWith("<omo-senpi-task>")) return true;
return text.startsWith("Continue working toward the active thread goal.") && text.includes("<untrusted_objective>");
}

export function sentMessages(context: Context): SentMessage[] {
return context.messages.filter(isTransmittedMessage);
}
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve hook messages in incremental payloads

On any non-flatten resident turn containing one of these hooks—especially an automatic goal-continuation turn—this filter removes the hook from sentMessages(), and session-stream.ts later builds the actual delta payload from that same filtered array via buildDeltaPromptBlocks(messages.slice(from)). The SDK therefore never receives the continuation or updated memory/team/task context (and a hook-only turn can submit an empty user payload), even though the turn may be recorded as synchronized; exclude these messages only from continuity hashing, not from the transport message list.

Useful? React with 👍 / 👎.

return !isContentlessUserMessage(message as SentMessage);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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(
`<memory_notice>\n- ${n} previous messages between you and the user are stored in recall memory\n</memory_notice>`,
);
}

function goalContinuation(tokens: number) {
return userMessage(
`Continue working toward the active thread goal.\n\n<untrusted_objective>\nship it\n</untrusted_objective>\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");
});
});