mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e1814def0 | ||
|
|
2d864df02e | ||
|
|
57586e6b52 |
@@ -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/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: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: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 in settings.modeId. Available modes for 'opencode': build, plan",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. 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 in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. 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 in settings.modeId. Available modes for 'codex': auto, full-access",
|
||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. 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 in settings.modeId. Available modes for 'zai-custom': unknown",
|
||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. 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 in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
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,6 +1183,7 @@ 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,
|
||||
@@ -1205,7 +1206,8 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou
|
||||
);
|
||||
|
||||
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||
expect(firstRunId).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(firstRunId).toBe("autonomous-turn-1");
|
||||
expect(internal.nextTurnOrdinal).toBe(2);
|
||||
|
||||
await internal.routeSdkMessageFromPump(
|
||||
{
|
||||
@@ -1218,6 +1220,7 @@ 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(
|
||||
{
|
||||
@@ -1357,7 +1360,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).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
@@ -1407,7 +1410,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).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
expect(internal.turnState).toBe("autonomous");
|
||||
|
||||
await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock);
|
||||
@@ -1443,7 +1446,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).toMatch(/^autonomous-turn-[0-9a-f]+-2$/);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2");
|
||||
await internal.routeSdkMessageFromPump(successResult(), queryMock);
|
||||
expect(internal.autonomousTurn).toBeNull();
|
||||
expect(internal.turnState).toBe("idle");
|
||||
@@ -1480,7 +1483,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).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// Safety net: Claude says nothing at all is running any more.
|
||||
await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock);
|
||||
@@ -1682,7 +1685,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).toMatch(/^autonomous-turn-[0-9a-f]+-1$/);
|
||||
expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1");
|
||||
|
||||
// This turn's own failure is reported, not swallowed as the interrupted turn's leftover.
|
||||
await internal.routeSdkMessageFromPump(
|
||||
|
||||
@@ -887,30 +887,6 @@ 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,6 +1,5 @@
|
||||
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";
|
||||
@@ -2372,7 +2371,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private readonly nextTurnId = createTurnIdSequence(1);
|
||||
private nextTurnOrdinal = 1;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
@@ -2390,12 +2389,6 @@ 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>();
|
||||
@@ -2538,7 +2531,6 @@ 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();
|
||||
@@ -3941,7 +3933,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(owner: "foreground" | "autonomous"): string {
|
||||
return this.nextTurnId(owner);
|
||||
return `${owner}-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private isTerminalTurnEvent(event: AgentStreamEvent): boolean {
|
||||
@@ -4427,21 +4419,6 @@ 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];
|
||||
}
|
||||
|
||||
@@ -4989,23 +4966,6 @@ 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: …")
|
||||
@@ -5015,12 +4975,7 @@ 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 &&
|
||||
!releasedErrorReply
|
||||
) {
|
||||
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -5042,14 +4997,6 @@ 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 { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { 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,12 +100,6 @@ 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;
|
||||
@@ -1287,29 +1281,6 @@ 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(
|
||||
@@ -4640,7 +4611,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(child).resolves.toMatchObject({
|
||||
type: "provider_subagent",
|
||||
provider: "codex",
|
||||
turnId: codexTurnId(0),
|
||||
turnId: "codex-turn-0",
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: "legacy-only-child-thread",
|
||||
@@ -4650,7 +4621,7 @@ describe("Codex app-server provider", () => {
|
||||
await expect(spawn).resolves.toMatchObject({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: codexTurnId(0),
|
||||
turnId: "codex-turn-0",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "spawn-legacy-only-child",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import { createTurnIdSequence } from "../turn-ids.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPermissionAction,
|
||||
@@ -3463,7 +3462,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private readonly nextTurnId = createTurnIdSequence(0);
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
@@ -5628,7 +5627,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private createTurnId(): string {
|
||||
return this.nextTurnId("codex");
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { 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,7 +25,6 @@ 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
|
||||
@@ -33,11 +32,6 @@ import { sessionTurnIds } from "./test-utils/turn-ids.js";
|
||||
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 {
|
||||
@@ -1644,10 +1638,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([
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1839,7 +1833,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "error",
|
||||
message: "Provider retry (attempt 1): model does not exist",
|
||||
},
|
||||
turnId: openCodeTurnId(0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
||||
await session.close();
|
||||
@@ -2379,35 +2373,6 @@ 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 = [];
|
||||
@@ -2676,11 +2641,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(
|
||||
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
||||
).toEqual([
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["turn_canceled", openCodeTurnId(0)],
|
||||
["turn_started", openCodeTurnId(1)],
|
||||
["timeline", openCodeTurnId(1)],
|
||||
["turn_completed", openCodeTurnId(1)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["turn_canceled", "opencode-turn-0"],
|
||||
["turn_started", "opencode-turn-1"],
|
||||
["timeline", "opencode-turn-1"],
|
||||
["turn_completed", "opencode-turn-1"],
|
||||
]);
|
||||
} finally {
|
||||
settleRetry.resolve();
|
||||
@@ -2741,7 +2706,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
item: expect.objectContaining({ text: "Wake output streams live." }),
|
||||
}),
|
||||
),
|
||||
@@ -2761,11 +2726,11 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_completed", turnId: openCodeTurnId(1) }),
|
||||
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
||||
),
|
||||
);
|
||||
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
||||
turnId: openCodeTurnId(2),
|
||||
turnId: "opencode-turn-2",
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2878,13 +2843,13 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_completed",
|
||||
provider: "opencode",
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
usage: expect.objectContaining({
|
||||
inputTokens: 7,
|
||||
outputTokens: 3,
|
||||
@@ -2893,8 +2858,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
const autonomousCompletion = events.find(
|
||||
(event) =>
|
||||
event.type === "turn_completed" && openCodeTurnId(1).asymmetricMatch(event.turnId),
|
||||
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
||||
);
|
||||
expect(autonomousCompletion?.type).toBe("turn_completed");
|
||||
if (autonomousCompletion?.type === "turn_completed") {
|
||||
@@ -2903,7 +2867,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "Autonomous wake completed.",
|
||||
@@ -2911,7 +2875,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}),
|
||||
);
|
||||
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
||||
turnId: openCodeTurnId(2),
|
||||
turnId: "opencode-turn-2",
|
||||
});
|
||||
} finally {
|
||||
settleAbort.resolve();
|
||||
@@ -2990,7 +2954,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: openCodeTurnId(0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
|
||||
openCode.emitEvent({
|
||||
@@ -2998,7 +2962,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
properties: { sessionID: "ses_retried_stop" },
|
||||
});
|
||||
await expect(session.startTurn("replacement")).resolves.toEqual({
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
});
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
} finally {
|
||||
@@ -3094,7 +3058,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: openCodeTurnId(1),
|
||||
turnId: "opencode-turn-1",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3803,7 +3767,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
];
|
||||
const replacement = session.startTurn("second");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
expect(countEvents(events, "turn_canceled")).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -4058,7 +4022,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
||||
|
||||
releaseIdle.resolve();
|
||||
await expect(replacement).resolves.toEqual({ turnId: openCodeTurnId(1) });
|
||||
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
expect(openCode.calls.sessionStatus.length).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
@@ -4758,9 +4722,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
"turn_completed",
|
||||
]);
|
||||
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
openCodeTurnId(0),
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
"opencode-turn-0",
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4796,7 +4760,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "turn_started",
|
||||
provider: "opencode",
|
||||
turnId: openCodeTurnId(0),
|
||||
turnId: "opencode-turn-0",
|
||||
});
|
||||
});
|
||||
for (const event of assistantTurnEvents({
|
||||
@@ -4810,9 +4774,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -4917,9 +4881,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
@@ -4963,9 +4927,9 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(turnEventSignatures(events)).toEqual([
|
||||
["turn_started", openCodeTurnId(0)],
|
||||
["timeline", openCodeTurnId(0)],
|
||||
["turn_completed", openCodeTurnId(0)],
|
||||
["turn_started", "opencode-turn-0"],
|
||||
["timeline", "opencode-turn-0"],
|
||||
["turn_completed", "opencode-turn-0"],
|
||||
]);
|
||||
});
|
||||
} finally {
|
||||
@@ -5039,7 +5003,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(events).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5154,12 +5118,12 @@ describe("OpenCode provider subagent contract", () => {
|
||||
|
||||
await streamDrained.promise;
|
||||
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
||||
{ type: "turn_started", provider: "opencode", turnId: openCodeTurnId(0) },
|
||||
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "opencode",
|
||||
reason: "interrupted",
|
||||
turnId: openCodeTurnId(0),
|
||||
turnId: "opencode-turn-0",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
@@ -5210,7 +5174,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
expect.objectContaining({
|
||||
type: "permission_requested",
|
||||
request: expect.objectContaining({ id: "perm_child_external" }),
|
||||
turnId: openCodeTurnId(0),
|
||||
turnId: "opencode-turn-0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import { createTurnIdSequence } from "../turn-ids.js";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
@@ -3406,7 +3405,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 readonly nextTurnId = createTurnIdSequence(0);
|
||||
private nextTurnOrdinal = 0;
|
||||
private turnState: OpenCodeTurnState = { status: "idle" };
|
||||
/**
|
||||
* Settlement of every session-scoped abort issued so far. It outlives the stop
|
||||
@@ -4883,7 +4882,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
|
||||
private createTurnId(): string {
|
||||
this.gapRepairRevision += 1;
|
||||
return this.nextTurnId("opencode");
|
||||
return `opencode-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private traceOpenCode(msg: OpenCodeTraceMessage, data: OpenCodeTraceData = {}): void {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/** 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. 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.",
|
||||
"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.",
|
||||
inputSchema: createAgentInputSchema,
|
||||
outputSchema: {
|
||||
agentId: z.string(),
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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++}`;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
@@ -23,7 +23,10 @@ export function createPushNotifications(options: {
|
||||
}): PushNotifications {
|
||||
const now = options.now ?? Date.now;
|
||||
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
|
||||
const service = new PushService(options.logger, (token) => store.revokeToken(token));
|
||||
const service = new PushService({
|
||||
logger: options.logger,
|
||||
revokeToken: (token) => store.revokeToken(token),
|
||||
});
|
||||
const deliver =
|
||||
options.deliver ??
|
||||
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PushService } from "./push-service.js";
|
||||
|
||||
describe("PushService", () => {
|
||||
test("requests high priority for every notification across batches", async () => {
|
||||
const requests: Record<string, unknown>[][] = [];
|
||||
const sendRequest: typeof fetch = async (_url, init) => {
|
||||
const messages = JSON.parse(String(init?.body)) as Record<string, unknown>[];
|
||||
requests.push(messages);
|
||||
return new Response(
|
||||
JSON.stringify({ data: messages.map(() => ({ status: "ok", id: "ticket" })) }),
|
||||
);
|
||||
};
|
||||
const service = new PushService({
|
||||
logger: pino({ level: "silent" }),
|
||||
revokeToken: () => undefined,
|
||||
sendRequest,
|
||||
});
|
||||
const tokens = Array.from({ length: 101 }, (_, index) => `ExponentPushToken[device-${index}]`);
|
||||
|
||||
await service.sendPush(tokens, {
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
});
|
||||
|
||||
expect(requests.map((batch) => batch.length)).toEqual([100, 1]);
|
||||
expect(requests.flat()).toEqual(
|
||||
tokens.map((token) => ({
|
||||
to: token,
|
||||
priority: "high",
|
||||
title: "Agent finished",
|
||||
body: "Done",
|
||||
data: { agentId: "agent" },
|
||||
sound: "default",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ interface ExpoPushMessage {
|
||||
body: string;
|
||||
data?: Record<string, unknown>;
|
||||
sound?: "default";
|
||||
priority?: "default" | "normal" | "high";
|
||||
}
|
||||
|
||||
interface ExpoPushTicket {
|
||||
@@ -31,10 +32,16 @@ const MAX_BATCH_SIZE = 100;
|
||||
export class PushService {
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly revokeToken: (token: string) => void;
|
||||
private readonly sendRequest: typeof fetch;
|
||||
|
||||
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
|
||||
this.logger = logger.child({ component: "push-service" });
|
||||
this.revokeToken = revokeToken;
|
||||
constructor(options: {
|
||||
logger: pino.Logger;
|
||||
revokeToken: (token: string) => void;
|
||||
sendRequest?: typeof fetch;
|
||||
}) {
|
||||
this.logger = options.logger.child({ component: "push-service" });
|
||||
this.revokeToken = options.revokeToken;
|
||||
this.sendRequest = options.sendRequest ?? fetch;
|
||||
}
|
||||
|
||||
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
|
||||
@@ -42,12 +49,16 @@ export class PushService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Expo defaults to "normal" priority on Android, which FCM holds back while
|
||||
// the device dozes, so notifications arrive minutes late. iOS already
|
||||
// defaults to "high".
|
||||
const messages: ExpoPushMessage[] = tokens.map((token) => ({
|
||||
to: token,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
data: payload.data,
|
||||
sound: "default",
|
||||
priority: "high",
|
||||
}));
|
||||
|
||||
// Batch tokens (max 100 per request per Expo limits)
|
||||
@@ -61,7 +72,7 @@ export class PushService {
|
||||
|
||||
private async sendBatch(messages: ExpoPushMessage[]): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
const response = await this.sendRequest(EXPO_PUSH_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -22,7 +22,6 @@ 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 = {
|
||||
@@ -343,7 +342,7 @@ class FakeAgentSession implements AgentSession {
|
||||
private permissionGate: Deferred<AgentPermissionResponse> | null = null;
|
||||
private readonly historyPath: string;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly nextTurnId = createTurnIdSequence(0);
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
@@ -456,7 +455,7 @@ class FakeAgentSession implements AgentSession {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
const turnId = this.nextTurnId("fake");
|
||||
const turnId = `fake-turn-${this.nextTurnOrdinal++}`;
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.onStartTurn?.(prompt, this.config);
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| Field | Shape / meaning |
|
||||
| ------------ | -------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider-reported `string` or `null`; unique for the agent's lifetime |
|
||||
| `turnId` | Provider-reported `string` or `null`; can repeat after a session reopens |
|
||||
| `timeline` | Deprecated, always `[]`; fetch history with the SDK. |
|
||||
| `request` | SDK `AgentPermissionRequest`; `kind` is `tool`, `plan`, `question`, `mode`, or `other` |
|
||||
| `resolution` | SDK `AgentPermissionResponse` |
|
||||
|
||||
@@ -92,7 +92,7 @@ type PluginTurnOutcome =
|
||||
|
||||
| 字段 | 结构 / 含义 |
|
||||
| ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;在 agent 的整个生命周期内唯一 |
|
||||
| `turnId` | Provider 报告的 `string` 或 `null`;会话重新打开后可能重复 |
|
||||
| `timeline` | 已弃用,始终为 `[]`;通过 SDK 获取历史。 |
|
||||
| `request` | SDK 的 `AgentPermissionRequest`;`kind` 为 `tool`、`plan`、`question`、`mode` 或 `other` |
|
||||
| `resolution` | SDK 的 `AgentPermissionResponse` |
|
||||
|
||||
Reference in New Issue
Block a user