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
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/src/core/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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("<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";
return undefined;
}

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

Expand Down
23 changes: 17 additions & 6 deletions packages/coding-agent/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:

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

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");
});
});