test: harden daemon lifecycle and e2e reliability across app/cli/server
This commit is contained in:
@@ -206,6 +206,119 @@ describe('DaemonClient', () => {
|
||||
expect(client.getConnectionState().status).toBe('disposed')
|
||||
})
|
||||
|
||||
test('sends explicit shutdown_server_request via shutdownServer', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
|
||||
const lifecycleClient = client as unknown as {
|
||||
shutdownServer: (requestId?: string) => Promise<{
|
||||
status: 'shutdown_requested'
|
||||
clientId: string
|
||||
requestId: string
|
||||
}>
|
||||
}
|
||||
|
||||
expect(typeof lifecycleClient.shutdownServer).toBe('function')
|
||||
const promise = lifecycleClient.shutdownServer('req-shutdown-1')
|
||||
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: 'session'
|
||||
message: {
|
||||
type: string
|
||||
requestId: string
|
||||
}
|
||||
}
|
||||
expect(request.message).toEqual({
|
||||
type: 'shutdown_server_request',
|
||||
requestId: 'req-shutdown-1',
|
||||
})
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: 'status',
|
||||
payload: {
|
||||
status: 'shutdown_requested',
|
||||
clientId: 'clsk_unit_test',
|
||||
requestId: 'req-shutdown-1',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
status: 'shutdown_requested',
|
||||
clientId: 'clsk_unit_test',
|
||||
requestId: 'req-shutdown-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('restartServer remains restart-only and sends restart_server_request', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
clientId: 'clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
})
|
||||
clients.push(client)
|
||||
|
||||
const connectPromise = client.connect()
|
||||
mock.triggerOpen()
|
||||
await connectPromise
|
||||
|
||||
const promise = client.restartServer('settings_update', 'req-restart-1')
|
||||
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const request = JSON.parse(mock.sent[0]) as {
|
||||
type: 'session'
|
||||
message: {
|
||||
type: string
|
||||
reason?: string
|
||||
requestId: string
|
||||
}
|
||||
}
|
||||
expect(request.message).toEqual({
|
||||
type: 'restart_server_request',
|
||||
reason: 'settings_update',
|
||||
requestId: 'req-restart-1',
|
||||
})
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: 'status',
|
||||
payload: {
|
||||
status: 'restart_requested',
|
||||
clientId: 'clsk_unit_test',
|
||||
reason: 'settings_update',
|
||||
requestId: 'req-restart-1',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
status: 'restart_requested',
|
||||
clientId: 'clsk_unit_test',
|
||||
reason: 'settings_update',
|
||||
requestId: 'req-restart-1',
|
||||
})
|
||||
})
|
||||
|
||||
test('transitions out of connecting when connect timeout elapses', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AgentRefreshedStatusPayloadSchema,
|
||||
AgentResumedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
SessionInboundMessageSchema,
|
||||
type WSWelcomeMessage,
|
||||
WSOutboundMessageSchema,
|
||||
@@ -275,6 +276,7 @@ export type FetchAgentTimelineOptions = {
|
||||
|
||||
type AgentRefreshedStatusPayload = z.infer<typeof AgentRefreshedStatusPayloadSchema>
|
||||
type RestartRequestedStatusPayload = z.infer<typeof RestartRequestedStatusPayloadSchema>
|
||||
type ShutdownRequestedStatusPayload = z.infer<typeof ShutdownRequestedStatusPayloadSchema>
|
||||
type FetchAgentsPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: 'fetch_agents_response' }
|
||||
@@ -1542,6 +1544,33 @@ export class DaemonClient {
|
||||
})
|
||||
}
|
||||
|
||||
async shutdownServer(requestId?: string): Promise<ShutdownRequestedStatusPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId)
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: 'shutdown_server_request',
|
||||
requestId: resolvedRequestId,
|
||||
})
|
||||
return this.sendRequest({
|
||||
requestId: resolvedRequestId,
|
||||
message,
|
||||
timeout: 10000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== 'status') {
|
||||
return null
|
||||
}
|
||||
const shutdown = ShutdownRequestedStatusPayloadSchema.safeParse(msg.payload)
|
||||
if (!shutdown.success) {
|
||||
return null
|
||||
}
|
||||
if (shutdown.data.requestId !== resolvedRequestId) {
|
||||
return null
|
||||
}
|
||||
return shutdown.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Audio / Voice
|
||||
// ============================================================================
|
||||
@@ -2203,7 +2232,8 @@ export class DaemonClient {
|
||||
cwd: options?.cwd,
|
||||
},
|
||||
responseType: 'list_provider_models_response',
|
||||
timeout: 30000,
|
||||
// Provider SDK cold starts (especially model discovery) can exceed 30s.
|
||||
timeout: 45000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
});
|
||||
await waitForPathExists({
|
||||
targetPath: path.join(worktreePath, "dev-terminal.txt"),
|
||||
timeoutMs: 15000,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
} finally {
|
||||
if (agentId) {
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
||||
{ provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -8,57 +8,83 @@
|
||||
* the Claude Agent SDK's command capabilities.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
(hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => {
|
||||
let client: ClaudeAgentClient;
|
||||
let session: AgentSession;
|
||||
let session: AgentSession | null = null;
|
||||
let commands: AgentSlashCommand[] = [];
|
||||
let restoreClaudeConfigDir: (() => void) | null = null;
|
||||
let tempCwd: string | null = null;
|
||||
|
||||
// Mock config for testing - uses plan mode to avoid actual tool execution
|
||||
const testConfig: AgentSessionConfig = {
|
||||
const buildTestConfig = (cwd: string): AgentSessionConfig => ({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
cwd,
|
||||
modeId: "plan",
|
||||
};
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
restoreClaudeConfigDir = useTempClaudeConfigDir();
|
||||
const rawTempDir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-commands-"));
|
||||
try {
|
||||
tempCwd = realpathSync(rawTempDir);
|
||||
} catch {
|
||||
tempCwd = rawTempDir;
|
||||
}
|
||||
client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
session = await client.createSession(buildTestConfig(tempCwd));
|
||||
if (typeof session.listCommands !== "function") {
|
||||
throw new Error("Claude test session does not expose listCommands");
|
||||
}
|
||||
commands = await session.listCommands();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (session) {
|
||||
await session.close();
|
||||
try {
|
||||
if (session) {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
session = null;
|
||||
if (tempCwd) {
|
||||
rmSync(tempCwd, { recursive: true, force: true });
|
||||
tempCwd = null;
|
||||
}
|
||||
restoreClaudeConfigDir?.();
|
||||
restoreClaudeConfigDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("listCommands()", () => {
|
||||
it("should return an array of AgentSlashCommand objects", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
// The session should have a listCommands method
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
|
||||
// Should be an array
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
|
||||
// Should have at least some built-in commands
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
|
||||
it("should have valid AgentSlashCommand structure for all commands", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
// Verify all commands have valid structure
|
||||
for (const cmd of commands) {
|
||||
@@ -72,22 +98,19 @@ const hasClaudeCredentials =
|
||||
// Names should NOT have the / prefix (that's added when executing)
|
||||
expect(cmd.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
|
||||
it("should include user-defined skills", async () => {
|
||||
session = await client.createSession(testConfig);
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
const commands = await session.listCommands!();
|
||||
const commandNames = commands.map((cmd) => cmd.name);
|
||||
|
||||
// Should have at least one command (skills are loaded from user/project settings)
|
||||
// The exact commands depend on what skills are configured
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
expect(commandNames).toContain("rewind");
|
||||
|
||||
await session.close();
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { AgentStreamEventPayload } from "../../messages.js";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentPermissionRequest,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
@@ -48,6 +49,14 @@ function tmpCwd(): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSessionAndCleanup(
|
||||
session: AgentSession | null | undefined,
|
||||
cwd: string
|
||||
): Promise<void> {
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function autoApprove(session: Awaited<ReturnType<ClaudeAgentClient["createSession"]>>, event: AgentStreamEvent) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
@@ -288,15 +297,16 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const marker = "CLAUDE_ACK_TOKEN";
|
||||
const result = await session.run(
|
||||
`Reply with the exact text ${marker} and then stop.`
|
||||
);
|
||||
try {
|
||||
const marker = "CLAUDE_ACK_TOKEN";
|
||||
const result = await session.run(
|
||||
`Reply with the exact text ${marker} and then stop.`
|
||||
);
|
||||
|
||||
expect(result.finalText).toContain(marker);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
expect(result.finalText).toContain(marker);
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
},
|
||||
120_000
|
||||
);
|
||||
@@ -309,26 +319,27 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const events = session.stream(
|
||||
"Think step by step about the pros and cons of single-file tests, but only share a short plan."
|
||||
);
|
||||
try {
|
||||
const events = session.stream(
|
||||
"Think step by step about the pros and cons of single-file tests, but only share a short plan."
|
||||
);
|
||||
|
||||
let sawReasoning = false;
|
||||
let sawReasoning = false;
|
||||
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline" && event.item.type === "reasoning") {
|
||||
sawReasoning = true;
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline" && event.item.type === "reasoning") {
|
||||
sawReasoning = true;
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(sawReasoning).toBe(true);
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
expect(sawReasoning).toBe(true);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
120_000
|
||||
);
|
||||
@@ -421,64 +432,65 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const events = session.stream(
|
||||
"First run a Bash command to print the working directory, then use your editor tools (not the shell) to create a file named tool-test.txt in the current directory that contains exactly the text 'hello world'. Report 'done' after the write finishes."
|
||||
);
|
||||
try {
|
||||
const events = session.stream(
|
||||
"First run a Bash command to print the working directory, then use your editor tools (not the shell) to create a file named tool-test.txt in the current directory that contains exactly the text 'hello world'. Report 'done' after the write finishes."
|
||||
);
|
||||
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
let completed = false;
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
let completed = false;
|
||||
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
break;
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call"
|
||||
);
|
||||
const commandEvents = toolCalls.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes("bash") &&
|
||||
item.name !== "permission_request"
|
||||
);
|
||||
const fileChangeEvent = toolCalls.find((item) => {
|
||||
if (item.detail.type === "write" || item.detail.type === "edit") {
|
||||
return item.detail.filePath.includes("tool-test.txt");
|
||||
}
|
||||
if (item.detail.type === "unknown") {
|
||||
return (
|
||||
rawContainsText(item.detail.input, "tool-test.txt") ||
|
||||
rawContainsText(item.detail.output, "tool-test.txt")
|
||||
);
|
||||
}
|
||||
return rawContainsText(item.detail, "tool-test.txt");
|
||||
});
|
||||
|
||||
const sawPwdCommand = commandEvents.some(
|
||||
(item) => (extractToolCommand(item.detail) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||
);
|
||||
|
||||
expect(completed).toBe(true);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
expect(sawPwdCommand).toBe(true);
|
||||
expect(fileChangeEvent).toBeTruthy();
|
||||
|
||||
const filePath = path.join(cwd, "tool-test.txt");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("hello world");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call"
|
||||
);
|
||||
const commandEvents = toolCalls.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes("bash") &&
|
||||
item.name !== "permission_request"
|
||||
);
|
||||
const fileChangeEvent = toolCalls.find((item) => {
|
||||
if (item.detail.type === "write" || item.detail.type === "edit") {
|
||||
return item.detail.filePath.includes("tool-test.txt");
|
||||
}
|
||||
if (item.detail.type === "unknown") {
|
||||
return (
|
||||
rawContainsText(item.detail.input, "tool-test.txt") ||
|
||||
rawContainsText(item.detail.output, "tool-test.txt")
|
||||
);
|
||||
}
|
||||
return rawContainsText(item.detail, "tool-test.txt");
|
||||
});
|
||||
|
||||
const sawPwdCommand = commandEvents.some(
|
||||
(item) => (extractToolCommand(item.detail) ?? "").toLowerCase().includes("pwd") && item.status === "completed"
|
||||
);
|
||||
|
||||
expect(completed).toBe(true);
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
expect(sawPwdCommand).toBe(true);
|
||||
expect(fileChangeEvent).toBeTruthy();
|
||||
|
||||
const filePath = path.join(cwd, "tool-test.txt");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("hello world");
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
@@ -775,16 +787,17 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const first = await session.run("Respond only with the word alpha.");
|
||||
expect(first.finalText.toLowerCase()).toContain("alpha");
|
||||
try {
|
||||
const first = await session.run("Respond only with the word alpha.");
|
||||
expect(first.finalText.toLowerCase()).toContain("alpha");
|
||||
|
||||
const second = await session.run(
|
||||
"Without adding any explanations, repeat exactly the same word you just said."
|
||||
);
|
||||
expect(second.finalText.toLowerCase()).toContain("alpha");
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
const second = await session.run(
|
||||
"Without adding any explanations, repeat exactly the same word you just said."
|
||||
);
|
||||
expect(second.finalText.toLowerCase()).toContain("alpha");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
},
|
||||
120_000
|
||||
);
|
||||
@@ -853,77 +866,80 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
|
||||
const session = await client.createSession(config);
|
||||
let resumed: AgentSession | null = null;
|
||||
|
||||
// Store a specific word in a file to create history and enable recall
|
||||
const timestamp = Date.now();
|
||||
const secretWord = `XYZZY${timestamp}PLUGH`;
|
||||
const secretFile = path.join(cwd, "secret.txt");
|
||||
const prompt = `Write exactly this word to a file called secret.txt: ${secretWord}. Then respond only with "STORED".`;
|
||||
try {
|
||||
// Store a specific word in a file to create history and enable recall
|
||||
const timestamp = Date.now();
|
||||
const secretWord = `XYZZY${timestamp}PLUGH`;
|
||||
const secretFile = path.join(cwd, "secret.txt");
|
||||
const prompt = `Write exactly this word to a file called secret.txt: ${secretWord}. Then respond only with "STORED".`;
|
||||
|
||||
let storedResponse = "";
|
||||
for await (const event of session.stream(prompt)) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
storedResponse = event.item.text;
|
||||
let storedResponse = "";
|
||||
for await (const event of session.stream(prompt)) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline" && event.item.type === "assistant_message") {
|
||||
storedResponse = event.item.text;
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
expect(storedResponse.toLowerCase()).toContain("stored");
|
||||
expect(existsSync(secretFile)).toBe(true);
|
||||
|
||||
await session.close();
|
||||
|
||||
const handle = session.describePersistence();
|
||||
expect(handle).toBeTruthy();
|
||||
expect(handle!.sessionId).toBeTruthy();
|
||||
|
||||
// Wait for history file to be written
|
||||
const historyPaths = getClaudeHistoryPaths(cwd, handle!.sessionId);
|
||||
expect(await waitForHistoryFile(historyPaths)).toBe(true);
|
||||
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
|
||||
|
||||
// Resume and verify context is preserved
|
||||
resumed = await client.resumeSession(handle!, { cwd });
|
||||
|
||||
// Verify history is emitted on resume
|
||||
const historyEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
historyEvents.push(event);
|
||||
}
|
||||
|
||||
// Should have timeline events from previous session
|
||||
const timelineEvents = historyEvents.filter((e) => e.type === "timeline");
|
||||
expect(timelineEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include the user message with the secret word
|
||||
const userMessages = timelineEvents.filter(
|
||||
(e) => e.type === "timeline" && e.item.type === "user_message"
|
||||
);
|
||||
expect(userMessages.length).toBeGreaterThan(0);
|
||||
const hasSecretWord = userMessages.some(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "user_message" &&
|
||||
e.item.text.includes(secretWord)
|
||||
);
|
||||
expect(hasSecretWord).toBe(true);
|
||||
|
||||
// Ask the agent to recall what it wrote - this verifies context is actually preserved
|
||||
const resumedResult = await resumed.run(
|
||||
"What word did you write to secret.txt? Reply with only that exact word."
|
||||
);
|
||||
// The model should recall some part of the unique word we stored
|
||||
// (models sometimes truncate or modify, so we check for any part of our unique token)
|
||||
const recalledSomething =
|
||||
resumedResult.finalText.includes(String(timestamp)) ||
|
||||
resumedResult.finalText.includes("XYZZY") ||
|
||||
resumedResult.finalText.includes("PLUGH");
|
||||
expect(recalledSomething).toBe(true);
|
||||
} finally {
|
||||
await resumed?.close();
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
expect(storedResponse.toLowerCase()).toContain("stored");
|
||||
expect(existsSync(secretFile)).toBe(true);
|
||||
|
||||
await session.close();
|
||||
|
||||
const handle = session.describePersistence();
|
||||
expect(handle).toBeTruthy();
|
||||
expect(handle!.sessionId).toBeTruthy();
|
||||
|
||||
// Wait for history file to be written
|
||||
const historyPaths = getClaudeHistoryPaths(cwd, handle!.sessionId);
|
||||
expect(await waitForHistoryFile(historyPaths)).toBe(true);
|
||||
expect(await waitForHistoryContains(historyPaths, secretWord)).toBe(true);
|
||||
|
||||
// Resume and verify context is preserved
|
||||
const resumed = await client.resumeSession(handle!, { cwd });
|
||||
|
||||
// Verify history is emitted on resume
|
||||
const historyEvents: AgentStreamEvent[] = [];
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
historyEvents.push(event);
|
||||
}
|
||||
|
||||
// Should have timeline events from previous session
|
||||
const timelineEvents = historyEvents.filter((e) => e.type === "timeline");
|
||||
expect(timelineEvents.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include the user message with the secret word
|
||||
const userMessages = timelineEvents.filter(
|
||||
(e) => e.type === "timeline" && e.item.type === "user_message"
|
||||
);
|
||||
expect(userMessages.length).toBeGreaterThan(0);
|
||||
const hasSecretWord = userMessages.some(
|
||||
(e) =>
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "user_message" &&
|
||||
e.item.text.includes(secretWord)
|
||||
);
|
||||
expect(hasSecretWord).toBe(true);
|
||||
|
||||
// Ask the agent to recall what it wrote - this verifies context is actually preserved
|
||||
const resumedResult = await resumed.run(
|
||||
"What word did you write to secret.txt? Reply with only that exact word."
|
||||
);
|
||||
// The model should recall some part of the unique word we stored
|
||||
// (models sometimes truncate or modify, so we check for any part of our unique token)
|
||||
const recalledSomething =
|
||||
resumedResult.finalText.includes(String(timestamp)) ||
|
||||
resumedResult.finalText.includes("XYZZY") ||
|
||||
resumedResult.finalText.includes("PLUGH");
|
||||
expect(recalledSomething).toBe(true);
|
||||
|
||||
await resumed.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
@@ -936,19 +952,20 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 1024 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const modes = await session.getAvailableModes();
|
||||
expect(modes.map((m) => m.id)).toContain("plan");
|
||||
try {
|
||||
const modes = await session.getAvailableModes();
|
||||
expect(modes.map((m) => m.id)).toContain("plan");
|
||||
|
||||
await session.setMode("plan");
|
||||
expect(await session.getCurrentMode()).toBe("plan");
|
||||
await session.setMode("plan");
|
||||
expect(await session.getCurrentMode()).toBe("plan");
|
||||
|
||||
const result = await session.run(
|
||||
"Just reply with the word PLAN to confirm you're still responsive."
|
||||
);
|
||||
expect(result.finalText.toLowerCase()).toContain("plan");
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
const result = await session.run(
|
||||
"Just reply with the word PLAN to confirm you're still responsive."
|
||||
);
|
||||
expect(result.finalText.toLowerCase()).toContain("plan");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
},
|
||||
120_000
|
||||
);
|
||||
@@ -960,42 +977,44 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
await session.setMode("plan");
|
||||
|
||||
const events = session.stream(
|
||||
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
|
||||
);
|
||||
try {
|
||||
await session.setMode("plan");
|
||||
|
||||
let capturedPlan: string | null = null;
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "permission_requested" && event.request.kind === "plan") {
|
||||
const planFromMetadata =
|
||||
typeof event.request.metadata?.planText === "string"
|
||||
? event.request.metadata.planText
|
||||
: null;
|
||||
const planFromInput =
|
||||
typeof (event.request.input as any)?.plan === "string"
|
||||
? ((event.request.input as any)?.plan as string)
|
||||
: null;
|
||||
capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan;
|
||||
const events = session.stream(
|
||||
"Devise a plan to create a file named dummy.txt containing the word plan-test. After planning, proceed to execute your plan."
|
||||
);
|
||||
|
||||
let capturedPlan: string | null = null;
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "permission_requested" && event.request.kind === "plan") {
|
||||
const planFromMetadata =
|
||||
typeof event.request.metadata?.planText === "string"
|
||||
? event.request.metadata.planText
|
||||
: null;
|
||||
const planFromInput =
|
||||
typeof (event.request.input as any)?.plan === "string"
|
||||
? ((event.request.input as any)?.plan as string)
|
||||
: null;
|
||||
capturedPlan = planFromMetadata ?? planFromInput ?? capturedPlan;
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
expect(capturedPlan).not.toBeNull();
|
||||
expect(capturedPlan?.includes("dummy.txt")).toBe(true);
|
||||
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
||||
|
||||
const filePath = path.join(cwd, "dummy.txt");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("plan-test");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
expect(capturedPlan).not.toBeNull();
|
||||
expect(capturedPlan?.includes("dummy.txt")).toBe(true);
|
||||
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
||||
|
||||
const filePath = path.join(cwd, "dummy.txt");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toContain("plan-test");
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
@@ -1008,67 +1027,68 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const prompt = [
|
||||
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
|
||||
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
|
||||
"Set multiSelect to false.",
|
||||
"After receiving the answer, reply with exactly QUESTION_FLOW_DONE.",
|
||||
"Do not use any other tools.",
|
||||
].join(" ");
|
||||
try {
|
||||
const prompt = [
|
||||
"You must call the AskUserQuestion tool exactly once and wait for the user's answer.",
|
||||
"Create one question with header 'color', prompt 'Choose a color', and options Blue and Red.",
|
||||
"Set multiSelect to false.",
|
||||
"After receiving the answer, reply with exactly QUESTION_FLOW_DONE.",
|
||||
"Do not use any other tools.",
|
||||
].join(" ");
|
||||
|
||||
let capturedQuestion: AgentPermissionRequest | null = null;
|
||||
let sawResolvedAllow = false;
|
||||
let sawDone = false;
|
||||
let capturedQuestion: AgentPermissionRequest | null = null;
|
||||
let sawResolvedAllow = false;
|
||||
let sawDone = false;
|
||||
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (
|
||||
event.type === "permission_requested" &&
|
||||
event.request.kind === "question" &&
|
||||
!capturedQuestion
|
||||
) {
|
||||
capturedQuestion = event.request;
|
||||
const baseInput =
|
||||
typeof capturedQuestion.input === "object" && capturedQuestion.input !== null
|
||||
? (capturedQuestion.input as Record<string, unknown>)
|
||||
: {};
|
||||
await session.respondToPermission(capturedQuestion.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...baseInput,
|
||||
answers: { color: "Blue" },
|
||||
},
|
||||
});
|
||||
for await (const event of session.stream(prompt)) {
|
||||
if (
|
||||
event.type === "permission_requested" &&
|
||||
event.request.kind === "question" &&
|
||||
!capturedQuestion
|
||||
) {
|
||||
capturedQuestion = event.request;
|
||||
const baseInput =
|
||||
typeof capturedQuestion.input === "object" && capturedQuestion.input !== null
|
||||
? (capturedQuestion.input as Record<string, unknown>)
|
||||
: {};
|
||||
await session.respondToPermission(capturedQuestion.id, {
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...baseInput,
|
||||
answers: { color: "Blue" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
capturedQuestion &&
|
||||
event.requestId === capturedQuestion.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawResolvedAllow = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("QUESTION_FLOW_DONE")
|
||||
) {
|
||||
sawDone = true;
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "permission_resolved" &&
|
||||
capturedQuestion &&
|
||||
event.requestId === capturedQuestion.id &&
|
||||
event.resolution.behavior === "allow"
|
||||
) {
|
||||
sawResolvedAllow = true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes("QUESTION_FLOW_DONE")
|
||||
) {
|
||||
sawDone = true;
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
expect(capturedQuestion).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(sawDone).toBe(true);
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
expect(capturedQuestion).not.toBeNull();
|
||||
expect(sawResolvedAllow).toBe(true);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
expect(sawDone).toBe(true);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
@@ -1271,50 +1291,51 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
||||
const config = buildConfig(cwd, { maxThinkingTokens: 2048 });
|
||||
const session = await client.createSession(config);
|
||||
|
||||
const events = session.stream(
|
||||
"Use the Task tool to launch a sub-agent that reads the current directory listing. " +
|
||||
"The sub-agent should run 'ls' in the shell and report the result. " +
|
||||
"Do NOT do the work yourself — delegate it to a sub-agent via the Task tool."
|
||||
);
|
||||
try {
|
||||
const events = session.stream(
|
||||
"Use the Task tool to launch a sub-agent that reads the current directory listing. " +
|
||||
"The sub-agent should run 'ls' in the shell and report the result. " +
|
||||
"Do NOT do the work yourself — delegate it to a sub-agent via the Task tool."
|
||||
);
|
||||
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
for await (const event of events) {
|
||||
await autoApprove(session, event);
|
||||
if (event.type === "timeline") {
|
||||
timeline.push(event.item);
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is ToolCallItem => item.type === "tool_call"
|
||||
);
|
||||
|
||||
// There should be at least one Task tool call
|
||||
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
||||
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
||||
// updates should exist for the sub-agent activity
|
||||
const taskWithSubAgentDetail = taskCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
if (taskCalls.length > 0) {
|
||||
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Verify the curator produces clean output with collapsed Task entries
|
||||
const curated = curateAgentActivity(timeline);
|
||||
const lines = curated.split("\n");
|
||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
||||
// Each Task callId should appear at most once in curated output
|
||||
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is ToolCallItem => item.type === "tool_call"
|
||||
);
|
||||
|
||||
// There should be at least one Task tool call
|
||||
const taskCalls = toolCalls.filter((item) => item.name === "Task");
|
||||
expect(taskCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// We can't 100% guarantee Claude won't also use tools directly, but Task detail
|
||||
// updates should exist for the sub-agent activity
|
||||
const taskWithSubAgentDetail = taskCalls.filter(
|
||||
(item) => item.detail.type === "sub_agent"
|
||||
);
|
||||
if (taskCalls.length > 0) {
|
||||
expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Verify the curator produces clean output with collapsed Task entries
|
||||
const curated = curateAgentActivity(timeline);
|
||||
const lines = curated.split("\n");
|
||||
const taskLines = lines.filter((l) => l.includes("[Task]"));
|
||||
// Each Task callId should appear at most once in curated output
|
||||
expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length);
|
||||
|
||||
await session.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
180_000
|
||||
);
|
||||
|
||||
@@ -74,6 +74,34 @@ const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = [
|
||||
"user",
|
||||
"project",
|
||||
];
|
||||
const CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
|
||||
const CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
|
||||
type ClaudeFallbackModel = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
const CLAUDE_FALLBACK_MODELS: readonly ClaudeFallbackModel[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Sonnet 4.5",
|
||||
description: "Best for everyday tasks",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
label: "Opus 4.6",
|
||||
description: "Most capable model for deep analysis and complex code changes",
|
||||
},
|
||||
{
|
||||
id: "haiku",
|
||||
label: "Haiku 4.5",
|
||||
description: "Fastest Claude model for lightweight tasks",
|
||||
},
|
||||
];
|
||||
|
||||
type TurnState = "idle" | "foreground" | "autonomous";
|
||||
|
||||
@@ -129,6 +157,66 @@ type ForegroundTurnState = {
|
||||
|
||||
type ClaudeModelFamily = "sonnet" | "opus" | "haiku";
|
||||
|
||||
function withTimeout<T>(params: {
|
||||
promise: Promise<T>;
|
||||
timeoutMs: number;
|
||||
label: string;
|
||||
}): Promise<T> {
|
||||
const { promise, timeoutMs, label } = params;
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function toClaudeModelDefinition(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
}): AgentModelDefinition {
|
||||
return {
|
||||
provider: "claude",
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
description: params.description,
|
||||
...(params.isDefault ? { isDefault: true } : {}),
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off", isDefault: true },
|
||||
{ id: "on", label: "On" },
|
||||
],
|
||||
defaultThinkingOptionId: "off",
|
||||
metadata: params.description
|
||||
? {
|
||||
description: params.description,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClaudeFallbackModels(): AgentModelDefinition[] {
|
||||
return CLAUDE_FALLBACK_MODELS.map((model) =>
|
||||
toClaudeModelDefinition({
|
||||
id: model.id,
|
||||
label: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeClaudeModelLabel(model: ModelInfo): string {
|
||||
const fallback = model.displayName?.trim() || model.value;
|
||||
const prefix = model.description?.split(/[·•]/)[0]?.trim() || "";
|
||||
@@ -190,15 +278,6 @@ function inferClaudeModelFamilyFromText(
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferClaudeModelFamily(model: ModelInfo): ClaudeModelFamily | null {
|
||||
const descriptionPrefix = model.description?.split(/[·•]/)[0]?.trim() || null;
|
||||
return (
|
||||
inferClaudeModelFamilyFromText(descriptionPrefix) ??
|
||||
inferClaudeModelFamilyFromText(model.displayName) ??
|
||||
inferClaudeModelFamilyFromText(model.value)
|
||||
);
|
||||
}
|
||||
|
||||
function pickFamilyAliasModelId(
|
||||
familyAliases: ReadonlyMap<ClaudeModelFamily, string> | null | undefined,
|
||||
family: ClaudeModelFamily
|
||||
@@ -1468,25 +1547,38 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
options: this.applyRuntimeSettings(claudeOptions),
|
||||
});
|
||||
try {
|
||||
const models: ModelInfo[] = await claudeQuery.supportedModels();
|
||||
return models.map((model) => ({
|
||||
provider: "claude" as const,
|
||||
id: model.value,
|
||||
label: normalizeClaudeModelLabel(model),
|
||||
description: model.description,
|
||||
thinkingOptions: [
|
||||
{ id: "off", label: "Off", isDefault: true },
|
||||
{ id: "on", label: "On" },
|
||||
],
|
||||
defaultThinkingOptionId: "off",
|
||||
metadata: {
|
||||
const models: ModelInfo[] = await withTimeout({
|
||||
promise: claudeQuery.supportedModels(),
|
||||
timeoutMs: CLAUDE_MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
label: "Claude model discovery",
|
||||
});
|
||||
if (models.length === 0) {
|
||||
this.logger.warn(
|
||||
"Claude SDK returned an empty model catalog; using fallback Claude model aliases"
|
||||
);
|
||||
return buildClaudeFallbackModels();
|
||||
}
|
||||
return models.map((model) =>
|
||||
toClaudeModelDefinition({
|
||||
id: model.value,
|
||||
label: normalizeClaudeModelLabel(model),
|
||||
description: model.description,
|
||||
},
|
||||
}));
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error },
|
||||
"Failed to fetch Claude model catalog from SDK; using fallback Claude model aliases"
|
||||
);
|
||||
return buildClaudeFallbackModels();
|
||||
} finally {
|
||||
if (typeof claudeQuery.return === "function") {
|
||||
try {
|
||||
await claudeQuery.return();
|
||||
await withTimeout({
|
||||
promise: claudeQuery.return(),
|
||||
timeoutMs: CLAUDE_MODEL_QUERY_SHUTDOWN_TIMEOUT_MS,
|
||||
label: "Claude model query shutdown",
|
||||
});
|
||||
} catch {
|
||||
// ignore shutdown errors
|
||||
}
|
||||
@@ -2262,38 +2354,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.userMessageIds.push(messageId);
|
||||
}
|
||||
|
||||
private async primeSelectableModelIds(query: Query): Promise<void> {
|
||||
try {
|
||||
const models = await query.supportedModels();
|
||||
const ids: string[] = [];
|
||||
const familyAliases = new Map<ClaudeModelFamily, string>();
|
||||
for (const model of models) {
|
||||
const modelId = normalizeModelIdCandidate(model.value);
|
||||
if (!modelId) {
|
||||
continue;
|
||||
}
|
||||
ids.push(modelId);
|
||||
const family = inferClaudeModelFamily(model);
|
||||
if (family && !familyAliases.has(family)) {
|
||||
familyAliases.set(family, modelId);
|
||||
}
|
||||
}
|
||||
this.selectableModelIds = new Set(ids);
|
||||
this.selectableModelFamilyAliases = familyAliases.size > 0 ? familyAliases : null;
|
||||
this.logger.debug(
|
||||
{
|
||||
modelIds: ids,
|
||||
modelFamilyAliases: Object.fromEntries(familyAliases),
|
||||
},
|
||||
"Primed Claude selectable model IDs"
|
||||
);
|
||||
} catch (error) {
|
||||
this.selectableModelIds = null;
|
||||
this.selectableModelFamilyAliases = null;
|
||||
this.logger.warn({ err: error }, "Failed to prime Claude selectable model IDs");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
if (this.query && !this.queryRestartNeeded) {
|
||||
return this.query;
|
||||
@@ -2315,12 +2375,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
this.input = input;
|
||||
this.query = query({ prompt: input, options });
|
||||
// Do not block query readiness on control-plane calls. We need `next()` to
|
||||
// start immediately so autonomous wake events are not missed between turns.
|
||||
void this.awaitWithTimeout(
|
||||
this.primeSelectableModelIds(this.query),
|
||||
"prime selectable model ids"
|
||||
);
|
||||
// Do not kick off background control-plane queries here. Methods like
|
||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||
// control plane can cause those calls to wait behind supportedModels().
|
||||
return this.query;
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,29 @@ describe("Codex app-server provider (integration)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("maps patch notifications with file_path aliases in array-style changes", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-file-path",
|
||||
changes: [
|
||||
{
|
||||
file_path: "/tmp/repo/src/alias-path.ts",
|
||||
type: "modify",
|
||||
diff: "@@\n-before\n+after\n",
|
||||
},
|
||||
],
|
||||
cwd: "/tmp/repo",
|
||||
running: false,
|
||||
});
|
||||
|
||||
expect(item.detail.type).toBe("edit");
|
||||
if (item.detail.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/alias-path.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-before");
|
||||
expect(item.detail.unifiedDiff).toContain("+after");
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const models = await client.listModels();
|
||||
@@ -457,6 +480,15 @@ describe("Codex app-server provider (integration)", () => {
|
||||
modeId: "read-only",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
extra: {
|
||||
codex: {
|
||||
tools: {
|
||||
shell: false,
|
||||
list_mcp_resources: false,
|
||||
list_mcp_resource_templates: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
paseo_test: {
|
||||
type: "stdio",
|
||||
@@ -468,7 +500,7 @@ describe("Codex app-server provider (integration)", () => {
|
||||
|
||||
const result = await session.run(
|
||||
[
|
||||
"You must call the MCP tool named paseo_test.echo exactly once.",
|
||||
"You must call the MCP tool named paseo_test.paseo_roundtrip_text exactly once.",
|
||||
`Call it with text: ${token}`,
|
||||
"Do not use shell or any non-MCP tools.",
|
||||
"After the tool call, respond with exactly the tool output text.",
|
||||
@@ -481,9 +513,10 @@ describe("Codex app-server provider (integration)", () => {
|
||||
item.type === "tool_call"
|
||||
);
|
||||
const toolNames = toolCalls.map((item) => item.name);
|
||||
const nonMcpToolNames = toolNames.filter((name) => name !== "paseo_test.paseo_roundtrip_text");
|
||||
const distinctMcpCalls = new Map<string, Extract<AgentTimelineItem, { type: "tool_call" }>>();
|
||||
for (const call of toolCalls) {
|
||||
if (call.name !== "paseo_test.echo") {
|
||||
if (call.name !== "paseo_test.paseo_roundtrip_text") {
|
||||
continue;
|
||||
}
|
||||
const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.detail)}`);
|
||||
@@ -494,14 +527,25 @@ describe("Codex app-server provider (integration)", () => {
|
||||
}
|
||||
|
||||
// Hard assertion: exactly one distinct call of the exact MCP tool.
|
||||
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
|
||||
if (nonMcpToolNames.length > 0) {
|
||||
const nonMcpCalls = toolCalls
|
||||
.filter((call) => call.name !== "paseo_test.paseo_roundtrip_text")
|
||||
.map((call) => ({
|
||||
name: call.name,
|
||||
status: call.status,
|
||||
detail: call.detail,
|
||||
}));
|
||||
throw new Error(
|
||||
`Unexpected non-MCP tool calls in MCP round-trip: ${JSON.stringify(nonMcpCalls)}; all tool names: ${JSON.stringify(toolNames)}`
|
||||
);
|
||||
}
|
||||
expect(distinctMcpCalls.size).toBe(1);
|
||||
const mcpToolCall = Array.from(distinctMcpCalls.values())[0]!;
|
||||
expect(mcpToolCall.name).toBe("paseo_test.echo");
|
||||
expect(mcpToolCall.name).toBe("paseo_test.paseo_roundtrip_text");
|
||||
expect(mcpToolCall.status).toBe("completed");
|
||||
|
||||
// Hard assertion: no non-MCP tools in this run.
|
||||
expect(toolNames.every((name) => name === "paseo_test.echo")).toBe(true);
|
||||
expect(nonMcpToolNames).toEqual([]);
|
||||
expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
|
||||
|
||||
// Hard assertion: roundtrip token must be present in the MCP tool I/O.
|
||||
@@ -521,7 +565,8 @@ describe("Codex app-server provider (integration)", () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
const promptsDir = path.join(codexHome, "prompts");
|
||||
const promptPath = path.join(promptsDir, "test.md");
|
||||
const promptName = `paseo-test-${process.pid}-${Date.now().toString(36)}`;
|
||||
const promptPath = path.join(promptsDir, `${promptName}.md`);
|
||||
const cwd = tmpCwd("codex-cmd-");
|
||||
const token = `PASEO_PROMPT_TOKEN_${Date.now()}`;
|
||||
|
||||
@@ -549,13 +594,37 @@ describe("Codex app-server provider (integration)", () => {
|
||||
});
|
||||
try {
|
||||
const commands = await session.listCommands?.();
|
||||
expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true);
|
||||
expect(commands?.some((cmd) => cmd.name === `prompts:${promptName}`)).toBe(true);
|
||||
|
||||
const executeArgs = "NAME=world extra_value";
|
||||
const expectedExpanded = `${token}::name=world::pos1=extra_value::dollar=$`;
|
||||
const rawSlashInput = "/prompts:test NAME=world extra_value";
|
||||
const rawSlashInput = `/prompts:${promptName} ${executeArgs}`;
|
||||
const runResult = await session.run(rawSlashInput);
|
||||
expect(runResult.finalText).toContain(expectedExpanded);
|
||||
|
||||
const internal = session as unknown as {
|
||||
client?: {
|
||||
request: (method: string, params: unknown) => Promise<unknown>;
|
||||
};
|
||||
currentThreadId?: string | null;
|
||||
};
|
||||
const threadId = internal.currentThreadId;
|
||||
const codexClient = internal.client;
|
||||
if (!threadId || !codexClient) {
|
||||
throw new Error("Codex session did not initialize app-server client/thread");
|
||||
}
|
||||
|
||||
const threadRead = (await codexClient.request("thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
})) as { thread?: { path?: string } };
|
||||
const rolloutPath = threadRead.thread?.path;
|
||||
if (!rolloutPath) {
|
||||
throw new Error("Codex app-server did not return rollout path");
|
||||
}
|
||||
|
||||
const rolloutText = readFileSync(rolloutPath, "utf8");
|
||||
expect(rolloutText).toContain(expectedExpanded);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -574,7 +643,8 @@ describe("Codex app-server provider (integration)", () => {
|
||||
const cleanup = useTempCodexSessionDir();
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
const promptsDir = path.join(codexHome, "prompts");
|
||||
const promptPath = path.join(promptsDir, "stream-test.md");
|
||||
const promptName = `paseo-stream-${process.pid}-${Date.now().toString(36)}`;
|
||||
const promptPath = path.join(promptsDir, `${promptName}.md`);
|
||||
const cwd = tmpCwd("codex-cmd-stream-");
|
||||
const token = `PASEO_STREAM_TOKEN_${Date.now()}`;
|
||||
|
||||
@@ -600,7 +670,7 @@ describe("Codex app-server provider (integration)", () => {
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
try {
|
||||
const events = session.stream("/prompts:stream-test");
|
||||
const events = session.stream(`/prompts:${promptName}`);
|
||||
const seenTypes = new Set<string>();
|
||||
const assistantChunks: string[] = [];
|
||||
for await (const event of events) {
|
||||
@@ -774,12 +844,17 @@ describe("Codex app-server provider (integration)", () => {
|
||||
|
||||
expect(sawPermission).toBe(true);
|
||||
expect(sawPermissionResolvedDeny).toBe(true);
|
||||
expect(
|
||||
timelineItems.some(
|
||||
(item) =>
|
||||
item.status === "failed" && hasShellCommand(item, "permission-deny.txt")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(captured).not.toBeNull();
|
||||
const deniedShellCall = timelineItems.find(
|
||||
(item) =>
|
||||
item.name === "shell" &&
|
||||
item.status === "failed" &&
|
||||
typeof item.metadata === "object" &&
|
||||
item.metadata !== null &&
|
||||
(item.metadata as { permissionRequestId?: string }).permissionRequestId === captured?.id
|
||||
);
|
||||
expect(deniedShellCall).toBeDefined();
|
||||
expect(deniedShellCall ? hasShellCommand(deniedShellCall, "permission-deny.txt") : false).toBe(true);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
@@ -796,16 +871,6 @@ describe("Codex app-server provider (integration)", () => {
|
||||
const patchFile = path.join(cwd, "patch.txt");
|
||||
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(logger);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
|
||||
let sawAssistantMessage = false;
|
||||
let sawShellTool = false;
|
||||
let sawPatchTool = false;
|
||||
@@ -813,33 +878,46 @@ describe("Codex app-server provider (integration)", () => {
|
||||
let sawPatchCompleted = false;
|
||||
const timelineItems: AgentTimelineItem[] = [];
|
||||
|
||||
const shellEvents = session.stream(
|
||||
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
|
||||
);
|
||||
const shellClient = new CodexAppServerAgentClient(logger);
|
||||
const shellSession = await shellClient.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
let failure: string | null = null;
|
||||
for await (const event of shellEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
try {
|
||||
const shellEvents = shellSession.stream(
|
||||
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
|
||||
);
|
||||
for await (const event of shellEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await shellSession.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (hasShellCommand(event.item, "printf")) {
|
||||
sawShellTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawShellCompleted = true;
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
}
|
||||
if (hasShellCommand(event.item, "printf")) {
|
||||
sawShellTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawShellCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await shellSession.close();
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
@@ -852,39 +930,50 @@ describe("Codex app-server provider (integration)", () => {
|
||||
"+patched",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const patchEvents = session.stream(
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
const patchClient = new CodexAppServerAgentClient(logger);
|
||||
const patchSession = await patchClient.createSession({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
});
|
||||
try {
|
||||
const patchEvents = patchSession.stream(
|
||||
buildStrictApplyPatchPrompt(patch, "PATCH_DONE", {
|
||||
includePermissionStep: true,
|
||||
})
|
||||
);
|
||||
|
||||
for await (const event of patchEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
for await (const event of patchEvents) {
|
||||
if (event.type === "permission_requested") {
|
||||
await patchSession.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
}
|
||||
if (hasApplyPatchFile(event.item, "patch.txt")) {
|
||||
sawPatchTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawPatchCompleted = true;
|
||||
if (event.type === "timeline") {
|
||||
timelineItems.push(event.item);
|
||||
if (event.item.type === "assistant_message") {
|
||||
sawAssistantMessage = true;
|
||||
}
|
||||
if (hasApplyPatchFile(event.item, "patch.txt")) {
|
||||
sawPatchTool = true;
|
||||
if (event.item.status === "completed") {
|
||||
sawPatchCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (event.type === "turn_failed") {
|
||||
failure = event.error;
|
||||
break;
|
||||
}
|
||||
if (event.type === "turn_completed") {
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
await patchSession.close();
|
||||
}
|
||||
|
||||
await session.close();
|
||||
|
||||
if (failure) {
|
||||
throw new Error(failure);
|
||||
}
|
||||
@@ -1294,11 +1383,16 @@ describe("Codex app-server provider (integration)", () => {
|
||||
return result.value;
|
||||
};
|
||||
|
||||
// Interrupt should be observed quickly; don't allow this test to hang if the stream stalls.
|
||||
// Keep polling until the hard deadline; first-run Codex startup can leave
|
||||
// a quiet gap >10s before the shell tool call appears.
|
||||
const hardDeadline = Date.now() + 45_000;
|
||||
while (Date.now() < hardDeadline) {
|
||||
const event = await nextEvent(10_000);
|
||||
if (!event) break;
|
||||
const remainingMs = hardDeadline - Date.now();
|
||||
const pollWindowMs = Math.max(250, Math.min(10_000, remainingMs));
|
||||
const event = await nextEvent(pollWindowMs);
|
||||
if (!event) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "permission_requested") {
|
||||
await session.respondToPermission(event.request.id, { behavior: "allow" });
|
||||
|
||||
@@ -812,6 +812,20 @@ function normalizeCodexCommandValue(
|
||||
}
|
||||
|
||||
function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
const resolvePathFromRecord = (record: Record<string, unknown>): string => {
|
||||
const directPath =
|
||||
(typeof record.path === "string" && record.path.trim().length > 0
|
||||
? record.path.trim()
|
||||
: "") ||
|
||||
(typeof record.file_path === "string" && record.file_path.trim().length > 0
|
||||
? record.file_path.trim()
|
||||
: "") ||
|
||||
(typeof record.filePath === "string" && record.filePath.trim().length > 0
|
||||
? record.filePath.trim()
|
||||
: "");
|
||||
return directPath;
|
||||
};
|
||||
|
||||
if (!changes || typeof changes !== "object") {
|
||||
return [];
|
||||
}
|
||||
@@ -823,10 +837,7 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
return null;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const pathValue =
|
||||
typeof record.path === "string" && record.path.trim().length > 0
|
||||
? record.path.trim()
|
||||
: "";
|
||||
const pathValue = resolvePathFromRecord(record);
|
||||
if (!pathValue) {
|
||||
return null;
|
||||
}
|
||||
@@ -843,10 +854,11 @@ function parseCodexPatchChanges(changes: unknown): CodexPatchFileChange[] {
|
||||
}
|
||||
|
||||
const recordChanges = changes as Record<string, unknown>;
|
||||
if (typeof recordChanges.path === "string" && recordChanges.path.trim().length > 0) {
|
||||
const directPathValue = resolvePathFromRecord(recordChanges);
|
||||
if (directPathValue) {
|
||||
return [
|
||||
{
|
||||
path: recordChanges.path.trim(),
|
||||
path: directPathValue,
|
||||
kind:
|
||||
(typeof recordChanges.kind === "string" && recordChanges.kind) ||
|
||||
(typeof recordChanges.type === "string" && recordChanges.type) ||
|
||||
@@ -2188,7 +2200,22 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
||||
|
||||
let sawTurnStarted = false;
|
||||
for await (const event of queue) {
|
||||
// Drop pre-start timeline noise that can leak from the previous turn.
|
||||
// Keep permission events, which can legitimately arrive before turn_started.
|
||||
if (!sawTurnStarted) {
|
||||
if (event.type === "permission_requested" || event.type === "permission_resolved") {
|
||||
yield event;
|
||||
continue;
|
||||
}
|
||||
if (event.type === "turn_started") {
|
||||
sawTurnStarted = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield event;
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
|
||||
@@ -244,6 +244,57 @@ describe("codex tool-call mapper", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange object-style change payloads keyed by path", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-content-object-map",
|
||||
status: "completed",
|
||||
changes: {
|
||||
"/tmp/repo/src/object-map.ts": {
|
||||
type: "modify",
|
||||
unified_diff: "@@\n-old\n+new\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/object-map.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-old");
|
||||
expect(item.detail.unifiedDiff).toContain("+new");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps fileChange array payloads that use file_path aliases", () => {
|
||||
const item = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
type: "fileChange",
|
||||
id: "codex-content-file-path-alias",
|
||||
status: "completed",
|
||||
changes: [
|
||||
{
|
||||
file_path: "/tmp/repo/src/file-path-alias.ts",
|
||||
kind: "modify",
|
||||
patch: "@@\n-before\n+after\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ cwd: "/tmp/repo" }
|
||||
);
|
||||
|
||||
expect(item).toBeTruthy();
|
||||
expect(item?.detail?.type).toBe("edit");
|
||||
if (item?.detail?.type === "edit") {
|
||||
expect(item.detail.filePath).toBe("src/file-path-alias.ts");
|
||||
expect(item.detail.unifiedDiff).toContain("-before");
|
||||
expect(item.detail.unifiedDiff).toContain("+after");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps write/edit/search known variants with distinct detail types", () => {
|
||||
const writeItem = mapCodexToolCallFromThreadItem(
|
||||
{
|
||||
|
||||
@@ -210,19 +210,7 @@ const CodexFileChangeItemSchema = z
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
changes: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
path: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
diff: z.string().optional(),
|
||||
patch: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
changes: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -628,30 +616,122 @@ function mapCommandExecutionItem(
|
||||
};
|
||||
}
|
||||
|
||||
type CodexFileChangeEntry = {
|
||||
path: string;
|
||||
kind?: string;
|
||||
diff?: string;
|
||||
};
|
||||
|
||||
function parseFileChangePath(
|
||||
entry: Record<string, unknown>,
|
||||
options?: CodexMapperOptions,
|
||||
fallbackPath?: string
|
||||
): string | undefined {
|
||||
const rawPath =
|
||||
(typeof entry.path === "string" && entry.path.trim().length > 0
|
||||
? entry.path.trim()
|
||||
: undefined) ??
|
||||
(typeof entry.file_path === "string" && entry.file_path.trim().length > 0
|
||||
? entry.file_path.trim()
|
||||
: undefined) ??
|
||||
(typeof entry.filePath === "string" && entry.filePath.trim().length > 0
|
||||
? entry.filePath.trim()
|
||||
: undefined) ??
|
||||
(typeof fallbackPath === "string" && fallbackPath.trim().length > 0
|
||||
? fallbackPath.trim()
|
||||
: undefined);
|
||||
if (!rawPath) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeCodexFilePath(rawPath, options?.cwd);
|
||||
}
|
||||
|
||||
function parseFileChangeKind(entry: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
(typeof entry.kind === "string" && entry.kind) ||
|
||||
(typeof entry.type === "string" && entry.type) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function parseFileChangeDiff(entry: Record<string, unknown>): string | undefined {
|
||||
return pickFirstPatchLikeString([
|
||||
entry.diff,
|
||||
entry.patch,
|
||||
entry.unified_diff,
|
||||
entry.unifiedDiff,
|
||||
entry.content,
|
||||
entry.newString,
|
||||
]);
|
||||
}
|
||||
|
||||
function toFileChangeEntry(
|
||||
entry: Record<string, unknown>,
|
||||
options?: CodexMapperOptions,
|
||||
fallbackPath?: string
|
||||
): CodexFileChangeEntry | null {
|
||||
const path = parseFileChangePath(entry, options, fallbackPath);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path,
|
||||
kind: parseFileChangeKind(entry),
|
||||
diff: parseFileChangeDiff(entry),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFileChangeEntries(
|
||||
changes: unknown,
|
||||
options?: CodexMapperOptions
|
||||
): CodexFileChangeEntry[] {
|
||||
if (!changes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(changes)) {
|
||||
return changes
|
||||
.map((entry) =>
|
||||
isRecord(entry) ? toFileChangeEntry(entry, options) : null
|
||||
)
|
||||
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
|
||||
}
|
||||
|
||||
if (!isRecord(changes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(changes.files)) {
|
||||
return parseFileChangeEntries(changes.files, options);
|
||||
}
|
||||
|
||||
const singleEntry = toFileChangeEntry(changes, options);
|
||||
if (singleEntry) {
|
||||
return [singleEntry];
|
||||
}
|
||||
|
||||
return Object.entries(changes)
|
||||
.map(([path, value]) => {
|
||||
if (isRecord(value)) {
|
||||
return toFileChangeEntry(value, options, path);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalizedPath = normalizeCodexFilePath(path.trim(), options?.cwd);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
return { path: normalizedPath, diff: value };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is CodexFileChangeEntry => entry !== null);
|
||||
}
|
||||
|
||||
function mapFileChangeItem(
|
||||
item: z.infer<typeof CodexFileChangeItemSchema>,
|
||||
options?: CodexMapperOptions
|
||||
): CodexNormalizedToolCallEnvelope {
|
||||
const changes = item.changes ?? [];
|
||||
|
||||
const files = changes
|
||||
.map((change) => {
|
||||
const pathValue =
|
||||
typeof change.path === "string"
|
||||
? normalizeCodexFilePath(change.path.trim(), options?.cwd)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
path: pathValue,
|
||||
kind: change.kind,
|
||||
diff: pickFirstPatchLikeString([
|
||||
change.diff,
|
||||
change.patch,
|
||||
change.content,
|
||||
]),
|
||||
};
|
||||
})
|
||||
.filter((change) => change.path !== undefined);
|
||||
const files = parseFileChangeEntries(item.changes, options);
|
||||
|
||||
const inputBase = {
|
||||
...(files.length > 0
|
||||
|
||||
@@ -99,6 +99,19 @@ export type PaseoSpeechConfig = {
|
||||
local?: PaseoLocalSpeechConfig;
|
||||
};
|
||||
|
||||
export type DaemonLifecycleIntent =
|
||||
| {
|
||||
type: "shutdown";
|
||||
clientId: string;
|
||||
requestId: string;
|
||||
}
|
||||
| {
|
||||
type: "restart";
|
||||
clientId: string;
|
||||
requestId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type PaseoDaemonConfig = {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
@@ -121,6 +134,11 @@ export type PaseoDaemonConfig = {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
pidLock?: {
|
||||
mode?: "self" | "external";
|
||||
ownerPid?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface PaseoDaemon {
|
||||
@@ -138,9 +156,16 @@ export async function createPaseoDaemon(
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
||||
const pidLockMode = config.pidLock?.mode ?? "self";
|
||||
const pidLockOwnerPid = config.pidLock?.ownerPid;
|
||||
const ownsPidLock = pidLockMode === "self";
|
||||
|
||||
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
|
||||
await acquirePidLock(config.paseoHome, config.listen);
|
||||
if (ownsPidLock) {
|
||||
await acquirePidLock(config.paseoHome, config.listen, {
|
||||
ownerPid: pidLockOwnerPid,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||
@@ -499,7 +524,14 @@ export async function createPaseoDaemon(
|
||||
getSpeechReadiness,
|
||||
},
|
||||
config.agentProviderSettings,
|
||||
daemonVersion
|
||||
daemonVersion,
|
||||
(intent) => {
|
||||
try {
|
||||
config.onLifecycleIntent?.(intent);
|
||||
} catch (error) {
|
||||
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
|
||||
}
|
||||
}
|
||||
);
|
||||
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
|
||||
wsServer?.publishSpeechReadiness(snapshot);
|
||||
@@ -601,7 +633,11 @@ export async function createPaseoDaemon(
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
// Release PID lock
|
||||
await releasePidLock(config.paseoHome);
|
||||
if (ownsPidLock) {
|
||||
await releasePidLock(config.paseoHome, {
|
||||
ownerPid: pidLockOwnerPid,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -613,7 +649,11 @@ export async function createPaseoDaemon(
|
||||
stop,
|
||||
};
|
||||
} catch (err) {
|
||||
await releasePidLock(config.paseoHome).catch(() => undefined);
|
||||
if (ownsPidLock) {
|
||||
await releasePidLock(config.paseoHome, {
|
||||
ownerPid: pidLockOwnerPid,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import type { AgentStreamEventPayload } from "../shared/messages.js";
|
||||
import type { AgentSnapshotPayload } from "./messages.js";
|
||||
|
||||
/**
|
||||
* Tests for client activity tracking and smart notifications.
|
||||
@@ -32,6 +33,9 @@ import type { AgentStreamEventPayload } from "../shared/messages.js";
|
||||
* - appVisible: whether the app/tab is in foreground
|
||||
*/
|
||||
describe("client activity tracking", () => {
|
||||
const TEST_PROVIDER = "claude";
|
||||
const TEST_MODEL = "claude-haiku-4-5";
|
||||
const TEST_CWD = "/tmp";
|
||||
let daemon: TestPaseoDaemon;
|
||||
let client1: DaemonClient;
|
||||
let client2: DaemonClient;
|
||||
@@ -55,6 +59,19 @@ describe("client activity tracking", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
async function createUiAgent(params: {
|
||||
client: DaemonClient;
|
||||
title: string;
|
||||
}): Promise<AgentSnapshotPayload> {
|
||||
return params.client.createAgent({
|
||||
provider: TEST_PROVIDER,
|
||||
model: TEST_MODEL,
|
||||
cwd: TEST_CWD,
|
||||
title: params.title,
|
||||
labels: { ui: "true" },
|
||||
});
|
||||
}
|
||||
|
||||
function waitForAttentionRequired(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
@@ -86,10 +103,8 @@ describe("client activity tracking", () => {
|
||||
test("no notification when actively focused on agent", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Active Focus Test",
|
||||
});
|
||||
|
||||
@@ -114,19 +129,9 @@ describe("client activity tracking", () => {
|
||||
test("notification when focused on different agent", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent1 = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
title: "Agent 1",
|
||||
});
|
||||
const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
|
||||
|
||||
const agent2 = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
title: "Agent 2",
|
||||
});
|
||||
const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
|
||||
|
||||
// User is looking at agent2, not agent1
|
||||
client1.sendHeartbeat({
|
||||
@@ -150,10 +155,8 @@ describe("client activity tracking", () => {
|
||||
test("no notification when app is not visible but activity is recent (user just switched tabs)", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "App Hidden Test",
|
||||
});
|
||||
|
||||
@@ -179,10 +182,8 @@ describe("client activity tracking", () => {
|
||||
test("notification when activity is stale (user walked away for 2+ minutes)", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Stale Activity Test",
|
||||
});
|
||||
|
||||
@@ -209,10 +210,8 @@ describe("client activity tracking", () => {
|
||||
test("notification when no heartbeat received (legacy/new client)", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "No Heartbeat Test",
|
||||
});
|
||||
|
||||
@@ -237,10 +236,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient();
|
||||
client2 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Two Tabs Test",
|
||||
});
|
||||
|
||||
@@ -280,10 +277,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient();
|
||||
client2 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Both Inactive Test",
|
||||
});
|
||||
|
||||
@@ -330,10 +325,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Web Active Test",
|
||||
});
|
||||
|
||||
@@ -373,10 +366,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Mobile Active Test",
|
||||
});
|
||||
|
||||
@@ -416,10 +407,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Web Stale Test",
|
||||
});
|
||||
|
||||
@@ -459,19 +448,9 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent1 = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
title: "Agent 1",
|
||||
});
|
||||
const agent1 = await createUiAgent({ client: client1, title: "Agent 1" });
|
||||
|
||||
const agent2 = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
title: "Agent 2",
|
||||
});
|
||||
const agent2 = await createUiAgent({ client: client1, title: "Agent 2" });
|
||||
|
||||
// Web: active but looking at agent2
|
||||
client1.sendHeartbeat({
|
||||
@@ -510,10 +489,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Both Inactive Test",
|
||||
});
|
||||
|
||||
@@ -562,10 +539,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile - no heartbeat
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Mobile No Heartbeat Test",
|
||||
});
|
||||
|
||||
@@ -600,10 +575,8 @@ describe("client activity tracking", () => {
|
||||
test("no notification when app not visible but activity recent (switched tabs recently)", async () => {
|
||||
client1 = await createClient();
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Tab Switch Test",
|
||||
});
|
||||
|
||||
@@ -630,10 +603,8 @@ describe("client activity tracking", () => {
|
||||
client1 = await createClient(); // web
|
||||
client2 = await createClient(); // mobile
|
||||
|
||||
const agent = await client1.createAgent({
|
||||
provider: "claude",
|
||||
model: "claude-haiku-4-5",
|
||||
cwd: "/tmp",
|
||||
const agent = await createUiAgent({
|
||||
client: client1,
|
||||
title: "Both Recent Activity Test",
|
||||
});
|
||||
|
||||
|
||||
@@ -244,7 +244,9 @@ describe("daemon checkout ship loop", () => {
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
|
||||
const remainingAgents = await ctx.client.fetchAgents();
|
||||
expect(remainingAgents.some((entry) => entry.id === agent.id)).toBe(false);
|
||||
expect(
|
||||
remainingAgents.entries.some((entry) => entry.agent.id === agent.id)
|
||||
).toBe(false);
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await ctx.client.deleteAgent(agentId).catch(() => undefined);
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
import { describe, test, expect } from "vitest";
|
||||
|
||||
describe("daemon E2E", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
describe("multi-agent orchestration", () => {
|
||||
// TODO: Re-implement orchestration tests with new Paseo MCP
|
||||
// The old agent-control MCP has been removed
|
||||
|
||||
@@ -377,7 +377,8 @@ const shouldRun = !process.env.CI;
|
||||
}
|
||||
});
|
||||
|
||||
ctx.client.sendTerminalStreamKey(streamId, { key: "d", ctrl: true });
|
||||
const kill = await ctx.client.killTerminal(terminalId);
|
||||
expect(kill.success).toBe(true);
|
||||
|
||||
await waitForCondition(() => sawExit, 10000);
|
||||
|
||||
@@ -457,6 +458,60 @@ const shouldRun = !process.env.CI;
|
||||
ws.once("error", reject);
|
||||
});
|
||||
|
||||
const helloReady = new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timed out waiting for websocket welcome"));
|
||||
}, 10000);
|
||||
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const onMessage = (raw: WebSocket.RawData) => {
|
||||
if (Array.isArray(raw)) {
|
||||
raw = Buffer.concat(
|
||||
raw.map((part) => (Buffer.isBuffer(part) ? part : Buffer.from(part)))
|
||||
);
|
||||
}
|
||||
if (typeof raw !== "string" && !Buffer.isBuffer(raw)) {
|
||||
return;
|
||||
}
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { type?: string };
|
||||
if (parsed.type === "welcome") {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON payloads (binary mux frames).
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ws.off("message", onMessage);
|
||||
ws.off("error", onError);
|
||||
};
|
||||
|
||||
ws.on("message", onMessage);
|
||||
ws.on("error", onError);
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: `terminal-backpressure-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2)}`,
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
})
|
||||
);
|
||||
await helloReady;
|
||||
|
||||
const attachRequestId = `attach-${Date.now()}`;
|
||||
const detachRequestId = `detach-${Date.now()}`;
|
||||
let streamId: number | null = null;
|
||||
@@ -546,7 +601,7 @@ const shouldRun = !process.env.CI;
|
||||
terminalId,
|
||||
message: {
|
||||
type: "input",
|
||||
data: "head -c 1048576 /dev/zero | tr '\\0' 'A'\r",
|
||||
data: "head -c 8388608 /dev/zero | tr '\\0' 'A'\r",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -4,11 +4,24 @@ import { resolvePaseoHome } from "./paseo-home.js";
|
||||
import { createRootLogger } from "./logger.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { PidLockError } from "./pid-lock.js";
|
||||
import type { DaemonLifecycleIntent } from "./bootstrap.js";
|
||||
|
||||
type SupervisorLifecycleMessage =
|
||||
| {
|
||||
type: "paseo:shutdown";
|
||||
}
|
||||
| {
|
||||
type: "paseo:restart";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
let paseoHome: string;
|
||||
let logger: ReturnType<typeof createRootLogger>;
|
||||
let config: ReturnType<typeof loadConfig>;
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
|
||||
try {
|
||||
paseoHome = resolvePaseoHome();
|
||||
@@ -28,9 +41,107 @@ async function main() {
|
||||
config.mcpEnabled = false;
|
||||
}
|
||||
|
||||
let daemon;
|
||||
const installExitHook = () => {
|
||||
if (exitHookInstalled || !shutdownPromise) {
|
||||
return;
|
||||
}
|
||||
exitHookInstalled = true;
|
||||
void shutdownPromise.then((exitCode) => {
|
||||
process.exit(exitCode);
|
||||
});
|
||||
};
|
||||
|
||||
const beginShutdown = (
|
||||
signal: string,
|
||||
options?: {
|
||||
successExitCode?: number;
|
||||
}
|
||||
) => {
|
||||
if (!shutdownPromise) {
|
||||
logger.info(`${signal} received, shutting down gracefully...`);
|
||||
|
||||
shutdownPromise = (async () => {
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn("Forcing shutdown - HTTP server didn't close in time");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
if (!daemon) {
|
||||
logger.error("Shutdown requested before daemon initialization completed");
|
||||
clearTimeout(forceExit);
|
||||
return 1;
|
||||
}
|
||||
await daemon.stop();
|
||||
clearTimeout(forceExit);
|
||||
logger.info("Server closed");
|
||||
return options?.successExitCode ?? 0;
|
||||
} catch (err) {
|
||||
clearTimeout(forceExit);
|
||||
logger.error({ err }, "Shutdown failed");
|
||||
return 1;
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
logger.info(`${signal} received while shutdown is already in progress`);
|
||||
}
|
||||
|
||||
installExitHook();
|
||||
};
|
||||
|
||||
const sendSupervisorLifecycleMessage = (message: SupervisorLifecycleMessage): boolean => {
|
||||
if (typeof process.send !== "function") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.send(message);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error({ err, message }, "Failed to send lifecycle IPC message to supervisor");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLifecycleIntent = (intent: DaemonLifecycleIntent) => {
|
||||
if (intent.type === "shutdown") {
|
||||
logger.warn(
|
||||
{ clientId: intent.clientId, requestId: intent.requestId },
|
||||
"Shutdown requested via websocket"
|
||||
);
|
||||
if (sendSupervisorLifecycleMessage({ type: "paseo:shutdown" })) {
|
||||
return;
|
||||
}
|
||||
beginShutdown("shutdown lifecycle intent");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ clientId: intent.clientId, requestId: intent.requestId, reason: intent.reason },
|
||||
"Restart requested via websocket"
|
||||
);
|
||||
if (
|
||||
sendSupervisorLifecycleMessage({
|
||||
type: "paseo:restart",
|
||||
...(intent.reason ? { reason: intent.reason } : {}),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
beginShutdown("restart lifecycle intent", { successExitCode: 0 });
|
||||
};
|
||||
|
||||
try {
|
||||
daemon = await createPaseoDaemon(config, logger);
|
||||
const pidLockMode = process.env.PASEO_PID_LOCK_MODE === "external" ? "external" : "self";
|
||||
daemon = await createPaseoDaemon(
|
||||
{
|
||||
...config,
|
||||
onLifecycleIntent: handleLifecycleIntent,
|
||||
pidLock: {
|
||||
mode: pidLockMode,
|
||||
},
|
||||
},
|
||||
logger
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof PidLockError) {
|
||||
logger.error({ pid: err.existingLock?.pid }, err.message);
|
||||
@@ -49,34 +160,8 @@ async function main() {
|
||||
throw err;
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const handleShutdown = async (signal: string) => {
|
||||
if (shuttingDown) {
|
||||
logger.info("Forcing exit...");
|
||||
process.exit(1);
|
||||
}
|
||||
shuttingDown = true;
|
||||
logger.info(`${signal} received, shutting down gracefully... (press Ctrl+C again to force exit)`);
|
||||
|
||||
const forceExit = setTimeout(() => {
|
||||
logger.warn("Forcing shutdown - HTTP server didn't close in time");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
await daemon.stop();
|
||||
clearTimeout(forceExit);
|
||||
logger.info("Server closed");
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
clearTimeout(forceExit);
|
||||
logger.error({ err }, "Shutdown failed");
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => handleShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => handleShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => beginShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => beginShutdown("SIGINT"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { acquirePidLock, getPidLockInfo, releasePidLock } from './pid-lock.js'
|
||||
|
||||
describe('pid-lock ownership', () => {
|
||||
test('writes and releases lock for explicit owner pid', async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-pid-lock-owner-'))
|
||||
const ownerPid = process.pid + 10_000
|
||||
|
||||
try {
|
||||
await (acquirePidLock as unknown as (home: string, sockPath: string, options: { ownerPid: number }) => Promise<void>)(
|
||||
paseoHome,
|
||||
'127.0.0.1:6767',
|
||||
{ ownerPid }
|
||||
)
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome)
|
||||
expect(lock?.pid).toBe(ownerPid)
|
||||
|
||||
await (releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>)(
|
||||
paseoHome,
|
||||
{ ownerPid: ownerPid + 1 }
|
||||
)
|
||||
const lockAfterWrongOwnerRelease = await getPidLockInfo(paseoHome)
|
||||
expect(lockAfterWrongOwnerRelease?.pid).toBe(ownerPid)
|
||||
|
||||
await (releasePidLock as unknown as (home: string, options: { ownerPid: number }) => Promise<void>)(
|
||||
paseoHome,
|
||||
{ ownerPid }
|
||||
)
|
||||
const lockAfterOwnerRelease = await getPidLockInfo(paseoHome)
|
||||
expect(lockAfterOwnerRelease).toBeNull()
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -34,20 +34,17 @@ function getPidFilePath(paseoHome: string): string {
|
||||
return join(paseoHome, "paseo.pid");
|
||||
}
|
||||
|
||||
function resolveLockOwnerPid(): number {
|
||||
if (typeof process.send === "function") {
|
||||
const ppid = process.ppid;
|
||||
if (Number.isInteger(ppid) && ppid > 1) {
|
||||
return ppid;
|
||||
}
|
||||
function resolveOwnerPid(ownerPid?: number): number {
|
||||
if (typeof ownerPid === "number" && Number.isInteger(ownerPid) && ownerPid > 0) {
|
||||
return ownerPid;
|
||||
}
|
||||
|
||||
return process.pid;
|
||||
}
|
||||
|
||||
export async function acquirePidLock(
|
||||
paseoHome: string,
|
||||
sockPath: string
|
||||
sockPath: string,
|
||||
options?: { ownerPid?: number }
|
||||
): Promise<void> {
|
||||
const pidPath = getPidFilePath(paseoHome);
|
||||
|
||||
@@ -66,7 +63,7 @@ export async function acquirePidLock(
|
||||
}
|
||||
|
||||
// Check if existing lock is stale
|
||||
const lockOwnerPid = resolveLockOwnerPid();
|
||||
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
||||
if (existingLock) {
|
||||
if (isPidRunning(existingLock.pid)) {
|
||||
if (existingLock.pid === lockOwnerPid) {
|
||||
@@ -117,9 +114,12 @@ export async function acquirePidLock(
|
||||
}
|
||||
}
|
||||
|
||||
export async function releasePidLock(paseoHome: string): Promise<void> {
|
||||
export async function releasePidLock(
|
||||
paseoHome: string,
|
||||
options?: { ownerPid?: number }
|
||||
): Promise<void> {
|
||||
const pidPath = getPidFilePath(paseoHome);
|
||||
const lockOwnerPid = resolveLockOwnerPid();
|
||||
const lockOwnerPid = resolveOwnerPid(options?.ownerPid);
|
||||
try {
|
||||
// Only remove if it's our lock
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
|
||||
@@ -324,6 +324,7 @@ export function startRelayTransport({
|
||||
attached = true;
|
||||
const externalMetadata: ExternalSocketMetadata = {
|
||||
transport: "relay",
|
||||
externalSessionKey: `session:${connectionId}`,
|
||||
};
|
||||
if (daemonKeyPair) {
|
||||
void attachEncryptedSocket(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
describe('session lifecycle boundary', () => {
|
||||
test('does not perform process lifecycle side effects directly', () => {
|
||||
const source = readFileSync(new URL('./session.ts', import.meta.url), 'utf8')
|
||||
expect(source).not.toMatch(/process\.(exit|send|kill)\s*\(/)
|
||||
})
|
||||
})
|
||||
@@ -146,9 +146,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
}
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>()
|
||||
let restartRequested = false
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
|
||||
const RESTART_EXIT_DELAY_MS = 250
|
||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
|
||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
|
||||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
|
||||
@@ -346,6 +344,7 @@ export type SessionOptions = {
|
||||
clientId: string
|
||||
onMessage: (msg: SessionOutboundMessage) => void
|
||||
onBinaryMessage?: (frame: BinaryMuxFrame) => void
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
|
||||
logger: pino.Logger
|
||||
downloadTokenStore: DownloadTokenStore
|
||||
pushTokenStore: PushTokenStore
|
||||
@@ -379,6 +378,19 @@ export type SessionOptions = {
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
|
||||
}
|
||||
|
||||
export type SessionLifecycleIntent =
|
||||
| {
|
||||
type: 'shutdown'
|
||||
clientId: string
|
||||
requestId: string
|
||||
}
|
||||
| {
|
||||
type: 'restart'
|
||||
clientId: string
|
||||
requestId: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
type VoiceFeatureUnavailableContext = {
|
||||
reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
|
||||
message: string
|
||||
@@ -480,6 +492,7 @@ export class Session {
|
||||
private readonly sessionId: string
|
||||
private readonly onMessage: (msg: SessionOutboundMessage) => void
|
||||
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null
|
||||
private readonly sessionLogger: pino.Logger
|
||||
private readonly paseoHome: string
|
||||
|
||||
@@ -581,6 +594,7 @@ export class Session {
|
||||
clientId,
|
||||
onMessage,
|
||||
onBinaryMessage,
|
||||
onLifecycleIntent,
|
||||
logger,
|
||||
downloadTokenStore,
|
||||
pushTokenStore,
|
||||
@@ -600,6 +614,7 @@ export class Session {
|
||||
this.sessionId = uuidv4()
|
||||
this.onMessage = onMessage
|
||||
this.onBinaryMessage = onBinaryMessage ?? null
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null
|
||||
this.downloadTokenStore = downloadTokenStore
|
||||
this.pushTokenStore = pushTokenStore
|
||||
this.paseoHome = paseoHome
|
||||
@@ -717,10 +732,6 @@ export class Session {
|
||||
}
|
||||
|
||||
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
|
||||
'interruptAgentIfRunning: not running, skipping'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -768,8 +779,6 @@ export class Session {
|
||||
prompt: AgentPromptInput,
|
||||
runOptions?: AgentRunOptions
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
this.sessionLogger.info({ agentId }, `Starting agent stream for ${agentId}`)
|
||||
|
||||
let iterator: AsyncGenerator<AgentStreamEvent>
|
||||
try {
|
||||
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
|
||||
@@ -1360,6 +1369,10 @@ export class Session {
|
||||
await this.handleRestartServerRequest(msg.requestId, msg.reason)
|
||||
break
|
||||
|
||||
case 'shutdown_server_request':
|
||||
await this.handleShutdownServerRequest(msg.requestId)
|
||||
break
|
||||
|
||||
case 'fetch_agent_timeline_request':
|
||||
await this.handleFetchAgentTimelineRequest(msg)
|
||||
break
|
||||
@@ -1634,12 +1647,6 @@ export class Session {
|
||||
}
|
||||
|
||||
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
||||
if (restartRequested) {
|
||||
this.sessionLogger.debug('Restart already requested, ignoring duplicate')
|
||||
return
|
||||
}
|
||||
|
||||
restartRequested = true
|
||||
const payload: { status: string } & Record<string, unknown> = {
|
||||
status: 'restart_requested',
|
||||
clientId: this.clientId,
|
||||
@@ -1655,17 +1662,41 @@ export class Session {
|
||||
payload,
|
||||
})
|
||||
|
||||
if (typeof process.send === 'function') {
|
||||
process.send({
|
||||
type: 'paseo:restart',
|
||||
...(reason ? { reason } : {}),
|
||||
})
|
||||
this.emitLifecycleIntent({
|
||||
type: 'restart',
|
||||
clientId: this.clientId,
|
||||
requestId,
|
||||
...(reason ? { reason } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
private async handleShutdownServerRequest(requestId: string): Promise<void> {
|
||||
this.sessionLogger.warn('Shutdown requested via websocket')
|
||||
this.emit({
|
||||
type: 'status',
|
||||
payload: {
|
||||
status: 'shutdown_requested',
|
||||
clientId: this.clientId,
|
||||
requestId,
|
||||
},
|
||||
})
|
||||
|
||||
this.emitLifecycleIntent({
|
||||
type: 'shutdown',
|
||||
clientId: this.clientId,
|
||||
requestId,
|
||||
})
|
||||
}
|
||||
|
||||
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
|
||||
if (!this.onLifecycleIntent) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
process.exit(0)
|
||||
}, RESTART_EXIT_DELAY_MS)
|
||||
try {
|
||||
this.onLifecycleIntent(intent)
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error, intent }, 'Lifecycle intent handler failed')
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
@@ -2426,43 +2457,6 @@ export class Session {
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
|
||||
await this.forwardAgentUpdate(snapshot)
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim()
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
})
|
||||
|
||||
try {
|
||||
await this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
outputSchema ? { outputSchema } : undefined
|
||||
)
|
||||
} catch (promptError) {
|
||||
this.sessionLogger.error(
|
||||
{ err: promptError, agentId: snapshot.id },
|
||||
`Failed to run initial prompt for agent ${snapshot.id}`
|
||||
)
|
||||
this.emit({
|
||||
type: 'activity_log',
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
const agentPayload = await this.getAgentPayloadById(snapshot.id)
|
||||
if (!agentPayload) {
|
||||
@@ -2479,6 +2473,41 @@ export class Session {
|
||||
})
|
||||
}
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim()
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
})
|
||||
|
||||
void this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
trimmedPrompt,
|
||||
resolveClientMessageId(clientMessageId),
|
||||
images,
|
||||
outputSchema ? { outputSchema } : undefined
|
||||
).catch((promptError) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: promptError, agentId: snapshot.id },
|
||||
`Failed to run initial prompt for agent ${snapshot.id}`
|
||||
)
|
||||
this.emit({
|
||||
type: 'activity_log',
|
||||
payload: {
|
||||
id: uuidv4(),
|
||||
timestamp: new Date(),
|
||||
type: 'error',
|
||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (worktreeConfig) {
|
||||
void runAsyncWorktreeBootstrap({
|
||||
agentId: snapshot.id,
|
||||
@@ -3336,10 +3365,6 @@ export class Session {
|
||||
*/
|
||||
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
|
||||
const agentIds = Array.isArray(agentId) ? agentId : [agentId]
|
||||
this.sessionLogger.debug(
|
||||
{ agentIds },
|
||||
`Clearing attention for ${agentIds.length} agent(s): ${agentIds.join(', ')}`
|
||||
)
|
||||
|
||||
try {
|
||||
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)))
|
||||
@@ -4580,11 +4605,6 @@ export class Session {
|
||||
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
|
||||
const { agentId, path: requestedPath = '.', mode, requestId } = request
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{ agentId, mode, path: requestedPath },
|
||||
`Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
|
||||
)
|
||||
|
||||
try {
|
||||
const agents = this.agentManager.listAgents()
|
||||
const agent = agents.find((a) => a.id === agentId)
|
||||
@@ -6211,7 +6231,6 @@ export class Session {
|
||||
if (this.agentMcpClient) {
|
||||
try {
|
||||
await this.agentMcpClient.close()
|
||||
this.sessionLogger.debug('Agent MCP client closed')
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error }, 'Failed to close Agent MCP client')
|
||||
}
|
||||
@@ -6639,7 +6658,10 @@ export class Session {
|
||||
|
||||
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId)
|
||||
if (typeof existingStreamId === 'number') {
|
||||
this.detachTerminalStream(existingStreamId, { emitExit: false })
|
||||
// Replacing an active stream can happen when multiple UI surfaces attach to the
|
||||
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
|
||||
// instead of continuing to send input to an invalid stream id.
|
||||
this.detachTerminalStream(existingStreamId, { emitExit: true })
|
||||
}
|
||||
|
||||
const streamId = this.allocateTerminalStreamId()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { TerminalManager } from "./terminal-manager.js";
|
||||
import { findSessionByName, killSession } from "./tmux.js";
|
||||
|
||||
const TEST_SESSION = "test-terminal-manager";
|
||||
const TEST_SESSION = `test-terminal-manager-${process.pid}-${Date.now().toString(36)}`;
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
|
||||
@@ -11,6 +11,39 @@ function isIgnorableCleanupError(error: unknown): boolean {
|
||||
return code === "ENOTEMPTY" || code === "EBUSY" || code === "EPERM";
|
||||
}
|
||||
|
||||
const NO_BASE_CONFIG_DIR = Symbol("no-base-config-dir");
|
||||
let baseConfigDir: string | typeof NO_BASE_CONFIG_DIR = NO_BASE_CONFIG_DIR;
|
||||
const activeConfigDirs: string[] = [];
|
||||
|
||||
function activateClaudeConfigDir(configDir: string): void {
|
||||
if (activeConfigDirs.length === 0) {
|
||||
baseConfigDir =
|
||||
typeof process.env.CLAUDE_CONFIG_DIR === "string"
|
||||
? process.env.CLAUDE_CONFIG_DIR
|
||||
: NO_BASE_CONFIG_DIR;
|
||||
}
|
||||
activeConfigDirs.push(configDir);
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
}
|
||||
|
||||
function deactivateClaudeConfigDir(configDir: string): void {
|
||||
const index = activeConfigDirs.lastIndexOf(configDir);
|
||||
if (index !== -1) {
|
||||
activeConfigDirs.splice(index, 1);
|
||||
}
|
||||
const latestActiveDir = activeConfigDirs[activeConfigDirs.length - 1];
|
||||
if (latestActiveDir) {
|
||||
process.env.CLAUDE_CONFIG_DIR = latestActiveDir;
|
||||
return;
|
||||
}
|
||||
if (baseConfigDir === NO_BASE_CONFIG_DIR) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = baseConfigDir;
|
||||
}
|
||||
baseConfigDir = NO_BASE_CONFIG_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an isolated Claude config directory for testing.
|
||||
* Creates a temp directory with:
|
||||
@@ -22,7 +55,6 @@ function isIgnorableCleanupError(error: unknown): boolean {
|
||||
* Returns a cleanup function that restores the original env and removes the temp dir.
|
||||
*/
|
||||
export function useTempClaudeConfigDir(): () => void {
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
const configDir = mkdtempSync(path.join(tmpdir(), "claude-config-"));
|
||||
const settings = {
|
||||
permissions: {
|
||||
@@ -40,13 +72,9 @@ export function useTempClaudeConfigDir(): () => void {
|
||||
writeFileSync(path.join(configDir, "settings.json"), settingsText, "utf8");
|
||||
writeFileSync(path.join(configDir, "settings.local.json"), settingsText, "utf8");
|
||||
seedClaudeAuth(configDir);
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
activateClaudeConfigDir(configDir);
|
||||
return () => {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
||||
}
|
||||
deactivateClaudeConfigDir(configDir);
|
||||
try {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -51,10 +51,38 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
const TEST_DAEMON_START_TIMEOUT_MS = 20_000;
|
||||
|
||||
async function startDaemonWithTimeout(
|
||||
daemon: Awaited<ReturnType<typeof createPaseoDaemon>>,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
const timeoutError = new Error(
|
||||
`Timed out starting test daemon after ${timeoutMs}ms`
|
||||
) as Error & { code?: string };
|
||||
timeoutError.code = "TEST_DAEMON_START_TIMEOUT";
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
|
||||
daemon.start().then(
|
||||
() => {
|
||||
clearTimeout(timeoutHandle);
|
||||
resolve();
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTestPaseoDaemon(
|
||||
options: TestPaseoDaemonOptions = {}
|
||||
): Promise<TestPaseoDaemon> {
|
||||
const maxAttempts = 5;
|
||||
const maxAttempts = 8;
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
@@ -91,7 +119,7 @@ export async function createTestPaseoDaemon(
|
||||
const logger = options.logger ?? pino({ level: "silent" });
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
try {
|
||||
await daemon.start();
|
||||
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
@@ -117,7 +145,10 @@ export async function createTestPaseoDaemon(
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
|
||||
if (!isAddressInUseError(error) || attempt === maxAttempts - 1) {
|
||||
if (
|
||||
(!isAddressInUseError(error) && !isStartupTimeoutError(error)) ||
|
||||
attempt === maxAttempts - 1
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -133,3 +164,11 @@ function isAddressInUseError(error: unknown): boolean {
|
||||
const record = error as { code?: string };
|
||||
return record.code === "EADDRINUSE";
|
||||
}
|
||||
|
||||
function isStartupTimeoutError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = error as { code?: string };
|
||||
return record.code === "TEST_DAEMON_START_TIMEOUT";
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "../shared/binary-mux.js";
|
||||
import type { AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import { isHostAllowed } from "./allowed-hosts.js";
|
||||
import { Session } from "./session.js";
|
||||
import { Session, type SessionLifecycleIntent } from "./session.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
export type ExternalSocketMetadata = {
|
||||
transport: "relay";
|
||||
externalSessionKey?: string;
|
||||
};
|
||||
|
||||
type PendingConnection = {
|
||||
@@ -216,6 +217,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
>();
|
||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private serverCapabilities: ServerCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
@@ -248,7 +250,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||||
},
|
||||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
|
||||
daemonVersion?: string
|
||||
daemonVersion?: string,
|
||||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
|
||||
) {
|
||||
this.logger = logger.child({ module: "websocket-server" });
|
||||
this.serverId = serverId;
|
||||
@@ -267,6 +270,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.voice = voice ?? null;
|
||||
this.dictation = dictation ?? null;
|
||||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||||
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
||||
this.serverCapabilities = buildServerCapabilities({
|
||||
readiness: this.dictation?.getSpeechReadiness?.() ?? null,
|
||||
});
|
||||
@@ -522,6 +526,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
}
|
||||
this.sendBinaryToConnection(connection, frame);
|
||||
},
|
||||
onLifecycleIntent: (intent) => {
|
||||
this.onLifecycleIntent?.(intent);
|
||||
},
|
||||
logger: connectionLogger.child({ module: "session" }),
|
||||
downloadTokenStore: this.downloadTokenStore,
|
||||
pushTokenStore: this.pushTokenStore,
|
||||
|
||||
@@ -45,6 +45,24 @@ describe('shared messages stream parsing', () => {
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
|
||||
})
|
||||
|
||||
it('parses explicit shutdown and restart lifecycle request payloads as distinct message types', () => {
|
||||
const shutdownParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: 'shutdown_server_request',
|
||||
requestId: 'req-shutdown-1',
|
||||
})
|
||||
expect(shutdownParsed.success).toBe(true)
|
||||
|
||||
const restartParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: 'restart_server_request',
|
||||
requestId: 'req-restart-1',
|
||||
reason: 'settings_changed',
|
||||
})
|
||||
expect(restartParsed.success).toBe(true)
|
||||
|
||||
expect(shutdownParsed.success && shutdownParsed.data.type).toBe('shutdown_server_request')
|
||||
expect(restartParsed.success && restartParsed.data.type).toBe('restart_server_request')
|
||||
})
|
||||
|
||||
it('parses representative agent_stream tool_call event', () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: 'agent_stream',
|
||||
|
||||
@@ -648,6 +648,11 @@ export const RestartServerRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const ShutdownServerRequestMessageSchema = z.object({
|
||||
type: z.literal('shutdown_server_request'),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const AgentTimelineCursorSchema = z.object({
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
@@ -1080,6 +1085,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
CancelAgentRequestMessageSchema,
|
||||
ShutdownServerRequestMessageSchema,
|
||||
RestartServerRequestMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
SetAgentModeRequestMessageSchema,
|
||||
@@ -1351,11 +1357,18 @@ export const RestartRequestedStatusPayloadSchema = z.object({
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const ShutdownRequestedStatusPayloadSchema = z.object({
|
||||
status: z.literal('shutdown_requested'),
|
||||
clientId: z.string(),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const KnownStatusPayloadSchema = z.discriminatedUnion('status', [
|
||||
AgentCreatedStatusPayloadSchema,
|
||||
AgentCreateFailedStatusPayloadSchema,
|
||||
AgentResumedStatusPayloadSchema,
|
||||
AgentRefreshedStatusPayloadSchema,
|
||||
ShutdownRequestedStatusPayloadSchema,
|
||||
RestartRequestedStatusPayloadSchema,
|
||||
])
|
||||
|
||||
@@ -2187,6 +2200,7 @@ export type ProjectIcon = z.infer<typeof ProjectIconSchema>
|
||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>
|
||||
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>
|
||||
export type ShutdownServerRequestMessage = z.infer<typeof ShutdownServerRequestMessageSchema>
|
||||
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>
|
||||
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>
|
||||
export type ListCommandsRequest = z.infer<typeof ListCommandsRequestSchema>
|
||||
|
||||
Reference in New Issue
Block a user