Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e050bbef4b test(server): require one turn's events to share an identical turn id
Matching only the ordinal let two events of one turn pass with different
nonces. The session matcher binds the nonce from the first id it sees, so
every later id with the same ordinal must be the exact same string.
2026-11-02 04:41:24 +01:00
Mohamed Boudra af0b7a39a8 fix(server): keep earlier replies visible after an idle agent is reopened
Idle runtime collection closes an agent's provider session but the daemon
keeps the earlier turns' timeline rows and their turn ids. The next prompt
reopens the agent as a new provider session instance, and Claude, Codex and
OpenCode each restarted their turn ordinal at the same value, so the new turn
reused the previous turn's id. Turn collapsing reads a user message with the
current turn's id as a steer into that turn, so both turns folded together
and the earlier reply was hidden behind the summary line.

Each provider session instance now mixes a random nonce into the ids it
issues, which makes turn ids unique for the agent's lifetime without
persisting a counter or threading state from the daemon into providers.
2026-11-02 04:36:52 +01:00
paseo-bot[bot]andNoah efc64a5443 Explain the mode required to create agents on another provider (#8483)
* fix(server): say when create_agent needs an explicit mode

* test(server): verify cross-provider mode discovery through the daemon

Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice.

Co-authored-by: Noah <noahguerin20@gmail.com>

* test(server): run mode discovery in the integration suite

The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions.

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 02:08:31 +00:00
7efa14238d fix(claude): report API-error turns as failed (#8480)
* fix(claude): fail a turn that Claude Code ends on an API error

Claude Code reports a turn that stopped on an API error (a session or
usage limit, an invalid key, an overloaded request) as a `success`
result with `is_error: true`, the error text in `result`, after a
synthetic assistant reply carrying the same text and an `error` field.
The provider read only the subtype, so the turn completed with the
error as its answer: the agent went idle with no lastError and showed
as finished in the sidebar.

Treat such a result as a failed turn with the result text as its
error. Hold the synthetic error reply back until the result arrives so
the failure row does not repeat it; a reply the turn moves past, or a
result without is_error, still shows it.

Co-authored-by: RoundRedDot <roundreddot@gmail.com>

* fix(claude): retain final usage when an API-error turn fails

Publish the converted result totals before the failure event so the
existing usage handler preserves work done earlier in the turn.

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 01:07:00 +00:00
paseo-bot[bot] dbbcb103ba fix(opencode): preserve denial reasons when a message supersedes approval (#8477)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 23:50:55 +00:00
Thomas VanandClaude Opus 5.5 7f318bf7cf fix(codex): end an interrupted turn's shell commands (#7525)
`turn/interrupt` ends a Codex turn and leaves the shell commands it started running: a
command the user just stopped runs on to its own end, alongside the next turn.

Once Codex accepts the interrupt, the provider now ends each command the interrupted turn
started that has not exited, by process id, with `thread/backgroundTerminals/terminate`,
and a turn Paseo starts waits for those calls before its `turn/start`.

- Attribution: `thread/backgroundTerminals/list` carries no turn, so the provider records
  each root-thread `commandExecution` process id under the turn of its `item/started`. A
  `completed` item drops its id; a `failed` one keeps it, because Codex reports an
  interrupted turn's still-running command as failed. A turn that ends any way but
  `interrupted` forgets its ids, so what an earlier turn left running on purpose is never
  ended. At most 16 turns are remembered.
- A late command: the turn keeps its accepted interrupt, so a command whose `item/started`
  arrives after Codex accepted the interrupt (one queued behind an earlier notification's
  conversion, for instance) is ended as soon as it is seen. Each process is terminated
  once, and a turn start waits for every pending sweep.
- It never sends `thread/backgroundTerminals/clean`, which ends every terminal on the
  thread. It ends nothing after an already-idle interrupt, nothing on a subagent's thread,
  and nothing after an interrupt of a turn Codex never identified.
- Bounds: each terminate times out after 2 s; a failure is logged
  (`provider.codex.interrupt.terminal_terminate_failed`) and fails nothing. `startTurn`
  waits at most the interrupt's bound plus one terminate's plus 500 ms.

Tests (codex-interrupted-turn-terminals.test.ts) drive a real CodexAppServerAgentSession
against the scripted app-server in codex/test-utils/fake-app-server.ts: exactly the
interrupted turn's processes are terminated, by id, and `clean` is never sent; a turn
Codex starts before acknowledging the interrupt keeps its commands; the next `turn/start`
waits for the terminates; a command announced after the interrupt was accepted is
terminated once and the next turn waits for both sweeps, whichever answers first; a
completed command is forgotten and a failed one kept; only the latest sixteen accepted
interrupts are kept; a failing or unanswered terminate does not block the next turn. The
harness waits for the interrupted turn's `turn_canceled` before it starts the next turn,
as a caller does: notifications are handled asynchronously, so the interrupt's answer can
arrive before the turn has ended.

Fixes #7522


Claude-Session: https://claude.ai/code/session_01Ay568T2kmtotoh23Dtzi5K

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:58:12 +00:00
22 changed files with 1169 additions and 80 deletions
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -0,0 +1,190 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
@@ -1183,7 +1183,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
const session = await createSession();
const internal: {
turnState: "idle" | "foreground" | "autonomous";
nextTurnOrdinal: number;
routeSdkMessageFromPump: (
message: Record<string, unknown>,
activeQuery: QueryMock,
@@ -1206,8 +1205,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
);
const firstRunId = internal.autonomousTurn?.id ?? null;
expect(firstRunId).toBe("autonomous-turn-1");
expect(internal.nextTurnOrdinal).toBe(2);
expect(firstRunId).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
await internal.routeSdkMessageFromPump(
{
@@ -1220,7 +1218,6 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
queryMock,
);
expect(internal.autonomousTurn?.id).toBe(firstRunId);
expect(internal.nextTurnOrdinal).toBe(2);
await internal.routeSdkMessageFromPump(
{
@@ -1360,7 +1357,7 @@ test("a main-session frame opens no turn until Claude starts one", async () => {
// session start a turn of its own, with no prompt from the user.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
@@ -1410,7 +1407,7 @@ test("Claude waking up after a helper finishes gets an autonomous turn that its
expect(internal.autonomousTurn).toBeNull();
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
expect(internal.turnState).toBe("autonomous");
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
@@ -1446,7 +1443,7 @@ test("a steer folded into a turn keeps one turn, and a turn Claude starts after
// A message that arrived while Claude wrote its final answer runs as a turn of its own.
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-2$/);
await internal.routeSdkMessageFromPump(successResult(), queryMock);
expect(internal.autonomousTurn).toBeNull();
expect(internal.turnState).toBe("idle");
@@ -1483,7 +1480,7 @@ test("an idle session state closes an autonomous turn that no result closed", as
session.subscribe((event) => events.push(event));
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
// Safety net: Claude says nothing at all is running any more.
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
@@ -1685,7 +1682,7 @@ test("a new turn clears an interrupt flag no result will consume", async () => {
await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock);
expect(internal.pendingInterruptAbort).toBe(false);
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
expect(internal.autonomousTurn?.id).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
await internal.routeSdkMessageFromPump(
@@ -887,6 +887,30 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("issues turn ids that stay unique after the session is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-turn-ids-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempRoot, "claude-config"));
const client = new ClaudeAgentClient({
logger,
queryFactory: createQueryMock().queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const handle = { provider: "claude" as const, sessionId: "reopened-session" };
const first = await client.resumeSession(handle, { cwd: process.cwd() });
const { turnId: firstTurnId } = await first.startTurn("first prompt");
await first.close();
const second = await client.resumeSession(handle, { cwd: process.cwd() });
const { turnId: secondTurnId } = await second.startTurn("second prompt");
await second.close();
vi.unstubAllEnvs();
await fs.rm(tempRoot, { recursive: true, force: true });
expect(secondTurnId).not.toBe(firstTurnId);
});
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
const settingsPath = path.join(settingsDir, "settings.json");
@@ -1,5 +1,6 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { createTurnIdSequence } from "../../turn-ids.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -2371,7 +2372,7 @@ class ClaudeAgentSession implements AgentSession {
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
private readonly nextTurnId = createTurnIdSequence(1);
private cancelCurrentTurn: (() => void) | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private lastOptionsModel: string | null = null;
@@ -2389,6 +2390,12 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2531,6 +2538,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -3933,7 +3941,7 @@ class ClaudeAgentSession implements AgentSession {
}
private createTurnId(owner: "foreground" | "autonomous"): string {
return `${owner}-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId(owner);
}
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
@@ -4419,6 +4427,21 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4966,6 +4989,23 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4975,7 +5015,12 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({
type: "timeline",
provider: "claude",
@@ -4997,6 +5042,14 @@ class ClaudeAgentSession implements AgentSession {
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { type Dirent, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -100,6 +100,12 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { buildProviderRegistry } from "../provider-registry.js";
import { sessionTurnIds } from "./test-utils/turn-ids.js";
let codexTurnId = sessionTurnIds("codex");
beforeEach(() => {
codexTurnId = sessionTurnIds("codex");
});
interface CollaborationModeRecord {
name: string;
@@ -1281,6 +1287,29 @@ describe("Codex app-server provider", () => {
}
});
test("issues turn ids that stay unique after the thread is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const beforeReopen = createFakeCodexAppServer();
const firstSession =
await createProviderWithFakeAppServer(beforeReopen).createSession(createConfig());
const { turnId: firstTurnId } = await firstSession.startTurn("first turn");
const saved = waitForNextEvent(firstSession, "thread_started");
beforeReopen.startsTurn({ threadId: "thread-1" });
await saved;
const handle = firstSession.describePersistence()!;
await firstSession.close();
const afterReopen = createFakeCodexAppServer();
const resumed = await createProviderWithFakeAppServer(afterReopen).resumeSession(handle);
try {
const { turnId: secondTurnId } = await resumed.startTurn("turn after reopen");
expect(secondTurnId).not.toBe(firstTurnId);
} finally {
await resumed.close();
}
});
test("keeps the default effort when a session without one is resumed after a restart", async () => {
const beforeRestart = createFakeCodexAppServer();
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
@@ -4611,7 +4640,7 @@ describe("Codex app-server provider", () => {
await expect(child).resolves.toMatchObject({
type: "provider_subagent",
provider: "codex",
turnId: "codex-turn-0",
turnId: codexTurnId(0),
event: {
type: "upsert",
id: "legacy-only-child-thread",
@@ -4621,7 +4650,7 @@ describe("Codex app-server provider", () => {
await expect(spawn).resolves.toMatchObject({
type: "timeline",
provider: "codex",
turnId: "codex-turn-0",
turnId: codexTurnId(0),
item: {
type: "tool_call",
callId: "spawn-legacy-only-child",
@@ -1,4 +1,5 @@
import { validateProviderOptions } from "../provider-options.js";
import { createTurnIdSequence } from "../turn-ids.js";
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
@@ -150,6 +151,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
@@ -3453,7 +3463,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
@@ -3476,6 +3486,14 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
@@ -4431,6 +4449,9 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
@@ -5036,15 +5057,27 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try {
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
await interruptRequest;
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
@@ -5085,6 +5118,137 @@ export class CodexAppServerAgentSession implements AgentSession {
return !this.client.hasPendingRequests();
}
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
@@ -5464,7 +5628,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private createTurnId(): string {
return `codex-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId("codex");
}
private pendingNotifications: Promise<void> = Promise.resolve();
@@ -6209,6 +6373,11 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -6613,6 +6782,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6775,6 +6945,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
@@ -0,0 +1,374 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -25,6 +25,7 @@ import type {
AssistantMessageTimelineItem,
AgentTimelineItem,
} from "../agent-sdk-types.js";
import { sessionTurnIds } from "./test-utils/turn-ids.js";
// Deliberately an independent literal rather than the production constant these tests
// guard: deriving the boundary from OPENCODE_SERVER_STARTUP_TIMEOUT_MS would keep the
@@ -32,6 +33,11 @@ import type {
const EXPECTED_STARTUP_BUDGET_MS = 30_000;
const EXPECTED_EVENT_READINESS_BUDGET_MS = 45_000;
let openCodeTurnId = sessionTurnIds("opencode");
beforeEach(() => {
openCodeTurnId = sessionTurnIds("opencode");
});
function tmpCwd(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
try {
@@ -1638,10 +1644,10 @@ describe("OpenCode adapter startTurn error handling", () => {
item: { type: "user_message", clientMessageId: "client-message-1" },
});
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
]);
});
@@ -1833,7 +1839,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "error",
message: "Provider retry (attempt 1): model does not exist",
},
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
await session.close();
@@ -2373,6 +2379,35 @@ describe("OpenCode adapter startTurn error handling", () => {
}
});
test("issues turn ids that stay unique after the session is reopened", async () => {
// Idle runtime collection closes the provider session; the next prompt reopens it from
// persistence while the daemon keeps the earlier turns' rows and their turn ids.
const runtime = new TestOpenCodeHarness();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const cwd = tmpCwd();
const handle = { provider: "opencode" as const, sessionId: "ses_reopened", metadata: { cwd } };
const turnIds: string[] = [];
try {
for (let instance = 0; instance < 2; instance += 1) {
const openCode = new TestOpenCodeClient();
openCode.sessionPromptAsyncEvents = [];
runtime.enqueueClient(openCode);
const session = await client.resumeSession(handle);
try {
turnIds.push((await session.startTurn("prompt")).turnId);
} finally {
await session.close();
}
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
expect(turnIds[1]).not.toBe(turnIds[0]);
});
test("uses OpenCode native steer admission and reconciles the user echo", async () => {
const { parent: session, openCode } = await createParentSession("ses_native_steer");
openCode.sessionPromptAsyncEvents = [];
@@ -2641,11 +2676,11 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
).toEqual([
["turn_started", "opencode-turn-0"],
["turn_canceled", "opencode-turn-0"],
["turn_started", "opencode-turn-1"],
["timeline", "opencode-turn-1"],
["turn_completed", "opencode-turn-1"],
["turn_started", openCodeTurnId(0)],
["turn_canceled", openCodeTurnId(0)],
["turn_started", openCodeTurnId(1)],
["timeline", openCodeTurnId(1)],
["turn_completed", openCodeTurnId(1)],
]);
} finally {
settleRetry.resolve();
@@ -2706,7 +2741,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
item: expect.objectContaining({ text: "Wake output streams live." }),
}),
),
@@ -2726,11 +2761,11 @@ describe("OpenCode adapter startTurn error handling", () => {
});
await vi.waitFor(() =>
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
expect.objectContaining({ type: "turn_completed", turnId: openCodeTurnId(1) }),
),
);
await expect(session.startTurn("after the wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: openCodeTurnId(2),
});
} finally {
settleAbort.resolve();
@@ -2843,13 +2878,13 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
});
await vi.waitFor(() =>
expect(events).toContainEqual({
type: "turn_completed",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
usage: expect.objectContaining({
inputTokens: 7,
outputTokens: 3,
@@ -2858,7 +2893,8 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
const autonomousCompletion = events.find(
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
(event) =>
event.type === "turn_completed" && openCodeTurnId(1).asymmetricMatch(event.turnId),
);
expect(autonomousCompletion?.type).toBe("turn_completed");
if (autonomousCompletion?.type === "turn_completed") {
@@ -2867,7 +2903,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
item: expect.objectContaining({
type: "assistant_message",
text: "Autonomous wake completed.",
@@ -2875,7 +2911,7 @@ describe("OpenCode adapter startTurn error handling", () => {
}),
);
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
turnId: "opencode-turn-2",
turnId: openCodeTurnId(2),
});
} finally {
settleAbort.resolve();
@@ -2954,7 +2990,7 @@ describe("OpenCode adapter startTurn error handling", () => {
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
openCode.emitEvent({
@@ -2962,7 +2998,7 @@ describe("OpenCode adapter startTurn error handling", () => {
properties: { sessionID: "ses_retried_stop" },
});
await expect(session.startTurn("replacement")).resolves.toEqual({
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
});
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
} finally {
@@ -3058,7 +3094,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-1",
turnId: openCodeTurnId(1),
}),
);
@@ -3767,7 +3803,7 @@ describe("OpenCode adapter startTurn error handling", () => {
];
const replacement = session.startTurn("second");
await vi.advanceTimersByTimeAsync(10_000);
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
expect(countEvents(events, "turn_canceled")).toBe(1);
} finally {
vi.useRealTimers();
@@ -4022,7 +4058,7 @@ describe("OpenCode adapter startTurn error handling", () => {
expect(openCode.calls.sessionStatus).toHaveLength(1);
releaseIdle.resolve();
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
} finally {
@@ -4722,9 +4758,9 @@ describe("OpenCode provider subagent contract", () => {
"turn_completed",
]);
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
"opencode-turn-0",
"opencode-turn-0",
"opencode-turn-0",
openCodeTurnId(0),
openCodeTurnId(0),
openCodeTurnId(0),
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4760,7 +4796,7 @@ describe("OpenCode provider subagent contract", () => {
expect(events).toContainEqual({
type: "turn_started",
provider: "opencode",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
});
});
for (const event of assistantTurnEvents({
@@ -4774,9 +4810,9 @@ describe("OpenCode provider subagent contract", () => {
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
expect(events).toContainEqual(
expect.objectContaining({
@@ -4881,9 +4917,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
});
expect(events).toContainEqual(
@@ -4927,9 +4963,9 @@ describe("OpenCode provider subagent contract", () => {
await vi.waitFor(() => {
expect(turnEventSignatures(events)).toEqual([
["turn_started", "opencode-turn-0"],
["timeline", "opencode-turn-0"],
["turn_completed", "opencode-turn-0"],
["turn_started", openCodeTurnId(0)],
["timeline", openCodeTurnId(0)],
["turn_completed", openCodeTurnId(0)],
]);
});
} finally {
@@ -5003,7 +5039,7 @@ describe("OpenCode provider subagent contract", () => {
});
await vi.waitFor(() => {
expect(events).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
]);
});
@@ -5118,12 +5154,12 @@ describe("OpenCode provider subagent contract", () => {
await streamDrained.promise;
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
},
]);
} finally {
@@ -5174,7 +5210,7 @@ describe("OpenCode provider subagent contract", () => {
expect.objectContaining({
type: "permission_requested",
request: expect.objectContaining({ id: "perm_child_external" }),
turnId: "opencode-turn-0",
turnId: openCodeTurnId(0),
}),
);
});
@@ -1,4 +1,5 @@
import { validateProviderOptions } from "../provider-options.js";
import { createTurnIdSequence } from "../turn-ids.js";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -3405,7 +3406,7 @@ class OpenCodeAgentSession implements AgentSession {
private partTypes = new Map<string, string>();
private availableModesCache: AgentMode[] | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private turnState: OpenCodeTurnState = { status: "idle" };
/**
* Settlement of every session-scoped abort issued so far. It outlives the stop
@@ -4882,7 +4883,7 @@ class OpenCodeAgentSession implements AgentSession {
private createTurnId(): string {
this.gapRepairRevision += 1;
return `opencode-turn-${this.nextTurnOrdinal++}`;
return this.nextTurnId("opencode");
}
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
@@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
describe("OpenCode v2 denial reasons", () => {
test.each<{
response: AgentPermissionResponse;
decision: "reject" | "once" | "always";
message?: string;
}>([
{
response: { behavior: "deny", message: "Use the existing file instead." },
decision: "reject",
message: "Use the existing file instead.",
},
{ response: { behavior: "deny" }, decision: "reject" },
{ response: { behavior: "allow" }, decision: "once" },
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
])("preserves a permission response: $response", async ({ response, decision, message }) => {
const harness = new V2Harness();
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.respondToPermission("approval", response);
expect(replies).toEqual([
{
sessionID: "session",
requestID: "approval",
decision,
...(message === undefined ? {} : { message }),
},
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test.each([true, false])(
"explains cards superseded by a steer when clearing is %s",
async (clearPendingPermissions) => {
const harness = new V2Harness();
harness.autoComplete = false;
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const { turnId } = await session.startTurn("Run pwd");
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
await expect(
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
).resolves.toEqual({ status: "accepted" });
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
expect(replies).toEqual(
clearPendingPermissions
? [
{
sessionID: "session",
requestID: "approval",
decision: "reject",
message:
"The user answered with a message instead of approving. Their message follows.",
},
]
: [],
);
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
} finally {
await session.close();
}
},
);
});
describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
@@ -68,6 +68,9 @@ export class SessionPermissions {
sessionID: this.permissionOwners.get(requestId) ?? this.id,
requestID: requestId,
decision: permissionReply(response),
...(response.behavior === "deny" && response.message !== undefined
? { message: response.message }
: {}),
});
}
this.resolvePending(requestId, response);
@@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession {
),
clearPermissions: async () => {
for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
await this.permissions.respondToPermission(request.id, {
behavior: "deny",
message:
"The user answered with a message instead of approving. Their message follows.",
});
},
});
this.usage = new SessionUsage({
@@ -0,0 +1,30 @@
/** A Vitest asymmetric matcher for one provider turn id. */
export interface TurnIdMatcher {
asymmetricMatch(actual: unknown): boolean;
toString(): string;
toAsymmetricMatcher(): string;
}
/**
* Matches the turn ids one provider session issues. Each session mixes a random nonce into its
* ids, so the first id seen binds that nonce and every later match must be the exact id
* `<owner>-turn-<nonce>-<ordinal>`. Two events of one turn therefore match only when their ids
* are identical. Create one per test.
*/
export function sessionTurnIds(owner: string): (ordinal: number) => TurnIdMatcher {
let nonce: string | null = null;
const pattern = new RegExp(`^${owner}-turn-([0-9a-f]+)-(\\d+)$`);
return (ordinal) => {
const describe = () => `${owner}-turn-${nonce ?? "<nonce>"}-${ordinal}`;
return {
asymmetricMatch(actual) {
const match = typeof actual === "string" ? pattern.exec(actual) : null;
if (!match || Number(match[2]) !== ordinal) return false;
nonce ??= match[1]!;
return match[1] === nonce;
},
toString: describe,
toAsymmetricMatcher: describe,
};
};
}
@@ -1590,7 +1590,7 @@ function createToolCatalog(
{
title: "Create agent",
description:
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -0,0 +1,13 @@
import { randomBytes } from "node:crypto";
/**
* Turn ids stay unique for an agent's whole life. The daemon keeps every turn's rows while
* idle runtime collection closes the provider session, and the next prompt reopens the agent
* as a new session instance whose ordinals start over. Each instance therefore mixes its own
* nonce into the ids it issues; the ordinal keeps the sequence readable in logs and tests.
*/
export function createTurnIdSequence(firstOrdinal: number): (owner: string) => string {
const instance = randomBytes(6).toString("hex");
let ordinal = firstOrdinal;
return (owner) => `${owner}-turn-${instance}-${ordinal++}`;
}
@@ -0,0 +1,67 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
const artifacts = path.join(process.cwd(), ".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
const mcp = new Client({ name: "mode-contract-test", version: "1" });
try {
const parent = await ctx.client.createAgent({
provider: "claude",
cwd: root,
modeId: "default",
title: "Attended parent",
});
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const { tools } = await mcp.listTools();
const create = tools.find((tool) => tool.name === "create_agent");
expect.soft(create?.description).toContain("settings.modeId");
const args = {
provider: "codex/gpt-5.4-mini",
title: "Cross-provider child",
initialPrompt: "Reply with OK",
};
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
expect(refused.isError).toBe(true);
expect.soft(refused.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
}),
]);
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
const inspected = await mcp.callTool({
name: "inspect_provider",
arguments: { provider: "codex" },
});
expect(inspected.isError).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
});
const created = await mcp.callTool({
name: "create_agent",
arguments: { ...args, settings: { modeId: "auto" } },
});
expect(created.isError).not.toBe(true);
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
const childId = (created.structuredContent as { agentId: string }).agentId;
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
} finally {
await mcp.close();
await ctx.cleanup();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -22,6 +22,7 @@ import type {
FetchCatalogOptions,
} from "../agent/agent-sdk-types.js";
import type { AgentPermissionRequest, AgentPermissionResponse } from "../agent/agent-sdk-types.js";
import { createTurnIdSequence } from "../agent/turn-ids.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
const TEST_CAPABILITIES: AgentCapabilityFlags = {
@@ -342,7 +343,7 @@ class FakeAgentSession implements AgentSession {
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0;
private readonly nextTurnId = createTurnIdSequence(0);
private activeForegroundTurnId: string | null = null;
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
@@ -455,7 +456,7 @@ class FakeAgentSession implements AgentSession {
throw new Error("A foreground turn is already active");
}
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
const turnId = this.nextTurnId("fake");
this.activeForegroundTurnId = turnId;
this.onStartTurn?.(prompt, this.config);
+1 -1
View File
@@ -94,7 +94,7 @@ type PluginTurnOutcome =
| Field | Shape / meaning |
| ------------ | -------------------------------------------------------------------------------------- |
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
| `turnId` | Provider-reported `string` or `null`; unique for the agent's lifetime |
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
| `resolution` | SDK `AgentPermissionResponse` |
+1 -1
View File
@@ -92,7 +92,7 @@ type PluginTurnOutcome =
| 字段 | 结构 / 含义 |
| ------------ | ---------------------------------------------------------------------------------------- |
| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 |
| `turnId` | Provider 报告的 `string` 或 `null`;在 agent 的整个生命周期内唯一 |
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
| `resolution` | SDK 的 `AgentPermissionResponse` |