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
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# 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-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,65 @@ 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",
"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 drops customType, so content signatures are the live detector.
*/
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("<memory_notice>")) return "omo-memory:notice";
if (text.startsWith("<RULES>\n") || text.startsWith("<RULES>\r\n")) return "mindy-team:context-block";
if (text.startsWith("<omo-senpi-task>")) return "senpi-task.usage";
if (text.startsWith("Continue working toward the active thread goal.") && text.includes("<untrusted_objective>")) {
return "goal-continuation";
}
return undefined;
}

export function sentMessages(context: Context): SentMessage[] {
return context.messages.filter(isTransmittedMessage);
}
Expand All @@ -77,8 +136,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 }
: {
Expand All @@ -87,8 +148,8 @@ export function sentMessageHashes(messages: readonly SentMessage[]): string[] {
toolName: message.toolName,
content: message.content,
},
),
);
);
});
return hashes;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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("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]));
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", () => {
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");
});
});