test: harden daemon lifecycle and e2e reliability across app/cli/server

This commit is contained in:
Mohamed Boudra
2026-02-25 10:21:46 +07:00
parent 094ce49d4f
commit a060ae1256
98 changed files with 5716 additions and 1771 deletions
@@ -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 {
+31 -1
View File
@@ -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
+44 -4
View File
@@ -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",
},
},
})
+115 -30
View File
@@ -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 })
}
})
})
+11 -11
View File
@@ -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*\(/)
})
})
+93 -71
View File
@@ -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',
+14
View File
@@ -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>