Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
2849 lines
81 KiB
TypeScript
2849 lines
81 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import type {
|
|
Options as ClaudeQueryOptions,
|
|
PermissionUpdate,
|
|
SDKMessage
|
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
import { withSidecarEventSinkForTest } from "./eventSink.ts";
|
|
import { SessionRuntime } from "./sessionRuntime.ts";
|
|
import { sidecarClaudeOptionsFromPayload } from "./options.ts";
|
|
import { isRecord, testCanUseToolOptions } from "./sessionRuntimeTestCommon.ts";
|
|
import {
|
|
fakeContextUsageQuery,
|
|
fakeSimpleResultQuery
|
|
} from "./sessionRuntimeTestQueries.delegated.ts";
|
|
import { fakeQueryWithInitializationModels } from "./sessionRuntimeTestQueries.assistant.ts";
|
|
import {
|
|
fakeCompactBoundaryQuery,
|
|
fakeDeferredContextUsageQuery,
|
|
fakeFailedCompactQuery,
|
|
fakeGuidancePromptQuery,
|
|
fakeLocalCommandFailedCompactQuery,
|
|
fakePermissionCheckQuery,
|
|
fakeSilentCompactQuery,
|
|
fakeStatusOnlyCompactQuery
|
|
} from "./sessionRuntimeTestQueries.session.ts";
|
|
import { waitForEvent } from "./sessionRuntimeTestQueries.nested.ts";
|
|
|
|
test("Claude-persisted user UUID becomes the provider Turn identity", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-identity",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const iterator = prompt[Symbol.asyncIterator]();
|
|
const outbound = await iterator.next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "persisted-claude-user-uuid",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-identity"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-identity",
|
|
"hello",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
"outbound-correlation-id"
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const providerStarted = events.find(
|
|
(event) => event.type === "provider_turn_identity_resolved"
|
|
);
|
|
const providerCheckpoint = events.find(
|
|
(event) => event.type === "provider_turn_checkpoint"
|
|
);
|
|
const completed = events.find((event) => event.type === "turn_completed");
|
|
assert.equal(
|
|
providerStarted?.payload?.providerTurnId,
|
|
"persisted-claude-user-uuid"
|
|
);
|
|
assert.equal(
|
|
completed?.payload?.providerTurnId,
|
|
"persisted-claude-user-uuid"
|
|
);
|
|
assert.deepEqual(providerCheckpoint?.payload, {
|
|
turnId: "turn-identity",
|
|
providerTurnId: "persisted-claude-user-uuid",
|
|
providerCheckpointMessageId: "persisted-claude-user-uuid"
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("successful result recovers provider Turn identity when SDK omits the root user echo", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const recoveryInputs: Array<Record<string, string>> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-recovered",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
type: "assistant",
|
|
uuid: "persisted-assistant-uuid",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-recovered",
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "hello" }]
|
|
}
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
}),
|
|
30_000,
|
|
async (input) => {
|
|
recoveryInputs.push(input);
|
|
return {
|
|
providerSessionId: input.sessionId,
|
|
providerTurnId: "persisted-user-uuid",
|
|
providerCheckpointMessageId: "persisted-assistant-uuid"
|
|
};
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-recovered",
|
|
"hello",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
"outbound-correlation-id"
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.deepEqual(recoveryInputs, [
|
|
{
|
|
sessionId: "provider-session-recovered",
|
|
cwd: "/repo",
|
|
recoveryToken: "outbound-correlation-id"
|
|
}
|
|
]);
|
|
assert.deepEqual(
|
|
events
|
|
.filter((event) =>
|
|
[
|
|
"provider_turn_identity_resolved",
|
|
"provider_turn_checkpoint",
|
|
"turn_completed"
|
|
].includes(event.type)
|
|
)
|
|
.map(({ type, payload }) => ({ type, payload })),
|
|
[
|
|
{
|
|
type: "provider_turn_identity_resolved",
|
|
payload: {
|
|
turnId: "turn-recovered",
|
|
providerTurnId: "persisted-user-uuid"
|
|
}
|
|
},
|
|
{
|
|
type: "provider_turn_checkpoint",
|
|
payload: {
|
|
turnId: "turn-recovered",
|
|
providerTurnId: "persisted-user-uuid",
|
|
providerCheckpointMessageId: "persisted-assistant-uuid"
|
|
}
|
|
},
|
|
{
|
|
type: "turn_completed",
|
|
payload: {
|
|
stopReason: "end_turn",
|
|
turnId: "turn-recovered",
|
|
providerTurnId: "persisted-user-uuid"
|
|
}
|
|
}
|
|
]
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("goal result-only recovery binds provider identity before activation", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal-result-only",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
await prompt[Symbol.asyncIterator]().next();
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
}),
|
|
30_000,
|
|
async (input) => ({
|
|
providerSessionId: input.sessionId,
|
|
providerTurnId: "persisted-goal-user-uuid",
|
|
providerCheckpointMessageId: "persisted-goal-result-uuid"
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"goal-result-only",
|
|
"/goal ship it",
|
|
undefined,
|
|
"goal_arm",
|
|
{
|
|
operationId: "goal-op-result",
|
|
revision: 3,
|
|
repairEpoch: 2,
|
|
action: "set"
|
|
},
|
|
"",
|
|
"goal-result-correlation-id"
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const identityIndex = events.findIndex(
|
|
(event) => event.type === "provider_turn_identity_resolved"
|
|
);
|
|
const startedIndex = events.findIndex(
|
|
(event) => event.type === "turn_started"
|
|
);
|
|
const completedIndex = events.findIndex(
|
|
(event) => event.type === "turn_completed"
|
|
);
|
|
assert.ok(identityIndex >= 0 && identityIndex < startedIndex);
|
|
assert.ok(startedIndex < completedIndex);
|
|
assert.deepEqual(events[identityIndex]?.payload, {
|
|
turnId: "goal-result-only",
|
|
providerTurnId: "persisted-goal-user-uuid",
|
|
turnOrigin: "goal_arm",
|
|
sourceGoalOperationId: "goal-op-result",
|
|
sourceGoalRevision: 3,
|
|
sourceGoalRepairEpoch: 2
|
|
});
|
|
assert.equal(
|
|
events[completedIndex]?.payload?.providerTurnId,
|
|
"persisted-goal-user-uuid"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("interactive request recovers provider Turn identity before waiting when SDK omits the root user echo", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const recoveryInputs: Array<Record<string, string>> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let permissionResult: unknown;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-interactive-recovered",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
type: "assistant",
|
|
uuid: "persisted-tool-assistant-uuid",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-interactive-recovered",
|
|
message: {
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "tool_use",
|
|
id: "toolu-write",
|
|
name: "Write",
|
|
input: { file_path: "/repo/tetris.html" }
|
|
}
|
|
]
|
|
}
|
|
} as never;
|
|
permissionResult = await options.canUseTool?.(
|
|
"Write",
|
|
{ file_path: "/repo/tetris.html" },
|
|
testCanUseToolOptions({
|
|
requestId: "request-write",
|
|
toolUseID: "toolu-write"
|
|
})
|
|
);
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
}),
|
|
30_000,
|
|
async (input) => {
|
|
recoveryInputs.push(input);
|
|
return {
|
|
providerSessionId: input.sessionId,
|
|
providerTurnId: "persisted-tool-user-uuid",
|
|
providerCheckpointMessageId: "persisted-tool-assistant-uuid"
|
|
};
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-interactive-recovered",
|
|
"write a file",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
"outbound-interactive-correlation-id"
|
|
);
|
|
await waitForEvent(events, "approval_requested");
|
|
|
|
assert.deepEqual(recoveryInputs, [
|
|
{
|
|
sessionId: "provider-session-interactive-recovered",
|
|
cwd: "/repo",
|
|
recoveryToken: "outbound-interactive-correlation-id"
|
|
}
|
|
]);
|
|
assert.deepEqual(
|
|
events
|
|
.filter((event) =>
|
|
[
|
|
"provider_turn_identity_resolved",
|
|
"provider_turn_checkpoint",
|
|
"approval_requested"
|
|
].includes(event.type)
|
|
)
|
|
.map(({ type, payload }) => ({ type, payload })),
|
|
[
|
|
{
|
|
type: "provider_turn_identity_resolved",
|
|
payload: {
|
|
turnId: "turn-interactive-recovered",
|
|
providerTurnId: "persisted-tool-user-uuid"
|
|
}
|
|
},
|
|
{
|
|
type: "provider_turn_checkpoint",
|
|
payload: {
|
|
turnId: "turn-interactive-recovered",
|
|
providerTurnId: "persisted-tool-user-uuid",
|
|
providerCheckpointMessageId: "persisted-tool-assistant-uuid"
|
|
}
|
|
},
|
|
{
|
|
type: "approval_requested",
|
|
payload: events.find((event) => event.type === "approval_requested")
|
|
?.payload
|
|
}
|
|
]
|
|
);
|
|
|
|
const request = events.find((event) => event.type === "approval_requested");
|
|
session.submitInteractive(
|
|
"turn-interactive-recovered",
|
|
String(request?.payload?.requestId ?? ""),
|
|
"submit",
|
|
"allow",
|
|
{}
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.deepEqual(permissionResult, {
|
|
behavior: "allow",
|
|
updatedInput: { file_path: "/repo/tetris.html" }
|
|
});
|
|
assert.equal(
|
|
events.filter((event) => event.type === "provider_turn_identity_resolved")
|
|
.length,
|
|
1
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("AskUserQuestion and ExitPlanMode share one accepted identity without root user echo", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let recoveryCalls = 0;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-multi-interaction",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
type: "assistant",
|
|
uuid: "assistant-before-interactions",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-multi-interaction",
|
|
message: {
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "I need two answers." }]
|
|
}
|
|
} as never;
|
|
await options.canUseTool?.(
|
|
"AskUserQuestion",
|
|
{
|
|
questions: [
|
|
{
|
|
header: "Choice",
|
|
question: "Pick one",
|
|
options: [{ label: "A", description: "Alpha" }]
|
|
}
|
|
]
|
|
},
|
|
testCanUseToolOptions({
|
|
requestId: "request-ask",
|
|
toolUseID: "toolu-ask"
|
|
})
|
|
);
|
|
await options.canUseTool?.(
|
|
"ExitPlanMode",
|
|
{ plan: "Implement the selected option." },
|
|
testCanUseToolOptions({
|
|
requestId: "request-exit-plan",
|
|
toolUseID: "toolu-exit-plan"
|
|
})
|
|
);
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
}),
|
|
30_000,
|
|
async (input) => {
|
|
recoveryCalls += 1;
|
|
return {
|
|
providerSessionId: input.sessionId,
|
|
providerTurnId: "provider-turn-multi-interaction",
|
|
providerCheckpointMessageId: "assistant-before-interactions"
|
|
};
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-multi-interaction",
|
|
"ask then exit plan",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
"correlation-multi-interaction"
|
|
);
|
|
await waitForEventCount(events, "user_input_requested", 1);
|
|
const ask = events.find((event) => event.type === "user_input_requested");
|
|
session.submitInteractive(
|
|
"turn-multi-interaction",
|
|
String(ask?.payload?.requestId ?? ""),
|
|
"submit",
|
|
"",
|
|
{ answers: { "Pick one": "A" } }
|
|
);
|
|
await waitForEventCount(events, "user_input_requested", 2);
|
|
const exitPlan = events.filter(
|
|
(event) => event.type === "user_input_requested"
|
|
)[1];
|
|
session.submitInteractive(
|
|
"turn-multi-interaction",
|
|
String(exitPlan?.payload?.requestId ?? ""),
|
|
"submit",
|
|
"default",
|
|
{}
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const ordered = events.filter((event) =>
|
|
[
|
|
"provider_turn_identity_resolved",
|
|
"user_input_requested",
|
|
"turn_completed"
|
|
].includes(event.type)
|
|
);
|
|
assert.equal(ordered[0]?.type, "provider_turn_identity_resolved");
|
|
assert.equal(
|
|
ordered.filter(
|
|
(event) => event.type === "provider_turn_identity_resolved"
|
|
).length,
|
|
1
|
|
);
|
|
assert.equal(
|
|
ordered.filter((event) => event.type === "user_input_requested").length,
|
|
2
|
|
);
|
|
assert.equal(recoveryCalls, 1);
|
|
assert.equal(
|
|
ordered.at(-1)?.payload?.providerTurnId,
|
|
"provider-turn-multi-interaction"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
async function waitForEventCount(
|
|
events: Array<{ type: string }>,
|
|
type: string,
|
|
count: number
|
|
): Promise<void> {
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
if (events.filter((event) => event.type === type).length >= count) {
|
|
return;
|
|
}
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
assert.fail(`timed out waiting for ${count} ${type} events`);
|
|
}
|
|
|
|
test("ephemeral Claude session state UUID does not replace the durable checkpoint", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-checkpoint",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const iterator = prompt[Symbol.asyncIterator]();
|
|
const outbound = await iterator.next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "persisted-user-uuid",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-checkpoint"
|
|
} as never;
|
|
yield {
|
|
type: "assistant",
|
|
uuid: "persisted-assistant-uuid",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-checkpoint",
|
|
message: {
|
|
id: "assistant-message",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "hello" }]
|
|
}
|
|
} as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "hook_started",
|
|
hook_id: "hook-1",
|
|
hook_name: "demo-hook",
|
|
hook_event: "PostToolUse",
|
|
uuid: "ephemeral-hook-started-uuid",
|
|
session_id: "provider-session-checkpoint"
|
|
} as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "hook_progress",
|
|
hook_id: "hook-1",
|
|
hook_name: "demo-hook",
|
|
hook_event: "PostToolUse",
|
|
stdout: "progress",
|
|
stderr: "",
|
|
output: "progress",
|
|
uuid: "ephemeral-hook-progress-uuid",
|
|
session_id: "provider-session-checkpoint"
|
|
} as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "hook_response",
|
|
hook_id: "hook-1",
|
|
hook_name: "demo-hook",
|
|
hook_event: "PostToolUse",
|
|
output: "done",
|
|
stdout: "",
|
|
stderr: "",
|
|
outcome: "success",
|
|
uuid: "ephemeral-hook-response-uuid",
|
|
session_id: "provider-session-checkpoint"
|
|
} as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "session_state_changed",
|
|
state: "idle",
|
|
uuid: "ephemeral-idle-uuid",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-checkpoint"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-checkpoint",
|
|
"hello",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"",
|
|
"outbound-correlation-id"
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const checkpoints = events.filter(
|
|
(event) => event.type === "provider_turn_checkpoint"
|
|
);
|
|
assert.deepEqual(
|
|
checkpoints.map((event) => event.payload?.providerCheckpointMessageId),
|
|
["persisted-user-uuid", "persisted-assistant-uuid"]
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("tutti host context shares one SDK prompt with unchanged user input", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const messages: Array<{
|
|
textBlocks: string[];
|
|
isSynthetic?: boolean;
|
|
shouldQuery?: boolean;
|
|
origin?: unknown;
|
|
}> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const iterator = prompt[Symbol.asyncIterator]();
|
|
const next = await iterator.next();
|
|
const message = next.value!;
|
|
const content = message.message.content;
|
|
messages.push({
|
|
textBlocks: Array.isArray(content)
|
|
? content.flatMap((block) =>
|
|
block.type === "text" ? [block.text] : []
|
|
)
|
|
: [content],
|
|
isSynthetic: message.isSynthetic,
|
|
shouldQuery: message.shouldQuery,
|
|
origin: message.origin
|
|
});
|
|
yield {
|
|
...message,
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-1"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"turn-1",
|
|
"what mode is active?",
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
"<tutti-host-context>active</tutti-host-context>"
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.deepEqual(messages, [
|
|
{
|
|
textBlocks: [
|
|
"<tutti-host-context>active</tutti-host-context>",
|
|
"what mode is active?"
|
|
],
|
|
isSynthetic: undefined,
|
|
shouldQuery: undefined,
|
|
origin: undefined
|
|
}
|
|
]);
|
|
assert.equal(
|
|
JSON.stringify(events).includes("<tutti-host-context>"),
|
|
false,
|
|
"host-owned context must not be projected as user-visible activity"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("SDK assistant authentication error fails the message and turn", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
const failure =
|
|
"Failed to authenticate. API Error: 401 The API Key appears to be invalid.";
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-auth",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "k3",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const iterator = prompt[Symbol.asyncIterator]();
|
|
const message = (await iterator.next()).value!;
|
|
yield {
|
|
...message,
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-auth"
|
|
} as never;
|
|
yield {
|
|
type: "assistant",
|
|
error: "authentication_failed",
|
|
message: {
|
|
id: "assistant-auth-error",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: failure }]
|
|
},
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-auth"
|
|
} as never;
|
|
yield {
|
|
type: "result",
|
|
subtype: "success",
|
|
is_error: true,
|
|
api_error_status: 401,
|
|
result: failure,
|
|
session_id: "provider-session-auth"
|
|
} as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-auth", "hello");
|
|
await waitForEvent(events, "turn_failed");
|
|
|
|
const failedMessage = events.find(
|
|
(event) => event.type === "assistant_failed"
|
|
);
|
|
assert.equal(failedMessage?.payload?.content, failure);
|
|
const failedTurn = events.find((event) => event.type === "turn_failed");
|
|
assert.equal(failedTurn?.payload?.turnId, "turn-auth");
|
|
assert.equal(failedTurn?.payload?.code, "authentication_failed");
|
|
assert.equal(failedTurn?.payload?.apiErrorStatus, 401);
|
|
assert.equal(
|
|
events.some((event) => event.type === "turn_completed"),
|
|
false
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("SDK authentication rejection before provider identity skips recovery", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
const failure =
|
|
"Failed to authenticate. API Error: 401 The API Key appears to be invalid.";
|
|
let identityRecoveryCalls = 0;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-auth-rejected",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "k3",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const iterator = prompt[Symbol.asyncIterator]();
|
|
await iterator.next();
|
|
yield {
|
|
type: "assistant",
|
|
error: "authentication_failed",
|
|
message: {
|
|
id: "assistant-auth-rejected",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: failure }]
|
|
},
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-auth-rejected"
|
|
} as never;
|
|
yield {
|
|
type: "result",
|
|
subtype: "success",
|
|
is_error: true,
|
|
api_error_status: 401,
|
|
result: failure,
|
|
session_id: "provider-session-auth-rejected"
|
|
} as never;
|
|
},
|
|
close() {}
|
|
}),
|
|
30_000,
|
|
async () => {
|
|
identityRecoveryCalls += 1;
|
|
throw new Error("identity recovery must not run for an explicit 401");
|
|
},
|
|
50
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-auth-rejected", "hello");
|
|
await waitForEvent(events, "turn_failed");
|
|
|
|
assert.equal(identityRecoveryCalls, 0);
|
|
assert.equal(
|
|
events.some((event) => event.type === "assistant_failed"),
|
|
false,
|
|
"provider output must not escape before durable acceptance"
|
|
);
|
|
assert.equal(
|
|
events.some((event) => event.type === "provider_turn_identity_resolved"),
|
|
false
|
|
);
|
|
const failedTurn = events.find((event) => event.type === "turn_failed");
|
|
assert.equal(failedTurn?.payload?.turnId, "turn-auth-rejected");
|
|
assert.equal(failedTurn?.payload?.code, "authentication_failed");
|
|
assert.equal(failedTurn?.payload?.apiErrorStatus, 401);
|
|
assert.equal(failedTurn?.payload?.dispatchDisposition, "rejected");
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("SDK api_retry authentication error fails before retrying", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let queryClosed = false;
|
|
let closeCalls = 0;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-api-retry-auth",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "k3",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
type: "system",
|
|
subtype: "api_retry",
|
|
attempt: 1,
|
|
max_retries: 10,
|
|
retry_delay_ms: 100,
|
|
error_status: 401,
|
|
error: "authentication_failed",
|
|
session_id: "provider-session-api-retry-auth"
|
|
} as never;
|
|
while (!queryClosed) {
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
},
|
|
close() {
|
|
closeCalls += 1;
|
|
queryClosed = true;
|
|
}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-api-retry-auth", "hello");
|
|
await waitForEvent(events, "turn_failed");
|
|
|
|
const failedTurn = events.find((event) => event.type === "turn_failed");
|
|
assert.equal(failedTurn?.payload?.turnId, "turn-api-retry-auth");
|
|
assert.equal(failedTurn?.payload?.code, "authentication_failed");
|
|
assert.equal(failedTurn?.payload?.apiErrorStatus, 401);
|
|
assert.equal(failedTurn?.payload?.dispatchDisposition, "rejected");
|
|
assert.equal(closeCalls, 1);
|
|
assert.equal(
|
|
events.filter(
|
|
(event) =>
|
|
event.type === "sdk_lifecycle_observed" &&
|
|
event.payload?.sdkMessageSubtype === "api_retry"
|
|
).length,
|
|
1
|
|
);
|
|
} finally {
|
|
queryClosed = true;
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("guidance preempts immediately and stays on the active SDK turn", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const prompts: string[] = [];
|
|
let releaseInterrupt = () => {};
|
|
let releaseAbortResult = () => {};
|
|
const interrupts = {
|
|
count: 0,
|
|
wait: new Promise<void>((resolve) => {
|
|
releaseInterrupt = resolve;
|
|
}),
|
|
resultWait: new Promise<void>((resolve) => {
|
|
releaseAbortResult = resolve;
|
|
})
|
|
};
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeGuidancePromptQuery(prompt, prompts, interrupts)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "start working");
|
|
await waitForCondition(() => prompts.length === 1, "initial prompt");
|
|
let guidanceAcknowledged = false;
|
|
const guidance = session.guide("prefer the focused path").then(() => {
|
|
guidanceAcknowledged = true;
|
|
});
|
|
|
|
assert.equal(interrupts.count, 1);
|
|
assert.deepEqual(prompts, ["start working"]);
|
|
assert.equal(guidanceAcknowledged, false);
|
|
|
|
releaseInterrupt();
|
|
await guidance;
|
|
const interruptedIndex = events.findIndex(
|
|
(event) => event.type === "guidance_interrupted"
|
|
);
|
|
assert.ok(interruptedIndex >= 0);
|
|
assert.equal(events[interruptedIndex]?.payload?.turnId, "turn-1");
|
|
assert.deepEqual(prompts, ["start working"]);
|
|
|
|
releaseAbortResult();
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.deepEqual(prompts, ["start working", "prefer the focused path"]);
|
|
assert.equal(interrupts.count, 1);
|
|
const guidedAssistantIndex = events.findIndex(
|
|
(event) =>
|
|
event.type === "assistant_completed" &&
|
|
event.payload?.content === "Guided response"
|
|
);
|
|
assert.ok(guidedAssistantIndex > interruptedIndex);
|
|
const completed = events.find((event) => event.type === "turn_completed");
|
|
assert.equal(completed?.payload?.turnId, "turn-1");
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" && event.payload?.turnId !== "turn-1"
|
|
),
|
|
false
|
|
);
|
|
assert.equal(
|
|
events.some((event) => event.type === "turn_failed"),
|
|
false
|
|
);
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_started" && event.payload?.synthetic === true
|
|
),
|
|
false
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("guidance rejects instead of enqueueing when SDK preemption fails", async () => {
|
|
const prompts: string[] = [];
|
|
const restoreSink = withSidecarEventSinkForTest(() => {});
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => {
|
|
const query = fakeGuidancePromptQuery(prompt, prompts);
|
|
return {
|
|
...query,
|
|
async interrupt() {
|
|
throw new Error("interrupt rejected");
|
|
}
|
|
};
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "start working");
|
|
await waitForCondition(() => prompts.length === 1, "initial prompt");
|
|
|
|
await assert.rejects(
|
|
session.guide("prefer the focused path"),
|
|
/guidance preemption failed: interrupt rejected/
|
|
);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
|
|
assert.deepEqual(prompts, ["start working"]);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
description: string
|
|
): Promise<void> {
|
|
const deadline = Date.now() + 1000;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
assert.fail(`timed out waiting for ${description}`);
|
|
}
|
|
|
|
test("goal set scheduling ack followed by immediate clear coalesces before SDK activation", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeSimpleResultQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
// Both calls have crossed the sidecar scheduling/ACK boundary before the
|
|
// deferred Goal dispatcher hands either command to the SDK iterable.
|
|
session.exec("goal-set-turn", "/goal ship it", undefined, "goal_arm", {
|
|
operationId: "goal-op-set",
|
|
revision: 1,
|
|
action: "set"
|
|
});
|
|
session.exec("goal-clear-command", "/goal clear", undefined, undefined, {
|
|
operationId: "goal-op-clear",
|
|
revision: 2,
|
|
action: "clear"
|
|
});
|
|
|
|
await waitForEvent(events, "goal_command_started");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_started" &&
|
|
event.payload?.turnId === "goal-set-turn"
|
|
),
|
|
false
|
|
);
|
|
const superseded = events.find(
|
|
(event) => event.type === "goal_command_superseded"
|
|
);
|
|
assert.equal(superseded?.payload?.operationId, "goal-op-set");
|
|
const started = events.find(
|
|
(event) => event.type === "goal_command_started"
|
|
);
|
|
assert.equal(started?.payload?.operationId, "goal-op-clear");
|
|
assert.equal(started?.payload?.revision, 2);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("SDK active_goal messages normalize provider goal lifecycle", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-active-goal",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const outbound = await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "provider-goal-turn",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-active-goal"
|
|
} as never;
|
|
yield {
|
|
type: "active_goal",
|
|
value: "malformed",
|
|
uuid: "active-goal-malformed",
|
|
session_id: "provider-session-active-goal"
|
|
} as never;
|
|
yield {
|
|
type: "active_goal",
|
|
value: {
|
|
condition: "count to three",
|
|
iterations: 2,
|
|
set_at: "2026-08-02T00:00:00.000Z",
|
|
tokens_at_start: 100,
|
|
last_reason: "only reached two"
|
|
},
|
|
uuid: "active-goal-1",
|
|
session_id: "provider-session-active-goal"
|
|
} as never;
|
|
yield {
|
|
type: "active_goal",
|
|
value: null,
|
|
uuid: "active-goal-2",
|
|
session_id: "provider-session-active-goal"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("goal-work-turn", "continue");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const updates = events.filter((event) => event.type === "goal_observed");
|
|
assert.equal(updates.length, 2);
|
|
assert.deepEqual(updates[0]?.payload, {
|
|
turnId: "goal-work-turn",
|
|
providerTurnId: "provider-goal-turn",
|
|
source: "active_goal",
|
|
updateType: "thread_goal_update",
|
|
goal: {
|
|
objective: "count to three",
|
|
status: "active",
|
|
iterations: 2,
|
|
reason: "only reached two"
|
|
}
|
|
});
|
|
assert.deepEqual(updates[1]?.payload, {
|
|
turnId: "goal-work-turn",
|
|
providerTurnId: "provider-goal-turn",
|
|
source: "active_goal",
|
|
updateType: "thread_goal_completed"
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("provider idle blocks an active Goal without a terminal verdict", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal-timeout",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const outbound = await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "provider-goal-timeout-turn",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-goal-timeout"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "session_state_changed",
|
|
state: "idle",
|
|
session_id: "provider-session-goal-timeout"
|
|
} as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"goal-timeout-turn",
|
|
"/goal finish the task",
|
|
undefined,
|
|
undefined,
|
|
{
|
|
operationId: "goal-timeout-operation",
|
|
revision: 1,
|
|
action: "set"
|
|
}
|
|
);
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "goal_observed" &&
|
|
isRecord(event.payload?.goal) &&
|
|
event.payload.goal.status === "blocked"
|
|
),
|
|
"blocked Goal at provider idle"
|
|
);
|
|
|
|
const updates = events.filter((event) => event.type === "goal_observed");
|
|
assert.equal(updates.length, 1);
|
|
assert.deepEqual(updates[0]?.payload?.goal, {
|
|
objective: "finish the task",
|
|
status: "blocked"
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("SDK active_goal clear keeps the exact goal command action", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal-clear",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const outbound = await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "provider-goal-clear-turn",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-goal-clear"
|
|
} as never;
|
|
yield {
|
|
type: "active_goal",
|
|
value: null,
|
|
uuid: "active-goal-clear",
|
|
session_id: "provider-session-goal-clear"
|
|
} as never;
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("goal-clear-turn", "/goal clear", undefined, undefined, {
|
|
operationId: "goal-op-clear",
|
|
revision: 2,
|
|
action: "clear"
|
|
});
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const update = events.find((event) => event.type === "goal_observed");
|
|
assert.equal(update?.payload?.action, "clear");
|
|
assert.equal(update?.payload?.source, "active_goal");
|
|
assert.equal(update?.payload?.updateType, "thread_goal_cleared");
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("system init follows goal_status written after SDK result", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const tempDirectory = await mkdtemp(
|
|
join(tmpdir(), "tutti-claude-goal-transcript-")
|
|
);
|
|
const transcriptDirectory = join(tempDirectory, "projects", "-repo");
|
|
const transcriptPath = join(
|
|
transcriptDirectory,
|
|
"provider-session-goal-status.jsonl"
|
|
);
|
|
await mkdir(transcriptDirectory, { recursive: true });
|
|
await writeFile(transcriptPath, "", "utf8");
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal-status",
|
|
"/repo",
|
|
{ CLAUDE_CONFIG_DIR: tempDirectory },
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
yield {
|
|
type: "system",
|
|
subtype: "init",
|
|
session_id: "provider-session-goal-status",
|
|
cwd: "/repo",
|
|
model: "haiku"
|
|
} as never;
|
|
yield {
|
|
type: "system",
|
|
subtype: "init",
|
|
session_id: "provider-subagent-goal-status",
|
|
parent_tool_use_id: "toolu-delegated-agent",
|
|
cwd: "/subagent",
|
|
model: "haiku"
|
|
} as never;
|
|
const outbound = await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "provider-goal-status-turn",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-goal-status"
|
|
} as never;
|
|
await appendFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
type: "attachment",
|
|
uuid: "goal-status-sentinel",
|
|
sessionId: "provider-session-goal-status",
|
|
attachment: {
|
|
type: "goal_status",
|
|
met: false,
|
|
sentinel: true,
|
|
condition: "count to three"
|
|
}
|
|
})}\n`,
|
|
"utf8"
|
|
);
|
|
yield { type: "result", subtype: "success" } as never;
|
|
await appendFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
type: "attachment",
|
|
uuid: "goal-status-complete",
|
|
sessionId: "provider-session-goal-status",
|
|
attachment: {
|
|
type: "goal_status",
|
|
met: true,
|
|
condition: "count to three",
|
|
reason: "counted one number per turn",
|
|
iterations: 3,
|
|
durationMs: 16_386,
|
|
tokens: 1_479
|
|
}
|
|
})}\n`,
|
|
"utf8"
|
|
);
|
|
yield {
|
|
type: "system",
|
|
subtype: "session_state_changed",
|
|
state: "idle",
|
|
session_id: "provider-session-goal-status"
|
|
} as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"goal-status-turn",
|
|
"/goal count to three",
|
|
undefined,
|
|
undefined,
|
|
{
|
|
operationId: "goal-op-status",
|
|
revision: 1,
|
|
action: "set"
|
|
}
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "goal_observed" &&
|
|
isRecord(event.payload?.goal) &&
|
|
event.payload.goal.status === "complete"
|
|
),
|
|
"late transcript goal completion"
|
|
);
|
|
|
|
const updates = events.filter((event) => event.type === "goal_observed");
|
|
assert.equal(updates.length, 2);
|
|
assert.deepEqual(updates[1]?.payload, {
|
|
turnId: "goal-status-turn",
|
|
providerTurnId: "provider-goal-status-turn",
|
|
source: "goal_status",
|
|
updateType: "thread_goal_update",
|
|
goal: {
|
|
objective: "count to three",
|
|
status: "complete",
|
|
reason: "counted one number per turn",
|
|
iterations: 3,
|
|
durationMs: 16_386,
|
|
tokens: 1_479
|
|
}
|
|
});
|
|
assert.ok(
|
|
events.findIndex(
|
|
(event) =>
|
|
event.type === "goal_observed" &&
|
|
isRecord(event.payload?.goal) &&
|
|
event.payload.goal.status === "active"
|
|
) < events.findIndex((event) => event.type === "turn_completed"),
|
|
"the sentinel goal evidence must be emitted before the root turn settles"
|
|
);
|
|
assert.ok(
|
|
events.findIndex(
|
|
(event) =>
|
|
event.type === "goal_observed" &&
|
|
isRecord(event.payload?.goal) &&
|
|
event.payload.goal.status === "complete"
|
|
) > events.findIndex((event) => event.type === "turn_completed"),
|
|
"the final goal evidence may arrive after the SDK result"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
await rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("resume tails the known session transcript without system init", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const tempDirectory = await mkdtemp(
|
|
join(tmpdir(), "tutti-claude-goal-resume-")
|
|
);
|
|
const transcriptDirectory = join(tempDirectory, "projects", "-repo");
|
|
const transcriptPath = join(
|
|
transcriptDirectory,
|
|
"provider-session-goal-resume.jsonl"
|
|
);
|
|
await mkdir(transcriptDirectory, { recursive: true });
|
|
await writeFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
type: "attachment",
|
|
uuid: "historical-goal-status",
|
|
attachment: {
|
|
type: "goal_status",
|
|
met: false,
|
|
condition: "historical goal"
|
|
}
|
|
})}\n`,
|
|
"utf8"
|
|
);
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-goal-resume",
|
|
"/repo",
|
|
{ CLAUDE_CONFIG_DIR: tempDirectory },
|
|
true,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
const outbound = await prompt[Symbol.asyncIterator]().next();
|
|
yield {
|
|
...outbound.value,
|
|
uuid: "provider-goal-resume-turn",
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-goal-resume"
|
|
} as never;
|
|
await appendFile(
|
|
transcriptPath,
|
|
`${JSON.stringify({
|
|
type: "attachment",
|
|
uuid: "current-goal-status",
|
|
attachment: {
|
|
type: "goal_status",
|
|
met: true,
|
|
condition: "resume goal"
|
|
}
|
|
})}\n`,
|
|
"utf8"
|
|
);
|
|
yield { type: "result", subtype: "success" } as never;
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec(
|
|
"goal-resume-turn",
|
|
"/goal resume goal",
|
|
undefined,
|
|
undefined,
|
|
{
|
|
operationId: "goal-op-resume",
|
|
revision: 1,
|
|
action: "set"
|
|
}
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const updates = events.filter((event) => event.type === "goal_observed");
|
|
assert.equal(updates.length, 1);
|
|
assert.equal(updates[0]?.payload?.source, "goal_status");
|
|
assert.deepEqual(updates[0]?.payload?.goal, {
|
|
objective: "resume goal",
|
|
status: "complete"
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
await rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("query enables bypass permission capability for later live mode switch", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
const previousSandbox = process.env.IS_SANDBOX;
|
|
let capturedOptions:
|
|
| {
|
|
allowDangerouslySkipPermissions?: boolean;
|
|
includeHookEvents?: boolean;
|
|
permissionMode?: string;
|
|
}
|
|
| undefined;
|
|
process.env.IS_SANDBOX = "1";
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => {
|
|
capturedOptions = options;
|
|
return fakeSimpleResultQuery(prompt);
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "hello");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(capturedOptions?.permissionMode, "default");
|
|
assert.equal(capturedOptions?.allowDangerouslySkipPermissions, true);
|
|
assert.equal(capturedOptions?.includeHookEvents, true);
|
|
} finally {
|
|
if (previousSandbox === undefined) {
|
|
delete process.env.IS_SANDBOX;
|
|
} else {
|
|
process.env.IS_SANDBOX = previousSandbox;
|
|
}
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("session start emits SDK model config options from initialization", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "mimo-v2.5-pro",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) =>
|
|
fakeQueryWithInitializationModels(prompt, [
|
|
{
|
|
value: "default",
|
|
displayName: "Default",
|
|
description: "Provider default"
|
|
},
|
|
{
|
|
value: "mimo-v2.5-pro",
|
|
displayName: "Mimo v2.5 Pro",
|
|
description: "Custom Mimo model"
|
|
}
|
|
])
|
|
);
|
|
|
|
await session.start();
|
|
|
|
const started = events.find((event) => event.type === "session_started");
|
|
const configOptions = started?.payload?.configOptions as
|
|
| Array<Record<string, unknown>>
|
|
| undefined;
|
|
const modelOption = configOptions?.find((option) => option.id === "model");
|
|
const modelOptions = modelOption?.options as
|
|
| Array<Record<string, unknown>>
|
|
| undefined;
|
|
assert.equal(modelOption?.currentValue, "mimo-v2.5-pro");
|
|
assert.deepEqual(
|
|
modelOptions?.map((option) => ({
|
|
value: option.value,
|
|
name: option.name,
|
|
description: option.description
|
|
})),
|
|
[
|
|
{
|
|
value: "default",
|
|
name: "Default",
|
|
description: "Provider default"
|
|
},
|
|
{
|
|
value: "mimo-v2.5-pro",
|
|
name: "Mimo v2.5 Pro",
|
|
description: "Custom Mimo model"
|
|
}
|
|
]
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("session start publishes the per-user model reported by SDK system init", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let session: SessionRuntime | undefined;
|
|
try {
|
|
session = new SessionRuntime(
|
|
"provider-session-effective-model",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "default",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => ({
|
|
async initializationResult() {
|
|
return {
|
|
models: [
|
|
{
|
|
value: "default",
|
|
displayName: "Sonnet 5",
|
|
description: "Efficient for routine tasks"
|
|
},
|
|
{
|
|
value: "haiku",
|
|
displayName: "Haiku 4.5",
|
|
description: "Small and fast"
|
|
}
|
|
]
|
|
};
|
|
},
|
|
async *[Symbol.asyncIterator]() {
|
|
yield {
|
|
type: "system",
|
|
subtype: "init",
|
|
model: "claude-haiku-4-5-20251001",
|
|
session_id: "provider-session-effective-model"
|
|
} as never;
|
|
for await (const message of prompt) {
|
|
yield {
|
|
...message,
|
|
type: "user",
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-effective-model"
|
|
} as never;
|
|
yield {
|
|
type: "assistant",
|
|
message: {
|
|
id: "assistant-effective-model",
|
|
model: "claude-opus-4-8",
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "Done." }]
|
|
},
|
|
parent_tool_use_id: null,
|
|
session_id: "provider-session-effective-model"
|
|
} as never;
|
|
yield {
|
|
type: "result",
|
|
subtype: "success",
|
|
session_id: "provider-session-effective-model"
|
|
} as never;
|
|
}
|
|
},
|
|
close() {}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
await waitForCondition(
|
|
() =>
|
|
events.some((event) => {
|
|
if (event.type !== "session_state") {
|
|
return false;
|
|
}
|
|
const configOptions = event.payload?.configOptions as
|
|
| Array<Record<string, unknown>>
|
|
| undefined;
|
|
return configOptions?.some(
|
|
(option) =>
|
|
option.id === "model" &&
|
|
option.currentValue === "default" &&
|
|
option.effectiveValue === "claude-haiku-4-5-20251001"
|
|
);
|
|
}),
|
|
"effective model session state"
|
|
);
|
|
|
|
session.exec("turn-effective-model", "Which model are you?");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some((event) => {
|
|
if (event.type !== "session_state") {
|
|
return false;
|
|
}
|
|
const configOptions = event.payload?.configOptions as
|
|
| Array<Record<string, unknown>>
|
|
| undefined;
|
|
return configOptions?.some(
|
|
(option) =>
|
|
option.id === "model" &&
|
|
option.currentValue === "default" &&
|
|
option.effectiveValue === "claude-opus-4-8"
|
|
);
|
|
}),
|
|
"assistant-reconciled effective model session state"
|
|
);
|
|
} finally {
|
|
await session?.close();
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("context usage prefers result modelUsage window over SDK maxTokens", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "sonnet",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeContextUsageQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "hi");
|
|
await waitForEvent(events, "turn_completed");
|
|
await waitForEvent(events, "usage_updated");
|
|
|
|
const usage = events.find(
|
|
(event) =>
|
|
event.type === "usage_updated" && isRecord(event.payload?.contextWindow)
|
|
);
|
|
const contextWindow = isRecord(usage?.payload?.contextWindow)
|
|
? usage.payload.contextWindow
|
|
: undefined;
|
|
assert.equal(contextWindow?.usedTokens, 36_092);
|
|
assert.equal(contextWindow?.totalTokens, 1_000_000);
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "usage_updated" && isRecord(event.payload?.usage)
|
|
),
|
|
false,
|
|
"cumulative result usage must not replace the authoritative context snapshot"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("restore start emits session_started without waiting for context usage", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const contextUsageResolvers: Array<(value: unknown) => void> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-restore",
|
|
"/repo",
|
|
{},
|
|
true,
|
|
false,
|
|
{
|
|
model: "haiku",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) =>
|
|
fakeDeferredContextUsageQuery(prompt, contextUsageResolvers)
|
|
);
|
|
|
|
const started = session.start();
|
|
await waitForCondition(
|
|
() => events.some((event) => event.type === "session_started"),
|
|
"session_started before context usage resolves"
|
|
);
|
|
await started;
|
|
assert.equal(
|
|
events.some((event) => event.type === "usage_updated"),
|
|
false,
|
|
"restore start must not wait for getContextUsage"
|
|
);
|
|
assert.equal(contextUsageResolvers.length, 1);
|
|
|
|
contextUsageResolvers[0]?.({ totalTokens: 12_345, maxTokens: 200_000 });
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "usage_updated" &&
|
|
isRecord(event.payload?.contextWindow) &&
|
|
event.payload.contextWindow.usedTokens === 12_345
|
|
),
|
|
"background restore context usage"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("turn completion does not wait for context usage and stale snapshots are dropped", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const contextUsageResolvers: Array<(value: unknown) => void> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "haiku",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) =>
|
|
fakeDeferredContextUsageQuery(prompt, contextUsageResolvers)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "first");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-1"
|
|
),
|
|
"first turn completion"
|
|
);
|
|
assert.equal(contextUsageResolvers.length, 1);
|
|
|
|
session.exec("turn-2", "second");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-2"
|
|
) && contextUsageResolvers.length === 2,
|
|
"second turn completion and context usage request"
|
|
);
|
|
|
|
contextUsageResolvers[1]?.({ totalTokens: 222, maxTokens: 200_000 });
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "usage_updated" &&
|
|
event.payload?.turnId === "turn-2" &&
|
|
isRecord(event.payload?.contextWindow)
|
|
),
|
|
"second turn context usage"
|
|
);
|
|
|
|
contextUsageResolvers[0]?.({ totalTokens: 111, maxTokens: 200_000 });
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "usage_updated" &&
|
|
event.payload?.turnId === "turn-1" &&
|
|
isRecord(event.payload?.contextWindow)
|
|
),
|
|
false,
|
|
"the delayed first-turn snapshot must not overwrite the newer turn"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("follow-up after settled turn resumes a fresh Claude query", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let queryCount = 0;
|
|
let resumedOptions: ClaudeQueryOptions | undefined;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-continue",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => {
|
|
queryCount += 1;
|
|
if (queryCount === 1) {
|
|
return fakeSimpleResultQuery(prompt, {
|
|
text: "first reply"
|
|
});
|
|
}
|
|
resumedOptions = options;
|
|
return fakeSimpleResultQuery(prompt, {
|
|
text: "continue reply"
|
|
});
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "first");
|
|
await waitForEvent(events, "turn_completed");
|
|
assert.equal(queryCount, 1);
|
|
|
|
session.exec("turn-2", "follow-up in the same conversation");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-2"
|
|
) ||
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_failed" && event.payload?.turnId === "turn-2"
|
|
),
|
|
`follow-up turn terminal; events=${JSON.stringify(events)}`
|
|
);
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" && event.payload?.turnId === "turn-2"
|
|
),
|
|
true,
|
|
`follow-up should complete; events=${JSON.stringify(events)}`
|
|
);
|
|
|
|
assert.equal(queryCount, 2);
|
|
assert.equal(resumedOptions?.resume, "provider-session-1");
|
|
assert.equal(Object.hasOwn(resumedOptions ?? {}, "sessionId"), false);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("allow for session survives the fresh Claude query used by a follow-up turn", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
const permissionResults: unknown[] = [];
|
|
const suggestions = [
|
|
{
|
|
type: "addRules",
|
|
rules: [{ toolName: "WebFetch", ruleContent: "domain:example.com" }],
|
|
behavior: "allow",
|
|
destination: "session"
|
|
} satisfies PermissionUpdate
|
|
];
|
|
let queryCount = 0;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-permission-ledger",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => {
|
|
queryCount += 1;
|
|
const currentQuery = queryCount;
|
|
return fakePermissionCheckQuery(
|
|
prompt,
|
|
options,
|
|
async (queryOptions) => {
|
|
permissionResults.push(
|
|
await queryOptions.canUseTool?.(
|
|
"WebFetch",
|
|
{ url: `https://example.com/${currentQuery}` },
|
|
{
|
|
...testCanUseToolOptions({
|
|
requestId: `request-web-fetch-${currentQuery}`,
|
|
toolUseID: `tool-web-fetch-${currentQuery}`
|
|
}),
|
|
suggestions
|
|
}
|
|
)
|
|
);
|
|
}
|
|
);
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "fetch once");
|
|
await waitForEvent(events, "approval_requested");
|
|
const request = events.find((event) => event.type === "approval_requested");
|
|
session.submitInteractive(
|
|
"turn-1",
|
|
String(request?.payload?.requestId ?? ""),
|
|
"approved",
|
|
"allow_always",
|
|
{}
|
|
);
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-1"
|
|
),
|
|
"first permission turn completion"
|
|
);
|
|
|
|
session.exec("turn-2", "fetch again");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-2"
|
|
),
|
|
"follow-up permission turn completion"
|
|
);
|
|
|
|
assert.equal(queryCount, 2);
|
|
assert.equal(
|
|
events.filter((event) => event.type === "approval_requested").length,
|
|
1
|
|
);
|
|
assert.deepEqual(permissionResults, [
|
|
{
|
|
behavior: "allow",
|
|
updatedInput: { url: "https://example.com/1" },
|
|
updatedPermissions: suggestions
|
|
},
|
|
{
|
|
behavior: "allow",
|
|
updatedInput: { url: "https://example.com/2" },
|
|
updatedPermissions: suggestions
|
|
}
|
|
]);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("settings effort after idle-retire bakes into resumed query settings without applyFlagSettings", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let queryCount = 0;
|
|
let resumedOptions: ClaudeQueryOptions | undefined;
|
|
const applyFlagSettingsCalls: unknown[] = [];
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-effort",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "medium",
|
|
speed: "standard"
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) => {
|
|
queryCount += 1;
|
|
const query = fakeSimpleResultQuery(prompt, {
|
|
text: queryCount === 1 ? "first reply" : "high effort reply"
|
|
}) as AsyncIterable<SDKMessage> & {
|
|
applyFlagSettings?: (settings: unknown) => Promise<void>;
|
|
close?: () => void;
|
|
};
|
|
query.applyFlagSettings = async (settings) => {
|
|
applyFlagSettingsCalls.push(settings);
|
|
};
|
|
if (queryCount > 1) {
|
|
resumedOptions = options;
|
|
}
|
|
return query;
|
|
}
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "first");
|
|
await waitForEvent(events, "turn_completed");
|
|
applyFlagSettingsCalls.length = 0;
|
|
|
|
await session.applySettings({ effort: "high" });
|
|
session.exec("turn-2", "follow-up after effort change");
|
|
await waitForCondition(
|
|
() =>
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_completed" &&
|
|
event.payload?.turnId === "turn-2"
|
|
) ||
|
|
events.some(
|
|
(event) =>
|
|
event.type === "turn_failed" && event.payload?.turnId === "turn-2"
|
|
),
|
|
`effort follow-up terminal; events=${JSON.stringify(events)}`
|
|
);
|
|
|
|
assert.equal(queryCount, 2);
|
|
assert.equal(resumedOptions?.resume, "provider-session-1");
|
|
assert.deepEqual(
|
|
(resumedOptions as { settings?: Record<string, unknown> } | undefined)
|
|
?.settings,
|
|
{
|
|
effortLevel: "high",
|
|
fastMode: false
|
|
}
|
|
);
|
|
assert.deepEqual(
|
|
applyFlagSettingsCalls,
|
|
[],
|
|
"resumed quiet query must not receive live applyFlagSettings"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("result usage remains available when context snapshot is unavailable", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "haiku",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) =>
|
|
fakeSimpleResultQuery(prompt, {
|
|
usage: {
|
|
input_tokens: 120,
|
|
output_tokens: 8,
|
|
cache_read_input_tokens: 72,
|
|
cache_creation_input_tokens: 0
|
|
}
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-usage-fallback", "hi");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
const usage = events.find(
|
|
(event) =>
|
|
event.type === "usage_updated" && isRecord(event.payload?.usage)
|
|
);
|
|
assert.ok(
|
|
usage,
|
|
"expected result usage when no context query is available"
|
|
);
|
|
assert.equal(usage.payload?.turnId, "turn-usage-fallback");
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("late compact boundary still attaches to slash compact turn", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) =>
|
|
fakeCompactBoundaryQuery(prompt, { boundaryAfterResult: true })
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-late", "/compact");
|
|
await waitForEvent(events, "turn_completed");
|
|
await waitForEvent(events, "compact_completed");
|
|
|
|
const compactEvent = events.find(
|
|
(event) => event.type === "compact_completed"
|
|
);
|
|
assert.equal(compactEvent?.payload?.turnId, "turn-late");
|
|
|
|
const usage = events.find((event) => event.type === "usage_updated");
|
|
assert.equal(usage?.payload?.turnId, "turn-late");
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("compact success reported only via status message still refreshes usage", async () => {
|
|
// Real Claude Code compaction can report completion via the `status`
|
|
// system message (`compact_result: "success"`) without a `compact_boundary`
|
|
// message ever following it in a given ordering/timing window. Before this
|
|
// fix, that path emitted "Compacting completed." without ever refreshing
|
|
// the context-usage percentage, so the GUI usage chip stayed pinned at its
|
|
// pre-compaction value (e.g. 100%) until some unrelated later turn happened
|
|
// to report fresh usage.
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeStatusOnlyCompactQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-status-only", "/compact");
|
|
await waitForEvent(events, "turn_completed");
|
|
await waitForEvent(events, "compact_completed");
|
|
|
|
const usage = events.find(
|
|
(event) =>
|
|
event.type === "usage_updated" && isRecord(event.payload?.contextWindow)
|
|
);
|
|
const contextWindow = isRecord(usage?.payload?.contextWindow)
|
|
? usage.payload.contextWindow
|
|
: undefined;
|
|
assert.ok(usage, "expected a usage_updated event carrying contextWindow");
|
|
assert.equal(usage?.payload?.turnId, "turn-status-only");
|
|
assert.equal(contextWindow?.usedTokens, 4_061);
|
|
assert.equal(contextWindow?.totalTokens, 1_000_000);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("compact failure preserves the status reason and assistant response", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeFailedCompactQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-compact-failed", "/compact");
|
|
await waitForEvent(events, "compact_failed");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.find((event) => event.type === "compact_failed")?.payload?.content,
|
|
"Compacting failed: Not enough messages to compact."
|
|
);
|
|
assert.equal(
|
|
events.find((event) => event.type === "compact_failed")?.payload?.reason,
|
|
"Not enough messages to compact."
|
|
);
|
|
assert.equal(
|
|
events.find((event) => event.type === "assistant_completed")?.payload
|
|
?.content,
|
|
"Not enough messages to compact."
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("silent slash compact still emits a progress banner before result", async () => {
|
|
// Real Claude Code 2.1.x can finish /compact with only result + getContextUsage
|
|
// and never stream status:compacting or compact_boundary to the query
|
|
// iterator. Without an immediate compact_started, AgentGUI shows only the
|
|
// turn duration footer and no compaction divider.
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeSilentCompactQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-silent-compact", "/compact");
|
|
await waitForEvent(events, "compact_started");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.find((event) => event.type === "compact_started")?.payload?.turnId,
|
|
"turn-silent-compact"
|
|
);
|
|
const usage = events.find(
|
|
(event) =>
|
|
event.type === "usage_updated" && isRecord(event.payload?.contextWindow)
|
|
);
|
|
assert.equal(usage?.payload?.turnId, "turn-silent-compact");
|
|
assert.equal(
|
|
(usage?.payload?.contextWindow as { usedTokens?: number } | undefined)
|
|
?.usedTokens,
|
|
1_990
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("local_command compact failure still emits compact_failed", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "default",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt }) => fakeLocalCommandFailedCompactQuery(prompt)
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-local-command-failed", "/compact");
|
|
await waitForEvent(events, "compact_failed");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.find((event) => event.type === "compact_failed")?.payload?.reason,
|
|
"Not enough messages to compact."
|
|
);
|
|
assert.equal(
|
|
events.find((event) => event.type === "compact_started")?.payload?.turnId,
|
|
"turn-local-command-failed"
|
|
);
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("bypass permission mode allows ordinary tools without approval", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let permissionResult: unknown;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "bypassPermissions",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) =>
|
|
fakePermissionCheckQuery(prompt, options, async (queryOptions) => {
|
|
permissionResult = await queryOptions.canUseTool?.(
|
|
"Bash",
|
|
{ command: "rm -rf /repo/*" },
|
|
testCanUseToolOptions({
|
|
requestId: "request-bash",
|
|
toolUseID: "toolu-bash"
|
|
})
|
|
);
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "delete everything");
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.some((event) => event.type === "approval_requested"),
|
|
false
|
|
);
|
|
assert.deepEqual(permissionResult, {
|
|
behavior: "allow",
|
|
updatedInput: { command: "rm -rf /repo/*" }
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|
|
|
|
test("bypass permission mode still surfaces AskUserQuestion", async () => {
|
|
const events: Array<{ type: string; payload?: Record<string, unknown> }> = [];
|
|
const restoreSink = withSidecarEventSinkForTest((event) =>
|
|
events.push(event)
|
|
);
|
|
let permissionResult: unknown;
|
|
try {
|
|
const session = new SessionRuntime(
|
|
"provider-session-1",
|
|
"/repo",
|
|
{},
|
|
false,
|
|
false,
|
|
{
|
|
model: "",
|
|
permissionModeId: "bypassPermissions",
|
|
planMode: false,
|
|
effort: "",
|
|
speed: ""
|
|
},
|
|
sidecarClaudeOptionsFromPayload({}),
|
|
undefined,
|
|
({ prompt, options }) =>
|
|
fakePermissionCheckQuery(prompt, options, async (queryOptions) => {
|
|
permissionResult = await queryOptions.canUseTool?.(
|
|
"AskUserQuestion",
|
|
{
|
|
questions: [
|
|
{
|
|
header: "Confirm",
|
|
question: "Delete everything?",
|
|
options: [{ label: "Yes", description: "Delete files" }]
|
|
}
|
|
]
|
|
},
|
|
testCanUseToolOptions({
|
|
requestId: "request-ask",
|
|
toolUseID: "toolu-ask"
|
|
})
|
|
);
|
|
})
|
|
);
|
|
|
|
await session.start();
|
|
session.exec("turn-1", "delete everything");
|
|
await waitForEvent(events, "user_input_requested");
|
|
|
|
const request = events.find(
|
|
(event) => event.type === "user_input_requested"
|
|
);
|
|
const requestQuestions = (
|
|
request?.payload?.input as { questions?: Array<Record<string, unknown>> }
|
|
)?.questions;
|
|
assert.equal(requestQuestions?.[0]?.id, "contract-question-1412lhw");
|
|
session.submitInteractive(
|
|
"turn-1",
|
|
String(request?.payload?.requestId ?? ""),
|
|
"submit",
|
|
"Yes",
|
|
{
|
|
answers: ["Yes"],
|
|
answersByQuestionId: { "contract-question-1412lhw": "Yes" }
|
|
}
|
|
);
|
|
await waitForEvent(events, "turn_completed");
|
|
|
|
assert.equal(
|
|
events.some((event) => event.type === "approval_requested"),
|
|
false
|
|
);
|
|
assert.deepEqual(permissionResult, {
|
|
behavior: "allow",
|
|
updatedInput: {
|
|
questions: [
|
|
{
|
|
header: "Confirm",
|
|
question: "Delete everything?",
|
|
options: [{ label: "Yes", description: "Delete files" }],
|
|
id: "contract-question-1412lhw"
|
|
}
|
|
],
|
|
answers: { "Delete everything?": "Yes" }
|
|
}
|
|
});
|
|
} finally {
|
|
restoreSink();
|
|
}
|
|
});
|