Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Changed

### Fixed
- Claude SDK OAuth restart continuity now persists from the compaction-aware active context, survives goal-continuation metadata and a resident entry closing before `message_end`, and therefore avoids flattening and re-sending an oversized full transcript after restart.

- `/gpt-account remove` (and every other account-removal path) now leaves the remaining account actually in use. Removing the account the stored credential's top-level fields projected used to keep those fields pointing at the deleted account's tokens, and a provider left with one account does not enter credential rotation, so requests kept authenticating as the removed account; the surviving account's material is now projected onto those fields.
- Claude SDK OAuth classifies Fable-style "requires usage credits" failures as a non-retryable entitlement instead of a rate limit, so the account is not blocked for 60s and AgentSession can fall through to the next model ([#709](https://github.com/code-yeongyu/senpi/issues/709)).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# claude-sdk-oauth

## 2026-09-03 - Persist compaction-aware restart bindings

### What changed

- `session-binding.ts`: requires a matching sent-prefix digest for closed-entry fallback bindings, and admits only labels, approved metadata, goal-cache warmups, and the goal-continuation custom message after a committed assistant.
- `session-registry-wiring.ts`: hashes the converted, compaction-aware session context at `message_end`, including an empty transmitted-message list, and falls back to a verified binding when the resident entry has already closed.

### Why

- Restart persistence must use the same provider-visible projection as admission, preserve valid count-zero anchors, and fail closed when lineage identity is unavailable or later conversation may already have reached the SDK.

### Why an extension could not handle it

- The SDK sent-stream digest, resident binding lifecycle, and restart sidecar are private to this builtin provider lane; no external extension hook can safely reconstruct them at `message_end`.

### Expected merge conflict zones

- MEDIUM: `session-binding.ts` around stored-binding identity checks and safe suffix validation.
- MEDIUM: `session-registry-wiring.ts` at the `message_end` persistence boundary and converted context hashing.

## 2026-09-03 - Classify Fable usage-credit failures as entitlement

### What changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { GOAL_CONTINUATION_MESSAGE_TYPE } from "../../../messages.ts";
import type { StoredBinding } from "./session-binding-store.ts";
import { assistantContentHash } from "./session-commit-boundary.ts";
import type { ContinuityBinding } from "./session-reattach.ts";
import { isTransmittedMessage, type SentMessage, sentHashPrefixDigest, sentMessageHashes } from "./session-sync.ts";
import { sentHashPrefixDigest } from "./session-sync.ts";

export const BINDING_ENTRY_TYPE = "claude-sdk-oauth-binding";
export const BINDING_MARKER = { schemaVersion: 2, marker: true } as const;
Expand Down Expand Up @@ -66,25 +67,33 @@ export function storedBindingFromEntry(
};
}

/** Hashes for the user/toolResult messages the persisted branch already carries. */
export function sentHashesFromBranch(branch: readonly BranchEntry[]): string[] {
// This walk is not compaction-aware, but admission compares against the
// compaction-truncated context. Anchoring across a boundary would inflate
// sentCount and flatten every later restart, so decline to anchor instead.
if (branch.some((entry) => entry.type === "compaction")) return [];
const messages: SentMessage[] = [];
for (const entry of branch) {
if (entry.type !== "message") continue;
if (isSentMessage(entry.message)) messages.push(entry.message);
}
return sentMessageHashes(messages);
}

function isSentMessage(value: unknown): value is SentMessage {
if (typeof value !== "object" || value === null) return false;
if (!("role" in value) || typeof value.role !== "string" || !("content" in value)) return false;
// Same selection rule the context path uses, so the digests cannot diverge.
return isTransmittedMessage(value as { role: string });
/** Persist a completed turn whose resident registry entry closed before `message_end`. */
export function storedBindingFromBinding(
binding: ContinuityBinding,
hashes: readonly string[],
anchor: StoredBindingAnchor,
): StoredBinding | undefined {
if (binding.sdkSessionIdConfirmed === false) return undefined;
if (binding.sentCount !== hashes.length) return undefined;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const expectedDigest = sentHashPrefixDigest(hashes);
const bindingDigest =
binding.sentPrefixHash ?? (binding.sentHashes.length > 0 ? sentHashPrefixDigest(binding.sentHashes) : undefined);
if (bindingDigest === undefined || bindingDigest !== expectedDigest) return undefined;
return {
schemaVersion: 1,
sessionPath: anchor.sessionPath,
sessionId: anchor.sessionId,
markerEntryId: anchor.markerEntryId,
sdkSessionId: binding.sdkSessionId,
sentCount: hashes.length,
sentPrefixHash: sentHashPrefixDigest(hashes),
assistantContentHash: anchor.assistantContentHash,
lastAssistantUuid: binding.lastAssistantUuid,
accountName: binding.accountName,
modelId: binding.modelId,
systemPromptHash: binding.systemPromptHash,
toolsetHash: binding.toolsetHash,
};
}

export function bindingFromStoredBranch(
Expand Down Expand Up @@ -118,10 +127,12 @@ const SAFE_BINDING_SUFFIX_TYPES: ReadonlySet<string> = new Set([
"senpi.hooks.stop-output",
"pi-rules.scan",
"rule-activation",
"goal-cache-warmup",
]);

function isSafeBindingSuffix(entry: BranchEntry): boolean {
if (entry.type === "label") return true;
if (entry.type === "custom_message") return entry.customType === GOAL_CONTINUATION_MESSAGE_TYPE;
return entry.type === "custom" && entry.customType !== undefined && SAFE_BINDING_SUFFIX_TYPES.has(entry.customType);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { convertToLlm } from "../../../messages.ts";
import type { ExtensionAPI, ExtensionContext } from "../../types.ts";
import { CLAUDE_SDK_OAUTH_PROVIDER_ID } from "./account-management.ts";
import {
BINDING_ENTRY_TYPE,
BINDING_MARKER,
type BindingInvalidation,
bindingFromStoredBranch,
sentHashesFromBranch,
storedBindingFromBinding,
storedBindingFromEntry,
} from "./session-binding.ts";
import { deleteStoredBinding, readStoredBinding, writeStoredBinding } from "./session-binding-store.ts";
Expand All @@ -16,15 +17,15 @@ import {
isResidentAssistant,
isTerminalFailure,
} from "./session-commit-boundary.ts";
import { bindingFromEntry, forgetBinding, rememberBinding } from "./session-reattach.ts";
import { bindingFromEntry, forgetBinding, getBinding, rememberBinding } from "./session-reattach.ts";
import {
closeSession,
getSession,
recordBranchInfo,
recordPendingFork,
switchSessionModel,
} from "./session-registry.ts";
import { sentHashesForEntry } from "./session-sync.ts";
import { sentHashesForEntry, sentMessageHashes, sentMessages } from "./session-sync.ts";

const commitBoundary = new AssistantCommitBoundary();

Expand Down Expand Up @@ -122,12 +123,14 @@ export function registerSessionRegistry(
if (event.message.role !== "assistant") return;
const sessionId = ctx.sessionManager.getSessionId();
const entry = getSession(sessionId);
if (!entry) return;
const binding = getBinding(sessionId);
const modelId = entry?.modelId ?? binding?.modelId;
if (!modelId) return;
if (isTerminalFailure(event.message)) {
commitBoundary.forget(sessionId);
return;
}
const outcome = commitBoundary.commit(sessionId, event.message, entry.modelId);
const outcome = commitBoundary.commit(sessionId, event.message, modelId);
if (outcome === "rewritten") {
recordPendingFork(sessionId, "assistant_rewritten");
await invalidateBinding(pi, ctx, "assistant_rewritten");
Expand All @@ -137,20 +140,31 @@ export function registerSessionRegistry(
if (outcome !== "clean") return;
const sessionFile = ctx.sessionManager.getSessionFile?.();
if (!sessionFile || !pi.appendEntry) return;
const hashes = sentHashesFromBranch(ctx.sessionManager.getBranch());
if (hashes.length === 0) return;
pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER);
const markerEntryId = ctx.sessionManager.getLeafId();
if (!markerEntryId) return;
await writeStoredBinding(
sessionFile,
storedBindingFromEntry(entry, hashes, {
const context = ctx.sessionManager.buildSessionContext();
const hashes = sentMessageHashes(sentMessages({ ...context, messages: convertToLlm(context.messages) }));
const committedAssistantHash = assistantContentHash(event.message);
const recordFor = (markerEntryId: string) => {
const anchor = {
sessionPath: sessionFile,
sessionId,
markerEntryId,
assistantContentHash: assistantContentHash(event.message),
}),
);
assistantContentHash: committedAssistantHash,
};
return entry
? storedBindingFromEntry(entry, hashes, anchor)
: binding
? storedBindingFromBinding(binding, hashes, anchor)
: undefined;
};
// Validate before touching the branch: a rejected closed-entry fallback must
// not leave a marker-only entry that retires the still-valid older sidecar.
if (!recordFor("pending")) return;
pi.appendEntry(BINDING_ENTRY_TYPE, BINDING_MARKER);
const markerEntryId = ctx.sessionManager.getLeafId();
if (!markerEntryId) return;
const stored = recordFor(markerEntryId);
if (!stored) return;
await writeStoredBinding(sessionFile, stored);
});
pi.on("session_shutdown", (event, ctx) => {
closeSession(ctx.sessionManager.getSessionId(), event.reason);
Expand Down
93 changes: 53 additions & 40 deletions packages/coding-agent/test/claude-sdk-oauth-binding-anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import {
BINDING_ENTRY_TYPE,
BINDING_MARKER,
bindingFromStoredBranch,
sentHashesFromBranch,
storedBindingFromBinding,
storedBindingFromEntry,
} from "../src/core/extensions/builtin/claude-sdk-oauth/session-binding.ts";
import type { StoredBinding } from "../src/core/extensions/builtin/claude-sdk-oauth/session-binding-store.ts";
import { assistantContentHash } from "../src/core/extensions/builtin/claude-sdk-oauth/session-commit-boundary.ts";
import { sentMessageHashes, sentMessages } from "../src/core/extensions/builtin/claude-sdk-oauth/session-sync.ts";

const PROMPT_HASH = "1".repeat(64);
const TOOLSET_HASH = "2".repeat(64);
Expand Down Expand Up @@ -91,16 +90,66 @@ describe("claude-sdk-oauth stored binding anchor", () => {
expect(bindingFromStoredBranch([marker(), assistantEntry(assistant("rewritten"))], stored())).toBeUndefined();
});

it("rejects an anchor followed by later conversation context", () => {
it("allows goal continuation context after the committed assistant", () => {
const branch = [
marker(),
assistantEntry(),
{ type: "message" as const, id: "later-user", message: { role: "user" as const } },
{
type: "custom_message" as const,
id: "goal-continuation",
customType: "goal-continuation",
content: "Continue the active goal.",
},
{
type: "custom" as const,
id: "goal-cache",
customType: "goal-cache-warmup",
data: { phase: "scheduled" },
},
];

expect(bindingFromStoredBranch(branch, stored())).toMatchObject({ sdkSessionId: "sdk-1", sentCount: 2 });
});

it("rejects a later user message after the committed assistant", () => {
const branch = [
marker(),
assistantEntry(),
{ type: "message" as const, id: "later-user", message: { role: "user" as const, content: "resume" } },
];

expect(bindingFromStoredBranch(branch, stored())).toBeUndefined();
});

it("rejects a count-only fallback binding", () => {
const binding = {
senpiSessionId: "senpi-1",
sdkSessionId: "sdk-1",
sentCount: 1,
sentHashes: [],
lastAssistantUuid: null,
accountName: "primary",
modelId: "claude-test",
systemPromptHash: PROMPT_HASH,
toolsetHash: TOOLSET_HASH,
sdkSessionIdConfirmed: true,
};
expect(
storedBindingFromBinding(binding, ["different"], {
sessionPath: "/tmp/session.jsonl",
sessionId: "senpi-1",
markerEntryId: "marker-1",
assistantContentHash: assistantContentHash(assistant()),
}),
).toBeUndefined();
});

it("rejects a stale anchor followed by another assistant", () => {
expect(
bindingFromStoredBranch([marker(), assistantEntry(), assistantEntry(assistant("later"))], stored()),
).toBeUndefined();
});

it("allows known non-context metadata after the committed assistant", () => {
const branch = [
marker(),
Expand Down Expand Up @@ -137,42 +186,6 @@ describe("claude-sdk-oauth stored binding anchor", () => {
expect(bindingFromStoredBranch(branch, stored())).toBeUndefined();
});

it("refuses to derive hashes across a compaction boundary", () => {
// The branch walk is not compaction-aware, while admission compares against
// the compaction-truncated context. Deriving here would inflate sentCount and
// flatten every later restart, so refuse to anchor at all.
const message = { role: "user" as const, content: [{ type: "text" as const, text: "before" }], timestamp: 1 };

expect(
sentHashesFromBranch([
{ type: "message", id: "u1", message },
{ type: "compaction", id: "c1" },
{ type: "message", id: "u2", message },
] as never),
).toEqual([]);
});

it("derives branch hashes exactly as the context path does", () => {
// A content-less user message is skipped when the provider builds its sent
// stream; if only one side skips it, every later index shifts and a restart
// reports a false divergence.
const transmitted = {
role: "user" as const,
content: [{ type: "text" as const, text: "real turn" }],
timestamp: 1,
};
const contentless = { role: "user" as const, content: [], timestamp: 2 };

const fromBranch = sentHashesFromBranch([
{ type: "message", id: "u1", message: transmitted },
{ type: "message", id: "u2", message: contentless },
] as never);
const fromContext = sentMessageHashes(sentMessages({ messages: [transmitted, contentless] } as never));

expect(fromBranch).toEqual(fromContext);
expect(fromBranch).toHaveLength(1);
});

it("keeps the sidecar fixed-size when the conversation grows", () => {
const sentCount = 10_000;
const hashes = Array.from({ length: sentCount }, (_value, index) => `hash-${index}`);
Expand Down
Loading