From 6f131ad5518a62b539070e1933a6d8669f4658cb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Apr 2026 23:58:58 +0700 Subject: [PATCH] chore(lint): extract nested callbacks in server (max-nested-callbacks) --- .../server/src/client/daemon-client.test.ts | 4942 +++++----- .../src/server/agent/agent-manager.test.ts | 8704 ++++++++--------- .../src/server/agent/mcp-parity.e2e.test.ts | 1170 ++- .../src/server/agent/mcp-shared.test.ts | 128 +- .../server/agent/provider-registry.test.ts | 962 +- ...ude-agent.event-stream.integration.test.ts | 599 +- ...agent.interrupt-restart-regression.test.ts | 742 +- .../providers/claude-agent.redesign.test.ts | 2063 ++-- .../providers/codex-rollout-parsing.test.ts | 1060 +- ...opencode-agent.list-models-timeout.test.ts | 140 +- .../src/server/daemon-client.e2e.test.ts | 1983 ++-- .../daemon-e2e/agent-operations.e2e.test.ts | 750 +- .../checkout-diff-subscription.e2e.test.ts | 154 +- .../server/daemon-e2e/filesystem.e2e.test.ts | 242 +- .../daemon-e2e/git-operations.e2e.test.ts | 1178 ++- .../src/server/daemon-e2e/images.e2e.test.ts | 264 +- .../daemon-e2e/live-preferences.e2e.test.ts | 321 +- .../daemon-e2e/permissions-codex.e2e.test.ts | 694 +- .../src/server/daemon-e2e/pi.real.e2e.test.ts | 827 +- .../server/daemon-e2e/terminal.e2e.test.ts | 1507 ++- .../timeline-reconnect-contract.e2e.test.ts | 322 +- .../server/daemon-e2e/tool-calls.e2e.test.ts | 478 +- .../daemon-e2e/wait-for-idle.e2e.test.ts | 380 +- .../src/server/paseo-worktree-service.test.ts | 218 +- .../server/src/server/script-proxy.test.ts | 758 +- .../src/server/session.workspaces.test.ts | 5175 +++++----- .../local/sherpa/speech-download.e2e.test.ts | 388 +- .../src/server/voice-local-agent.e2e.test.ts | 67 +- .../src/server/voice-roundtrip.e2e.test.ts | 501 +- .../workspace-git-service.primitive.test.ts | 10 +- packages/server/src/tasks/task-store.test.ts | 1934 ++-- .../src/terminal/terminal-manager.test.ts | 666 +- packages/server/src/terminal/terminal.test.ts | 2057 ++-- 33 files changed, 20628 insertions(+), 20756 deletions(-) diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 8ffa04757b..abcb358a7b 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -88,182 +88,154 @@ function wrapSessionMessage(message: unknown): string { }); } -describe("DaemonClient", () => { - const clients: DaemonClient[] = []; - - afterEach(async () => { - for (const client of clients) { - await client.close(); - } - clients.length = 0; - }); - - test("dedupes in-flight checkout status requests per agentId", 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 p1 = client.getCheckoutStatus("/tmp/project"); - const p2 = client.getCheckoutStatus("/tmp/project"); - - expect(mock.sent).toHaveLength(1); - - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { type: "checkout_status_request"; cwd: string; requestId: string }; - }; - - const response = { - type: "session", - message: { - type: "checkout_status_response", - payload: { - cwd: "/tmp/project", - error: null, - requestId: request.message.requestId, - isGit: false, - isPaseoOwnedWorktree: false, - repoRoot: null, - currentBranch: null, - isDirty: null, - baseRef: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - hasRemote: false, - remoteUrl: null, - }, - }, - }; - - mock.triggerMessage(JSON.stringify(response)); - const [r1, r2] = await Promise.all([p1, p2]); - expect(r1).toMatchObject({ - cwd: "/tmp/project", - requestId: request.message.requestId, - isGit: false, - }); - expect(r2).toMatchObject({ - cwd: "/tmp/project", - requestId: request.message.requestId, - isGit: false, - }); - - // After completion, a new call should issue a new request. - const p3 = client.getCheckoutStatus("/tmp/project"); - expect(mock.sent).toHaveLength(2); - - const request2 = JSON.parse(mock.sent[1]) as { - type: "session"; - message: { type: "checkout_status_request"; cwd: string; requestId: string }; - }; - - mock.triggerMessage( - JSON.stringify({ - ...response, - message: { - ...response.message, - payload: { ...response.message.payload, requestId: request2.message.requestId }, - }, - }), - ); - - await expect(p3).resolves.toMatchObject({ - cwd: "/tmp/project", - requestId: request2.message.requestId, - isGit: false, - }); - }); - - test("does not reconnect after close when ensureConnected is called", 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; - expect(client.getConnectionState().status).toBe("connected"); +const clients: DaemonClient[] = []; +afterEach(async () => { + for (const client of clients) { await client.close(); - expect(client.getConnectionState().status).toBe("disposed"); + } + clients.length = 0; +}); - client.ensureConnected(); - expect(client.getConnectionState().status).toBe("disposed"); +test("dedupes in-flight checkout status requests per agentId", 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 p1 = client.getCheckoutStatus("/tmp/project"); + const p2 = client.getCheckoutStatus("/tmp/project"); + + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { type: "checkout_status_request"; cwd: string; requestId: string }; + }; + + const response = { + type: "session", + message: { + type: "checkout_status_response", + payload: { + cwd: "/tmp/project", + error: null, + requestId: request.message.requestId, + isGit: false, + isPaseoOwnedWorktree: false, + repoRoot: null, + currentBranch: null, + isDirty: null, + baseRef: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + }, + }, + }; + + mock.triggerMessage(JSON.stringify(response)); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toMatchObject({ + cwd: "/tmp/project", + requestId: request.message.requestId, + isGit: false, + }); + expect(r2).toMatchObject({ + cwd: "/tmp/project", + requestId: request.message.requestId, + isGit: false, }); - test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + // After completion, a new call should issue a new request. + const p3 = client.getCheckoutStatus("/tmp/project"); + expect(mock.sent).toHaveLength(2); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + const request2 = JSON.parse(mock.sent[1]) as { + type: "session"; + message: { type: "checkout_status_request"; cwd: string; requestId: string }; + }; - const events: Array[0]>[0]> = []; - client.subscribe((event) => { - events.push(event); - }); + mock.triggerMessage( + JSON.stringify({ + ...response, + message: { + ...response.message, + payload: { ...response.message.payload, requestId: request2.message.requestId }, + }, + }), + ); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; + await expect(p3).resolves.toMatchObject({ + cwd: "/tmp/project", + requestId: request2.message.requestId, + isGit: false, + }); +}); - mock.triggerMessage( - wrapSessionMessage({ - type: "workspace_setup_progress", - payload: { - workspaceId: "ws-feature-a", - status: "running", - detail: { - type: "worktree_setup", - worktreePath: "/tmp/project/.paseo/worktrees/feature-a", - branchName: "feature-a", - log: "phase-one\n", - commands: [ - { - index: 1, - command: "npm install", - cwd: "/tmp/project/.paseo/worktrees/feature-a", - log: "phase-one\n", - status: "running", - exitCode: null, - }, - ], - }, - error: null, - }, - }), - ); +test("does not reconnect after close when ensureConnected is called", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); - expect(events).toContainEqual({ + 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; + expect(client.getConnectionState().status).toBe("connected"); + + await client.close(); + expect(client.getConnectionState().status).toBe("disposed"); + + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("disposed"); +}); + +test("normalizes workspace_setup_progress into a workspace-scoped daemon event", 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 events: Array[0]>[0]> = []; + client.subscribe((event) => { + events.push(event); + }); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + mock.triggerMessage( + wrapSessionMessage({ type: "workspace_setup_progress", - workspaceId: "ws-feature-a", payload: { workspaceId: "ws-feature-a", status: "running", @@ -285,108 +257,113 @@ describe("DaemonClient", () => { }, error: null, }, - }); - }); + }), + ); - test("sends create_agent_request with string workspace ids", 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 createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project/.paseo/worktrees/feature-a", + expect(events).toContainEqual({ + type: "workspace_setup_progress", + workspaceId: "ws-feature-a", + payload: { workspaceId: "ws-feature-a", - title: "Compat agent", - modeId: "default", - }); + status: "running", + detail: { + type: "worktree_setup", + worktreePath: "/tmp/project/.paseo/worktrees/feature-a", + branchName: "feature-a", + log: "phase-one\n", + commands: [ + { + index: 1, + command: "npm install", + cwd: "/tmp/project/.paseo/worktrees/feature-a", + log: "phase-one\n", + status: "running", + exitCode: null, + }, + ], + }, + error: null, + }, + }); +}); - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "create_agent_request"; - requestId: string; - workspaceId: string; - }; - }; - expect(request.message).toEqual( - expect.objectContaining({ - type: "create_agent_request", - workspaceId: "ws-feature-a", - }), - ); +test("sends create_agent_request with string workspace ids", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.message.requestId, - error: "compat test sentinel", - }, - }), - ); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); - await expect(createPromise).rejects.toThrow("compat test sentinel"); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project/.paseo/worktrees/feature-a", + workspaceId: "ws-feature-a", + title: "Compat agent", + modeId: "default", }); - test("sends structured attachments with create_agent_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 createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - initialPrompt: "Review this PR", - attachments: [ - { - type: "github_pr", - mimeType: "application/github-pr", - number: 123, - title: "Fix race in worktree setup", - url: "https://github.com/getpaseo/paseo/pull/123", - baseRefName: "main", - headRefName: "fix/worktree-race", - }, - ], - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "create_agent_request"; - requestId: string; - attachments: Array<{ type: string; mimeType: string; number: number }>; - }; + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "create_agent_request"; + requestId: string; + workspaceId: string; }; - expect(request.message.attachments).toEqual([ + }; + expect(request.message).toEqual( + expect.objectContaining({ + type: "create_agent_request", + workspaceId: "ws-feature-a", + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.message.requestId, + error: "compat test sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("compat test sentinel"); +}); + +test("sends structured attachments with create_agent_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 createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + initialPrompt: "Review this PR", + attachments: [ { type: "github_pr", mimeType: "application/github-pr", @@ -396,190 +373,192 @@ describe("DaemonClient", () => { baseRefName: "main", headRefName: "fix/worktree-race", }, - ]); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.message.requestId, - error: "attachment test sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("attachment test sentinel"); + ], }); - test("sends worktree base-ref fields in create_agent_request git options", 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 createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-ref", - git: { - createWorktree: true, - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }, - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "create_agent_request"; - requestId: string; - git: { - createWorktree: boolean; - worktreeSlug: string; - refName: string; - action: string; - githubPrNumber: number; - }; - }; + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "create_agent_request"; + requestId: string; + attachments: Array<{ type: string; mimeType: string; number: number }>; }; - expect(request.message.git).toEqual({ + }; + expect(request.message.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Fix race in worktree setup", + url: "https://github.com/getpaseo/paseo/pull/123", + baseRefName: "main", + headRefName: "fix/worktree-race", + }, + ]); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.message.requestId, + error: "attachment test sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("attachment test sentinel"); +}); + +test("sends worktree base-ref fields in create_agent_request git options", 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 createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + requestId: "req-agent-ref", + git: { createWorktree: true, worktreeSlug: "review-pr-123", refName: "feature/worktree-base-ref", action: "checkout", githubPrNumber: 123, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: request.message.requestId, - error: "git ref fields sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("git ref fields sentinel"); + }, }); - test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", 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 createPromise = client.createAgent({ - provider: "codex", - cwd: "/tmp/project", - requestId: "req-agent-legacy", + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "create_agent_request"; + requestId: string; git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - }); - - expect(String(mock.sent[0])).toBe( - JSON.stringify({ - type: "session", - message: { - type: "create_agent_request", - config: { - provider: "codex", - cwd: "/tmp/project", - }, - git: { - createWorktree: true, - worktreeSlug: "feature-a", - }, - labels: {}, - requestId: "req-agent-legacy", - }, - }), - ); - - mock.triggerMessage( - wrapSessionMessage({ - type: "status", - payload: { - status: "agent_create_failed", - requestId: "req-agent-legacy", - error: "legacy git shape sentinel", - }, - }), - ); - - await expect(createPromise).rejects.toThrow("legacy git shape sentinel"); - }); - - test("sends structured attachments with create_paseo_worktree_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 createPromise = client.createPaseoWorktree({ - cwd: "/tmp/project", - worktreeSlug: "review-pr-123", - attachments: [ - { - type: "github_pr", - mimeType: "application/github-pr", - number: 123, - title: "Fix race in worktree setup", - url: "https://github.com/getpaseo/paseo/pull/123", - }, - ], - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "create_paseo_worktree_request"; - requestId: string; - attachments: Array<{ type: string; mimeType: string; number: number }>; + createWorktree: boolean; + worktreeSlug: string; + refName: string; + action: string; + githubPrNumber: number; }; }; - expect(request.message.attachments).toEqual([ + }; + expect(request.message.git).toEqual({ + createWorktree: true, + worktreeSlug: "review-pr-123", + refName: "feature/worktree-base-ref", + action: "checkout", + githubPrNumber: 123, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.message.requestId, + error: "git ref fields sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("git ref fields sentinel"); +}); + +test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", 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 createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + requestId: "req-agent-legacy", + git: { + createWorktree: true, + worktreeSlug: "feature-a", + }, + }); + + expect(String(mock.sent[0])).toBe( + JSON.stringify({ + type: "session", + message: { + type: "create_agent_request", + config: { + provider: "codex", + cwd: "/tmp/project", + }, + git: { + createWorktree: true, + worktreeSlug: "feature-a", + }, + labels: {}, + requestId: "req-agent-legacy", + }, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: "req-agent-legacy", + error: "legacy git shape sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("legacy git shape sentinel"); +}); + +test("sends structured attachments with create_paseo_worktree_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 createPromise = client.createPaseoWorktree({ + cwd: "/tmp/project", + worktreeSlug: "review-pr-123", + attachments: [ { type: "github_pr", mimeType: "application/github-pr", @@ -587,1707 +566,1867 @@ describe("DaemonClient", () => { title: "Fix race in worktree setup", url: "https://github.com/getpaseo/paseo/pull/123", }, - ]); - - mock.triggerMessage( - wrapSessionMessage({ - type: "create_paseo_worktree_response", - payload: { - requestId: request.message.requestId, - workspace: null, - error: "worktree attachment sentinel", - setupTerminalId: null, - }, - }), - ); - - await expect(createPromise).resolves.toEqual({ - requestId: request.message.requestId, - workspace: null, - error: "worktree attachment sentinel", - setupTerminalId: null, - }); + ], }); - test("sends worktree base-ref fields in create_paseo_worktree_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 createPromise = client.createPaseoWorktree( - { - cwd: "/tmp/project", - worktreeSlug: "review-pr-123", - refName: "feature/worktree-base-ref", - action: "checkout", - githubPrNumber: 123, - }, - "req-worktree-ref", - ); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "create_paseo_worktree_request"; - requestId: string; - cwd: string; - worktreeSlug: string; - refName: string; - action: string; - githubPrNumber: number; - }; + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "create_paseo_worktree_request"; + requestId: string; + attachments: Array<{ type: string; mimeType: string; number: number }>; }; - expect(request.message).toEqual({ - type: "create_paseo_worktree_request", + }; + expect(request.message.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Fix race in worktree setup", + url: "https://github.com/getpaseo/paseo/pull/123", + }, + ]); + + mock.triggerMessage( + wrapSessionMessage({ + type: "create_paseo_worktree_response", + payload: { + requestId: request.message.requestId, + workspace: null, + error: "worktree attachment sentinel", + setupTerminalId: null, + }, + }), + ); + + await expect(createPromise).resolves.toEqual({ + requestId: request.message.requestId, + workspace: null, + error: "worktree attachment sentinel", + setupTerminalId: null, + }); +}); + +test("sends worktree base-ref fields in create_paseo_worktree_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 createPromise = client.createPaseoWorktree( + { cwd: "/tmp/project", worktreeSlug: "review-pr-123", refName: "feature/worktree-base-ref", action: "checkout", githubPrNumber: 123, - requestId: "req-worktree-ref", - }); + }, + "req-worktree-ref", + ); - mock.triggerMessage( - wrapSessionMessage({ - type: "create_paseo_worktree_response", - payload: { - requestId: request.message.requestId, - workspace: null, - error: "worktree ref fields sentinel", - setupTerminalId: null, - }, - }), - ); - - await expect(createPromise).resolves.toEqual({ - requestId: request.message.requestId, - workspace: null, - error: "worktree ref fields sentinel", - setupTerminalId: null, - }); + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "create_paseo_worktree_request"; + requestId: string; + cwd: string; + worktreeSlug: string; + refName: string; + action: string; + githubPrNumber: number; + }; + }; + expect(request.message).toEqual({ + type: "create_paseo_worktree_request", + cwd: "/tmp/project", + worktreeSlug: "review-pr-123", + refName: "feature/worktree-base-ref", + action: "checkout", + githubPrNumber: 123, + requestId: "req-worktree-ref", }); - test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + mock.triggerMessage( + wrapSessionMessage({ + type: "create_paseo_worktree_response", + payload: { + requestId: request.message.requestId, + workspace: null, + error: "worktree ref fields sentinel", + setupTerminalId: null, + }, + }), + ); - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); + await expect(createPromise).resolves.toEqual({ + requestId: request.message.requestId, + workspace: null, + error: "worktree ref fields sentinel", + setupTerminalId: null, + }); +}); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; +test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); - const createPromise = client.createPaseoWorktree( - { + 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 createPromise = client.createPaseoWorktree( + { + cwd: "/tmp/project", + worktreeSlug: "feature-a", + }, + "req-worktree-legacy", + ); + + expect(String(mock.sent[0])).toBe( + JSON.stringify({ + type: "session", + message: { + type: "create_paseo_worktree_request", cwd: "/tmp/project", worktreeSlug: "feature-a", + requestId: "req-worktree-legacy", }, - "req-worktree-legacy", - ); + }), + ); - expect(String(mock.sent[0])).toBe( - JSON.stringify({ - type: "session", - message: { - type: "create_paseo_worktree_request", - cwd: "/tmp/project", - worktreeSlug: "feature-a", - requestId: "req-worktree-legacy", - }, - }), - ); + mock.triggerMessage( + wrapSessionMessage({ + type: "create_paseo_worktree_response", + payload: { + requestId: "req-worktree-legacy", + workspace: null, + error: "legacy worktree shape sentinel", + setupTerminalId: null, + }, + }), + ); - mock.triggerMessage( - wrapSessionMessage({ - type: "create_paseo_worktree_response", - payload: { - requestId: "req-worktree-legacy", - workspace: null, - error: "legacy worktree shape sentinel", - setupTerminalId: null, - }, - }), - ); + await expect(createPromise).resolves.toEqual({ + requestId: "req-worktree-legacy", + workspace: null, + error: "legacy worktree shape sentinel", + setupTerminalId: null, + }); +}); - await expect(createPromise).resolves.toEqual({ - requestId: "req-worktree-legacy", - workspace: null, - error: "legacy worktree shape sentinel", - setupTerminalId: null, - }); +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", }); - 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 { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "shutdown_requested", clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - connectTimeoutMs: 100, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const pendingConnect = client.connect().then( - () => ({ ok: true as const }), - (error) => ({ ok: false as const, error }), - ); - expect(client.getConnectionState().status).toBe("connecting"); - - await vi.advanceTimersByTimeAsync(120); - const result = await pendingConnect; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toBeInstanceOf(Error); - expect((result.error as Error).message).toContain("Connection timed out"); - } - expect(client.getConnectionState().status).toBe("disconnected"); - } finally { - vi.useRealTimers(); - } - }); - - test("reconnects after relay close with replaced-by-new-connection reason", async () => { - vi.useFakeTimers(); - try { - const logger = createMockLogger(); - const first = createMockTransport(); - const second = createMockTransport(); - const transports = [first, second]; - let transportIndex = 0; - - const client = new DaemonClient({ - url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", - clientId: "clsk_test", - logger, - reconnect: { - enabled: true, - baseDelayMs: 5, - maxDelayMs: 5, - }, - transportFactory: () => { - const next = transports[Math.min(transportIndex, transports.length - 1)]; - transportIndex += 1; - return next.transport; - }, - }); - clients.push(client); - - const connectPromise = client.connect(); - first.triggerOpen(); - await connectPromise; - expect(client.getConnectionState().status).toBe("connected"); - - first.triggerClose({ code: 1008, reason: "Replaced by new connection" }); - expect(client.getConnectionState().status).toBe("disconnected"); - - await vi.advanceTimersByTimeAsync(10); - expect(client.getConnectionState().status).toBe("connecting"); - - second.triggerOpen(); - expect(client.getConnectionState().status).toBe("connected"); - } finally { - vi.useRealTimers(); - } - }); - - test("requires non-empty clientId", () => { - expect(() => { - new DaemonClient({ - url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", - clientId: "", - reconnect: { enabled: false }, - }); - }).toThrow("Daemon client requires a non-empty clientId"); - }); - - test("requires non-empty clientId for direct connections", () => { - expect(() => { - new DaemonClient({ - url: "ws://127.0.0.1:6767/ws", - clientId: " ", - reconnect: { enabled: false }, - }); - }).toThrow("Daemon client requires a non-empty clientId"); - }); - - test("logs configured runtime generation in connection transition events", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - runtimeGeneration: 7, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const transitionPayloads = logger.debug.mock.calls - .filter(([, message]) => message === "DaemonClientTransition") - .map(([payload]) => payload as { generation?: number | null }); - expect(transitionPayloads.length).toBeGreaterThan(0); - for (const payload of transitionPayloads) { - expect(payload.generation).toBe(7); - } - }); - - test("subscribes to checkout diff updates via RPC handshake", 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.subscribeCheckoutDiff( - "/tmp/project", - { mode: "uncommitted" }, - { subscriptionId: "checkout-sub-1" }, - ); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(request.message.type).toBe("subscribe_checkout_diff_request"); - expect(request.message.subscriptionId).toBe("checkout-sub-1"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.compare).toEqual({ mode: "uncommitted" }); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "subscribe_checkout_diff_response", - payload: { - subscriptionId: "checkout-sub-1", - cwd: "/tmp/project", - files: [], - error: null, - requestId: request.message.requestId, - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - subscriptionId: "checkout-sub-1", - cwd: "/tmp/project", - files: [], - error: null, - requestId: request.message.requestId, - }); - }); - - test("getCheckoutDiff uses one-shot subscription protocol", 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.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); - - expect(mock.sent).toHaveLength(1); - const subscribeRequest = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request"); - expect(subscribeRequest.message.cwd).toBe("/tmp/project"); - expect(subscribeRequest.message.compare).toEqual({ - mode: "base", - baseRef: "main", - }); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "subscribe_checkout_diff_response", - payload: { - subscriptionId: subscribeRequest.message.subscriptionId, - cwd: "/tmp/project", - files: [], - error: null, - requestId: subscribeRequest.message.requestId, - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - cwd: "/tmp/project", - files: [], - error: null, - requestId: subscribeRequest.message.requestId, - }); - - expect(mock.sent).toHaveLength(2); - const unsubscribeRequest = JSON.parse(mock.sent[1]) as { - type: "session"; - message: { - type: "unsubscribe_checkout_diff_request"; - subscriptionId: string; - }; - }; - expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request"); - expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId); - }); - - test("requests branch suggestions via RPC", 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.getBranchSuggestions( - { cwd: "/tmp/project", query: "mai", limit: 5 }, - "req-branches", - ); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "branch_suggestions_request"; - cwd: string; - query?: string; - limit?: number; - requestId: string; - }; - }; - expect(request.message.type).toBe("branch_suggestions_request"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.query).toBe("mai"); - expect(request.message.limit).toBe(5); - expect(request.message.requestId).toBe("req-branches"); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "branch_suggestions_response", - payload: { - branches: ["main"], - error: null, - requestId: "req-branches", - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - branches: ["main"], - error: null, - requestId: "req-branches", - }); - }); - - test("requests directory suggestions via RPC", 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.getDirectorySuggestions( - { - query: "proj", - limit: 10, - cwd: "/tmp/project", - includeFiles: true, - includeDirectories: true, + requestId: "req-shutdown-1", }, - "req-directories", - ); + }), + ); - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "directory_suggestions_request"; - query: string; - cwd?: string; - includeFiles?: boolean; - includeDirectories?: boolean; - limit?: number; - requestId: string; - }; + 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.type).toBe("directory_suggestions_request"); - expect(request.message.query).toBe("proj"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.includeFiles).toBe(true); - expect(request.message.includeDirectories).toBe(true); - expect(request.message.limit).toBe(10); - expect(request.message.requestId).toBe("req-directories"); + }; + expect(request.message).toEqual({ + type: "restart_server_request", + reason: "settings_update", + requestId: "req-restart-1", + }); - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "directory_suggestions_response", - payload: { - directories: ["/Users/test/projects/paseo"], - entries: [{ path: "README.md", kind: "file" }], - error: null, - requestId: "req-directories", - }, + 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 { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + connectTimeoutMs: 100, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const pendingConnect = client.connect().then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ); + expect(client.getConnectionState().status).toBe("connecting"); + + await vi.advanceTimersByTimeAsync(120); + const result = await pendingConnect; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(Error); + expect((result.error as Error).message).toContain("Connection timed out"); + } + expect(client.getConnectionState().status).toBe("disconnected"); + } finally { + vi.useRealTimers(); + } +}); + +test("reconnects after relay close with replaced-by-new-connection reason", async () => { + vi.useFakeTimers(); + try { + const logger = createMockLogger(); + const first = createMockTransport(); + const second = createMockTransport(); + const transports = [first, second]; + let transportIndex = 0; + + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_test", + logger, + reconnect: { + enabled: true, + baseDelayMs: 5, + maxDelayMs: 5, + }, + transportFactory: () => { + const next = transports[Math.min(transportIndex, transports.length - 1)]; + transportIndex += 1; + return next.transport; + }, + }); + clients.push(client); + + const connectPromise = client.connect(); + first.triggerOpen(); + await connectPromise; + expect(client.getConnectionState().status).toBe("connected"); + + first.triggerClose({ code: 1008, reason: "Replaced by new connection" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + await vi.advanceTimersByTimeAsync(10); + expect(client.getConnectionState().status).toBe("connecting"); + + second.triggerOpen(); + expect(client.getConnectionState().status).toBe("connected"); + } finally { + vi.useRealTimers(); + } +}); + +test("requires non-empty clientId", () => { + expect(() => { + new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "", + reconnect: { enabled: false }, + }); + }).toThrow("Daemon client requires a non-empty clientId"); +}); + +test("requires non-empty clientId for direct connections", () => { + expect(() => { + new DaemonClient({ + url: "ws://127.0.0.1:6767/ws", + clientId: " ", + reconnect: { enabled: false }, + }); + }).toThrow("Daemon client requires a non-empty clientId"); +}); + +test("logs configured runtime generation in connection transition events", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + runtimeGeneration: 7, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const transitionPayloads = logger.debug.mock.calls + .filter(([, message]) => message === "DaemonClientTransition") + .map(([payload]) => payload as { generation?: number | null }); + expect(transitionPayloads.length).toBeGreaterThan(0); + for (const payload of transitionPayloads) { + expect(payload.generation).toBe(7); + } +}); + +test("subscribes to checkout diff updates via RPC handshake", 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.subscribeCheckoutDiff( + "/tmp/project", + { mode: "uncommitted" }, + { subscriptionId: "checkout-sub-1" }, + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "subscribe_checkout_diff_request"; + subscriptionId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string }; + requestId: string; + }; + }; + expect(request.message.type).toBe("subscribe_checkout_diff_request"); + expect(request.message.subscriptionId).toBe("checkout-sub-1"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.compare).toEqual({ mode: "uncommitted" }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "subscribe_checkout_diff_response", + payload: { + subscriptionId: "checkout-sub-1", + cwd: "/tmp/project", + files: [], + error: null, + requestId: request.message.requestId, }, - }), - ); + }, + }), + ); - await expect(promise).resolves.toEqual({ - directories: ["/Users/test/projects/paseo"], - entries: [{ path: "README.md", kind: "file" }], - error: null, - requestId: "req-directories", - }); + await expect(promise).resolves.toEqual({ + subscriptionId: "checkout-sub-1", + cwd: "/tmp/project", + files: [], + error: null, + requestId: request.message.requestId, + }); +}); + +test("getCheckoutDiff uses one-shot subscription protocol", 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.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); + + expect(mock.sent).toHaveLength(1); + const subscribeRequest = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "subscribe_checkout_diff_request"; + subscriptionId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string }; + requestId: string; + }; + }; + expect(subscribeRequest.message.type).toBe("subscribe_checkout_diff_request"); + expect(subscribeRequest.message.cwd).toBe("/tmp/project"); + expect(subscribeRequest.message.compare).toEqual({ + mode: "base", + baseRef: "main", }); - test("requests checkout merge from base via RPC", 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.checkoutMergeFromBase( - "/tmp/project", - { baseRef: "main", requireCleanTarget: true }, - "req-merge-from-base", - ); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; + mock.triggerMessage( + JSON.stringify({ + type: "session", message: { - type: "checkout_merge_from_base_request"; - cwd: string; - baseRef?: string; - requireCleanTarget?: boolean; - requestId: string; - }; - }; - expect(request.message.type).toBe("checkout_merge_from_base_request"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.baseRef).toBe("main"); - expect(request.message.requireCleanTarget).toBe(true); - expect(request.message.requestId).toBe("req-merge-from-base"); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "checkout_merge_from_base_response", - payload: { - cwd: "/tmp/project", - requestId: "req-merge-from-base", - success: true, - error: null, - }, + type: "subscribe_checkout_diff_response", + payload: { + subscriptionId: subscribeRequest.message.subscriptionId, + cwd: "/tmp/project", + files: [], + error: null, + requestId: subscribeRequest.message.requestId, }, - }), - ); + }, + }), + ); - await expect(promise).resolves.toEqual({ - cwd: "/tmp/project", - requestId: "req-merge-from-base", - success: true, - error: null, - }); + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + files: [], + error: null, + requestId: subscribeRequest.message.requestId, }); - test("requests checkout pull via RPC", 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.checkoutPull("/tmp/project", "req-pull"); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "checkout_pull_request"; - cwd: string; - requestId: string; - }; + expect(mock.sent).toHaveLength(2); + const unsubscribeRequest = JSON.parse(mock.sent[1]) as { + type: "session"; + message: { + type: "unsubscribe_checkout_diff_request"; + subscriptionId: string; }; - expect(request.message.type).toBe("checkout_pull_request"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.requestId).toBe("req-pull"); + }; + expect(unsubscribeRequest.message.type).toBe("unsubscribe_checkout_diff_request"); + expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId); +}); - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "checkout_pull_response", - payload: { - cwd: "/tmp/project", - requestId: "req-pull", - success: true, - error: null, - }, +test("requests branch suggestions via RPC", 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.getBranchSuggestions( + { cwd: "/tmp/project", query: "mai", limit: 5 }, + "req-branches", + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "branch_suggestions_request"; + cwd: string; + query?: string; + limit?: number; + requestId: string; + }; + }; + expect(request.message.type).toBe("branch_suggestions_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.query).toBe("mai"); + expect(request.message.limit).toBe(5); + expect(request.message.requestId).toBe("req-branches"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "branch_suggestions_response", + payload: { + branches: ["main"], + error: null, + requestId: "req-branches", }, - }), - ); + }, + }), + ); - await expect(promise).resolves.toEqual({ + await expect(promise).resolves.toEqual({ + branches: ["main"], + error: null, + requestId: "req-branches", + }); +}); + +test("requests directory suggestions via RPC", 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.getDirectorySuggestions( + { + query: "proj", + limit: 10, cwd: "/tmp/project", - requestId: "req-pull", - success: true, - error: null, - }); + includeFiles: true, + includeDirectories: true, + }, + "req-directories", + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "directory_suggestions_request"; + query: string; + cwd?: string; + includeFiles?: boolean; + includeDirectories?: boolean; + limit?: number; + requestId: string; + }; + }; + expect(request.message.type).toBe("directory_suggestions_request"); + expect(request.message.query).toBe("proj"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.includeFiles).toBe(true); + expect(request.message.includeDirectories).toBe(true); + expect(request.message.limit).toBe(10); + expect(request.message.requestId).toBe("req-directories"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "directory_suggestions_response", + payload: { + directories: ["/Users/test/projects/paseo"], + entries: [{ path: "README.md", kind: "file" }], + error: null, + requestId: "req-directories", + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + directories: ["/Users/test/projects/paseo"], + entries: [{ path: "README.md", kind: "file" }], + error: null, + requestId: "req-directories", + }); +}); + +test("requests checkout merge from base via RPC", 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.checkoutMergeFromBase( + "/tmp/project", + { baseRef: "main", requireCleanTarget: true }, + "req-merge-from-base", + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout_merge_from_base_request"; + cwd: string; + baseRef?: string; + requireCleanTarget?: boolean; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout_merge_from_base_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.baseRef).toBe("main"); + expect(request.message.requireCleanTarget).toBe(true); + expect(request.message.requestId).toBe("req-merge-from-base"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout_merge_from_base_response", + payload: { + cwd: "/tmp/project", + requestId: "req-merge-from-base", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + requestId: "req-merge-from-base", + success: true, + error: null, + }); +}); + +test("requests checkout pull via RPC", 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.checkoutPull("/tmp/project", "req-pull"); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout_pull_request"; + cwd: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout_pull_request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.requestId).toBe("req-pull"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout_pull_response", + payload: { + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + cwd: "/tmp/project", + requestId: "req-pull", + success: true, + error: null, + }); +}); + +test("resubscribes checkout diff streams after reconnect", 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 internal = client as unknown as { + checkoutDiffSubscriptions: Map< + string, + { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } + >; + }; + internal.checkoutDiffSubscriptions.set("checkout-sub-1", { + cwd: "/tmp/project", + compare: { mode: "base", baseRef: "main" }, }); - test("resubscribes checkout diff streams after reconnect", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory: () => mock.transport, - }); - clients.push(client); - - const internal = client as unknown as { - checkoutDiffSubscriptions: Map< - string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >; + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "subscribe_checkout_diff_request"; + subscriptionId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string }; + requestId: string; }; - internal.checkoutDiffSubscriptions.set("checkout-sub-1", { - cwd: "/tmp/project", - compare: { mode: "base", baseRef: "main" }, - }); + }; + expect(request.message.type).toBe("subscribe_checkout_diff_request"); + expect(request.message.subscriptionId).toBe("checkout-sub-1"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" }); + expect(typeof request.message.requestId).toBe("string"); + expect(request.message.requestId.length).toBeGreaterThan(0); +}); - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; +test("fetches agents via RPC with filters, sort, and pagination", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "subscribe_checkout_diff_request"; - subscriptionId: string; - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string }; - requestId: string; - }; - }; - expect(request.message.type).toBe("subscribe_checkout_diff_request"); - expect(request.message.subscriptionId).toBe("checkout-sub-1"); - expect(request.message.cwd).toBe("/tmp/project"); - expect(request.message.compare).toEqual({ mode: "base", baseRef: "main" }); - expect(typeof request.message.requestId).toBe("string"); - expect(request.message.requestId.length).toBeGreaterThan(0); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, }); + clients.push(client); - test("fetches agents via RPC with filters, sort, and pagination", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; - 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.fetchAgents({ - filter: { labels: { surface: "workspace" } }, - sort: [ - { key: "status_priority", direction: "asc" }, - { key: "created_at", direction: "desc" }, - ], - page: { limit: 25, cursor: "cursor-1" }, - subscribe: { subscriptionId: "sub-1" }, - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { - type: "fetch_agents_request"; - requestId: string; - filter?: { labels?: Record }; - sort?: Array<{ - key: "status_priority" | "created_at" | "updated_at" | "title"; - direction: "asc" | "desc"; - }>; - page?: { limit: number; cursor?: string }; - subscribe?: { subscriptionId?: string }; - }; - }; - expect(request.message.type).toBe("fetch_agents_request"); - expect(request.message.sort).toEqual([ + const promise = client.fetchAgents({ + filter: { labels: { surface: "workspace" } }, + sort: [ { key: "status_priority", direction: "asc" }, { key: "created_at", direction: "desc" }, - ]); - expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); - expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" }); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "fetch_agents_response", - payload: { - requestId: request.message.requestId, - subscriptionId: "sub-1", - entries: [], - pageInfo: { - nextCursor: null, - prevCursor: "cursor-1", - hasMore: false, - }, - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - requestId: request.message.requestId, - subscriptionId: "sub-1", - entries: [], - pageInfo: { - nextCursor: null, - prevCursor: "cursor-1", - hasMore: false, - }, - }); + ], + page: { limit: 25, cursor: "cursor-1" }, + subscribe: { subscriptionId: "sub-1" }, }); - test("sends active-scoped fetch_agents_request", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "fetch_agents_request"; + requestId: string; + filter?: { labels?: Record }; + sort?: Array<{ + key: "status_priority" | "created_at" | "updated_at" | "title"; + direction: "asc" | "desc"; + }>; + page?: { limit: number; cursor?: string }; + subscribe?: { subscriptionId?: string }; + }; + }; + expect(request.message.type).toBe("fetch_agents_request"); + expect(request.message.sort).toEqual([ + { key: "status_priority", direction: "asc" }, + { key: "created_at", direction: "desc" }, + ]); + expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); + expect(request.message.subscribe).toEqual({ subscriptionId: "sub-1" }); - 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.fetchAgents({ - scope: "active", - page: { limit: 50 }, - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; + mock.triggerMessage( + JSON.stringify({ + type: "session", message: { - type: "fetch_agents_request"; - requestId: string; - scope?: "active"; - }; - }; - expect(request.message).toMatchObject({ - type: "fetch_agents_request", - scope: "active", - }); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "fetch_agents_response", - payload: { - requestId: request.message.requestId, - entries: [], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, + type: "fetch_agents_response", + payload: { + requestId: request.message.requestId, + subscriptionId: "sub-1", + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, }, }, - }), - ); + }, + }), + ); - await expect(promise).resolves.toMatchObject({ - requestId: request.message.requestId, - entries: [], - }); + await expect(promise).resolves.toEqual({ + requestId: request.message.requestId, + subscriptionId: "sub-1", + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, + }); +}); + +test("sends active-scoped fetch_agents_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.fetchAgents({ + scope: "active", + page: { limit: 50 }, }); - test("fetches paginated agent history separately from active agents", async () => { - const logger = createMockLogger(); - const mock = createMockTransport(); + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "fetch_agents_request"; + requestId: string; + scope?: "active"; + }; + }; + expect(request.message).toMatchObject({ + type: "fetch_agents_request", + scope: "active", + }); - 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.fetchAgentHistory({ - page: { limit: 25, cursor: "cursor-1" }, - sort: [{ key: "updated_at", direction: "desc" }], - }); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; + mock.triggerMessage( + JSON.stringify({ + type: "session", message: { - type: "fetch_agent_history_request"; - requestId: string; - page?: { limit: number; cursor?: string }; - }; - }; - expect(request.message.type).toBe("fetch_agent_history_request"); - expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); - - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "fetch_agent_history_response", - payload: { - requestId: request.message.requestId, - entries: [], - pageInfo: { - nextCursor: null, - prevCursor: "cursor-1", - hasMore: false, - }, + type: "fetch_agents_response", + payload: { + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, }, }, - }), - ); - - await expect(promise).resolves.toEqual({ - requestId: request.message.requestId, - entries: [], - pageInfo: { - nextCursor: null, - prevCursor: "cursor-1", - hasMore: false, }, - }); + }), + ); + + await expect(promise).resolves.toMatchObject({ + requestId: request.message.requestId, + entries: [], + }); +}); + +test("fetches paginated agent history separately from active agents", 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.fetchAgentHistory({ + page: { limit: 25, cursor: "cursor-1" }, + sort: [{ key: "updated_at", direction: "desc" }], }); - test("uses server-provided dictation finish timeout budget", async () => { - vi.useFakeTimers(); - 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 finishPromise = client.finishDictationStream("dict-1", 0); - const finishError = finishPromise.then( - () => null, - (error) => error, - ); - - expect(mock.sent).toHaveLength(1); - mock.triggerMessage( - wrapSessionMessage({ - type: "dictation_stream_finish_accepted", - payload: { - dictationId: "dict-1", - timeoutMs: 100, - }, - }), - ); - - await vi.advanceTimersByTimeAsync(5_101); - const error = await finishError; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "Timeout waiting for dictation finalization (5100ms)", - ); - - vi.useRealTimers(); - }); - - test("resolves dictation finish when final arrives after finish accepted", 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 finishPromise = client.finishDictationStream("dict-2", 1); - expect(mock.sent).toHaveLength(1); - - mock.triggerMessage( - wrapSessionMessage({ - type: "dictation_stream_finish_accepted", - payload: { - dictationId: "dict-2", - timeoutMs: 1000, - }, - }), - ); - mock.triggerMessage( - wrapSessionMessage({ - type: "dictation_stream_final", - payload: { - dictationId: "dict-2", - text: "hello", - }, - }), - ); - - await expect(finishPromise).resolves.toEqual({ - dictationId: "dict-2", - text: "hello", - }); - }); - - test("cancels waiters when send fails (no leaked timeouts)", async () => { - vi.useFakeTimers(); - const logger = createMockLogger(); - const mock = createMockTransport(); - let sendCount = 0; - - const transportFactory = () => ({ - ...mock.transport, - send: () => { - sendCount += 1; - if (sendCount > 1) { - throw new Error("boom"); - } - }, - }); - - const client = new DaemonClient({ - url: "ws://test", - clientId: "clsk_unit_test", - logger, - reconnect: { enabled: false }, - transportFactory, - }); - clients.push(client); - - const connectPromise = client.connect(); - mock.triggerOpen(); - await connectPromise; - - const promise = client.getCheckoutStatus("/tmp/project"); - await expect(promise).rejects.toThrow("boom"); - - // Ensure we didn't leave a waiter behind that will reject later. - const internal = client as unknown as { waiters: Set }; - expect(internal.waiters.size).toBe(0); - - vi.runOnlyPendingTimers(); - vi.useRealTimers(); - }); - - test("lists available providers via RPC", 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.listAvailableProviders(); - expect(mock.sent).toHaveLength(1); - - const request = JSON.parse(mock.sent[0]) as { - type: "session"; - message: { type: "list_available_providers_request"; requestId: string }; + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "fetch_agent_history_request"; + requestId: string; + page?: { limit: number; cursor?: string }; }; - expect(request.message.type).toBe("list_available_providers_request"); + }; + expect(request.message.type).toBe("fetch_agent_history_request"); + expect(request.message.page).toEqual({ limit: 25, cursor: "cursor-1" }); - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "list_available_providers_response", - payload: { - providers: [ - { provider: "claude", available: true, error: null }, - { provider: "codex", available: false, error: "Missing binary" }, - ], - error: null, - fetchedAt: "2026-02-12T00:00:00.000Z", - requestId: request.message.requestId, - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - providers: [ - { provider: "claude", available: true, error: null }, - { provider: "codex", available: false, error: "Missing binary" }, - ], - error: null, - fetchedAt: "2026-02-12T00:00:00.000Z", - requestId: request.message.requestId, - }); - }); - - test("lists commands with draft config via RPC", 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.listCommands("__new_agent__", { - draftConfig: { - provider: "codex", - cwd: "/tmp/project", - modeId: "bypassPermissions", - model: "gpt-5", - thinkingOptionId: "off", - }, - }); - expect(mock.sent).toHaveLength(1); - - const request = JSON.parse(mock.sent[0]) as { - type: "session"; + mock.triggerMessage( + JSON.stringify({ + type: "session", message: { - type: "list_commands_request"; - agentId: string; - draftConfig?: { - provider: string; - cwd: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - }; - requestId: string; - }; - }; - expect(request.message.type).toBe("list_commands_request"); - expect(request.message.agentId).toBe("__new_agent__"); - expect(request.message.draftConfig).toEqual({ + type: "fetch_agent_history_response", + payload: { + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: request.message.requestId, + entries: [], + pageInfo: { + nextCursor: null, + prevCursor: "cursor-1", + hasMore: false, + }, + }); +}); + +test("uses server-provided dictation finish timeout budget", async () => { + vi.useFakeTimers(); + 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 finishPromise = client.finishDictationStream("dict-1", 0); + const finishError = finishPromise.then( + () => null, + (error) => error, + ); + + expect(mock.sent).toHaveLength(1); + mock.triggerMessage( + wrapSessionMessage({ + type: "dictation_stream_finish_accepted", + payload: { + dictationId: "dict-1", + timeoutMs: 100, + }, + }), + ); + + await vi.advanceTimersByTimeAsync(5_101); + const error = await finishError; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Timeout waiting for dictation finalization (5100ms)"); + + vi.useRealTimers(); +}); + +test("resolves dictation finish when final arrives after finish accepted", 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 finishPromise = client.finishDictationStream("dict-2", 1); + expect(mock.sent).toHaveLength(1); + + mock.triggerMessage( + wrapSessionMessage({ + type: "dictation_stream_finish_accepted", + payload: { + dictationId: "dict-2", + timeoutMs: 1000, + }, + }), + ); + mock.triggerMessage( + wrapSessionMessage({ + type: "dictation_stream_final", + payload: { + dictationId: "dict-2", + text: "hello", + }, + }), + ); + + await expect(finishPromise).resolves.toEqual({ + dictationId: "dict-2", + text: "hello", + }); +}); + +test("cancels waiters when send fails (no leaked timeouts)", async () => { + vi.useFakeTimers(); + const logger = createMockLogger(); + const mock = createMockTransport(); + let sendCount = 0; + + const transportFactory = () => ({ + ...mock.transport, + send: () => { + sendCount += 1; + if (sendCount > 1) { + throw new Error("boom"); + } + }, + }); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.getCheckoutStatus("/tmp/project"); + await expect(promise).rejects.toThrow("boom"); + + // Ensure we didn't leave a waiter behind that will reject later. + const internal = client as unknown as { waiters: Set }; + expect(internal.waiters.size).toBe(0); + + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); + +test("lists available providers via RPC", 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.listAvailableProviders(); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { type: "list_available_providers_request"; requestId: string }; + }; + expect(request.message.type).toBe("list_available_providers_request"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_available_providers_response", + payload: { + providers: [ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: false, error: "Missing binary" }, + ], + error: null, + fetchedAt: "2026-02-12T00:00:00.000Z", + requestId: request.message.requestId, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + providers: [ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: false, error: "Missing binary" }, + ], + error: null, + fetchedAt: "2026-02-12T00:00:00.000Z", + requestId: request.message.requestId, + }); +}); + +test("lists commands with draft config via RPC", 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.listCommands("__new_agent__", { + draftConfig: { provider: "codex", cwd: "/tmp/project", modeId: "bypassPermissions", model: "gpt-5", thinkingOptionId: "off", - }); + }, + }); + expect(mock.sent).toHaveLength(1); - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "list_commands_response", - payload: { - agentId: "__new_agent__", - commands: [{ name: "help", description: "Show help", argumentHint: "" }], - error: null, - requestId: request.message.requestId, - }, - }, - }), - ); - - await expect(promise).resolves.toEqual({ - agentId: "__new_agent__", - commands: [{ name: "help", description: "Show help", argumentHint: "" }], - error: null, - requestId: request.message.requestId, - }); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: { + provider: string; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + }; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("__new_agent__"); + expect(request.message.draftConfig).toEqual({ + provider: "codex", + cwd: "/tmp/project", + modeId: "bypassPermissions", + model: "gpt-5", + thinkingOptionId: "off", }); - test("lists commands with legacy requestId signature via RPC", 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.listCommands("agent-1", "req-legacy"); - expect(mock.sent).toHaveLength(1); - - const request = JSON.parse(mock.sent[0]) as { - type: "session"; + mock.triggerMessage( + JSON.stringify({ + type: "session", message: { - type: "list_commands_request"; - agentId: string; - draftConfig?: unknown; - requestId: string; + type: "list_commands_response", + payload: { + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: request.message.requestId, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + agentId: "__new_agent__", + commands: [{ name: "help", description: "Show help", argumentHint: "" }], + error: null, + requestId: request.message.requestId, + }); +}); + +test("lists commands with legacy requestId signature via RPC", 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.listCommands("agent-1", "req-legacy"); + expect(mock.sent).toHaveLength(1); + + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "list_commands_request"; + agentId: string; + draftConfig?: unknown; + requestId: string; + }; + }; + expect(request.message.type).toBe("list_commands_request"); + expect(request.message.agentId).toBe("agent-1"); + expect(request.message.requestId).toBe("req-legacy"); + expect(request.message.draftConfig).toBeUndefined(); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "list_commands_response", + payload: { + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + agentId: "agent-1", + commands: [], + error: null, + requestId: "req-legacy", + }); +}); + +test("emits output events for the active terminal stream", 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 seen: string[] = []; + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.type !== "output") { + return; + } + seen.push(new TextDecoder().decode(event.data)); + }); + + const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + slot: 11, + error: null, + requestId: "sub-1", + }, + }), + ); + await subscribePromise; + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 11, + payload: new TextEncoder().encode("hello"), + }), + ); + + expect(seen).toEqual(["hello"]); + expect(mock.sent).toHaveLength(1); + unsubscribe(); +}); + +test("emits snapshot events for the subscribed terminal stream", 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 snapshots: unknown[] = []; + client.onTerminalStreamEvent((event) => { + if (event.type !== "snapshot") { + return; + } + snapshots.push(event.state); + }); + + const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + slot: 12, + error: null, + requestId: "sub-2", + }, + }), + ); + await subscribePromise; + + const state = { + rows: 1, + cols: 5, + grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], + scrollback: [], + cursor: { row: 0, col: 5 }, + }; + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Snapshot, + slot: 12, + payload: encodeTerminalSnapshotPayload(state), + }), + ); + + expect(snapshots).toEqual([state]); +}); + +test("sends input and resize frames for the subscribed terminal slot", 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 subscribePromise = client.subscribeTerminal("term-1", "sub-3"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + slot: 13, + error: null, + requestId: "sub-3", + }, + }), + ); + await subscribePromise; + mock.sent.length = 0; + + client.sendTerminalInput("term-1", { + type: "input", + data: "echo hello\r", + }); + client.sendTerminalInput("term-1", { + type: "resize", + rows: 24, + cols: 80, + }); + + const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); + const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); + + expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); + expect(inputFrame?.slot).toBe(13); + expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); + expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); + expect(resizeFrame?.slot).toBe(13); + expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ + rows: 24, + cols: 80, + }); +}); + +test("routes concurrent terminal stream frames by slot", 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 seen: string[] = []; + client.onTerminalStreamEvent((event) => { + if (event.type !== "output") { + return; + } + seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`); + }); + + const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + slot: 21, + error: null, + requestId: "sub-multi-1", + }, + }), + ); + await subscribeFirstPromise; + + const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-2", + slot: 22, + error: null, + requestId: "sub-multi-2", + }, + }), + ); + await subscribeSecondPromise; + mock.sent.length = 0; + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 22, + payload: new TextEncoder().encode("beta"), + }), + ); + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 21, + payload: new TextEncoder().encode("alpha"), + }), + ); + + client.sendTerminalInput("term-2", { + type: "input", + data: "echo beta\r", + }); + client.sendTerminalInput("term-1", { + type: "resize", + rows: 10, + cols: 20, + }); + + const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); + const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); + + expect(seen).toEqual(["term-2:beta", "term-1:alpha"]); + expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); + expect(inputFrame?.slot).toBe(22); + expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); + expect(resizeFrame?.slot).toBe(21); +}); + +test("ignores terminal stream frames after terminal_stream_exit", 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 seen: string[] = []; + const unsubscribe = client.onTerminalStreamEvent((event) => { + if (event.type !== "output") { + return; + } + seen.push(new TextDecoder().decode(event.data)); + }); + + const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_terminal_response", + payload: { + terminalId: "term-1", + slot: 14, + error: null, + requestId: "sub-4", + }, + }), + ); + await subscribePromise; + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 14, + payload: new TextEncoder().encode("before-exit"), + }), + ); + expect(seen).toEqual(["before-exit"]); + + mock.triggerMessage( + wrapSessionMessage({ + type: "terminal_stream_exit", + payload: { + terminalId: "term-1", + }, + }), + ); + + mock.triggerMessage( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + slot: 14, + payload: new TextEncoder().encode("after-exit"), + }), + ); + + expect(seen).toEqual(["before-exit"]); + unsubscribe(); +}); + +test("parses canonical agent_stream tool_call payloads without crashing", 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 received: unknown[] = []; + const unsubscribe = client.on("agent_stream", (msg) => { + received.push(msg); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: "agent_cli", + timestamp: "2026-02-08T20:20:00.000Z", + event: { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_cli_stream", + name: "shell", + status: "running", + detail: { + type: "shell", + command: "pwd", + }, + error: null, + }, + }, + }, + }), + ); + + unsubscribe(); + + expect(received).toHaveLength(1); + const streamMsg = received[0] as { + payload: { + event: { + type: "timeline"; + item: { + type: "tool_call"; + status: string; + error: unknown; + detail: { + type: string; + }; + }; }; }; - expect(request.message.type).toBe("list_commands_request"); - expect(request.message.agentId).toBe("agent-1"); - expect(request.message.requestId).toBe("req-legacy"); - expect(request.message.draftConfig).toBeUndefined(); + }; - mock.triggerMessage( - JSON.stringify({ - type: "session", - message: { - type: "list_commands_response", - payload: { - agentId: "agent-1", - commands: [], - error: null, - requestId: "req-legacy", + expect(streamMsg.payload.event.item.status).toBe("running"); + expect(streamMsg.payload.event.item.error).toBeNull(); + expect(streamMsg.payload.event.item.detail.type).toBe("shell"); + expect(logger.warn).not.toHaveBeenCalled(); +}); + +test("drops legacy agent_stream tool_call payloads and logs validation warning", 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 received: unknown[] = []; + const unsubscribe = client.on("agent_stream", (msg) => { + received.push(msg); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_stream", + payload: { + agentId: "agent_cli", + timestamp: "2026-02-08T20:20:00.000Z", + event: { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "call_cli_stream_legacy", + name: "shell", + status: "inProgress", + detail: { + type: "unknown", + input: { command: "pwd" }, + output: null, + }, }, }, - }), - ); + }, + }), + ); - await expect(promise).resolves.toEqual({ - agentId: "agent-1", - commands: [], - error: null, - requestId: "req-legacy", - }); + unsubscribe(); + + expect(received).toHaveLength(0); + expect(logger.warn).toHaveBeenCalled(); +}); + +test("parses canonical fetch_agent_timeline_response payloads without crashing", 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 received: unknown[] = []; + const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { + received.push(msg); }); - test("emits output events for the active terminal stream", 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 seen: string[] = []; - const unsubscribe = client.onTerminalStreamEvent((event) => { - if (event.type !== "output") { - return; - } - seen.push(new TextDecoder().decode(event.data)); - }); - - const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-1", - slot: 11, - error: null, - requestId: "sub-1", - }, - }), - ); - await subscribePromise; - - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot: 11, - payload: new TextEncoder().encode("hello"), - }), - ); - - expect(seen).toEqual(["hello"]); - expect(mock.sent).toHaveLength(1); - unsubscribe(); - }); - - test("emits snapshot events for the subscribed terminal stream", 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 snapshots: unknown[] = []; - client.onTerminalStreamEvent((event) => { - if (event.type !== "snapshot") { - return; - } - snapshots.push(event.state); - }); - - const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-1", - slot: 12, - error: null, - requestId: "sub-2", - }, - }), - ); - await subscribePromise; - - const state = { - rows: 1, - cols: 5, - grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]], - scrollback: [], - cursor: { row: 0, col: 5 }, - }; - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Snapshot, - slot: 12, - payload: encodeTerminalSnapshotPayload(state), - }), - ); - - expect(snapshots).toEqual([state]); - }); - - test("sends input and resize frames for the subscribed terminal slot", 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 subscribePromise = client.subscribeTerminal("term-1", "sub-3"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-1", - slot: 13, - error: null, - requestId: "sub-3", - }, - }), - ); - await subscribePromise; - mock.sent.length = 0; - - client.sendTerminalInput("term-1", { - type: "input", - data: "echo hello\r", - }); - client.sendTerminalInput("term-1", { - type: "resize", - rows: 24, - cols: 80, - }); - - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(13); - expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(13); - expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ - rows: 24, - cols: 80, - }); - }); - - test("routes concurrent terminal stream frames by slot", 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 seen: string[] = []; - client.onTerminalStreamEvent((event) => { - if (event.type !== "output") { - return; - } - seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`); - }); - - const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-1", - slot: 21, - error: null, - requestId: "sub-multi-1", - }, - }), - ); - await subscribeFirstPromise; - - const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-2", - slot: 22, - error: null, - requestId: "sub-multi-2", - }, - }), - ); - await subscribeSecondPromise; - mock.sent.length = 0; - - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot: 22, - payload: new TextEncoder().encode("beta"), - }), - ); - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot: 21, - payload: new TextEncoder().encode("alpha"), - }), - ); - - client.sendTerminalInput("term-2", { - type: "input", - data: "echo beta\r", - }); - client.sendTerminalInput("term-1", { - type: "resize", - rows: 10, - cols: 20, - }); - - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - - expect(seen).toEqual(["term-2:beta", "term-1:alpha"]); - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(22); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(21); - }); - - test("ignores terminal stream frames after terminal_stream_exit", 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 seen: string[] = []; - const unsubscribe = client.onTerminalStreamEvent((event) => { - if (event.type !== "output") { - return; - } - seen.push(new TextDecoder().decode(event.data)); - }); - - const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); - mock.triggerMessage( - wrapSessionMessage({ - type: "subscribe_terminal_response", - payload: { - terminalId: "term-1", - slot: 14, - error: null, - requestId: "sub-4", - }, - }), - ); - await subscribePromise; - - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot: 14, - payload: new TextEncoder().encode("before-exit"), - }), - ); - expect(seen).toEqual(["before-exit"]); - - mock.triggerMessage( - wrapSessionMessage({ - type: "terminal_stream_exit", - payload: { - terminalId: "term-1", - }, - }), - ); - - mock.triggerMessage( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Output, - slot: 14, - payload: new TextEncoder().encode("after-exit"), - }), - ); - - expect(seen).toEqual(["before-exit"]); - unsubscribe(); - }); - - test("parses canonical agent_stream tool_call payloads without crashing", 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 received: unknown[] = []; - const unsubscribe = client.on("agent_stream", (msg) => { - received.push(msg); - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "agent_stream", - payload: { - agentId: "agent_cli", - timestamp: "2026-02-08T20:20:00.000Z", - event: { - type: "timeline", + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId: "req-1", + agentId: "agent_cli", + agent: null, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, + hasOlder: false, + hasNewer: false, + entries: [ + { + timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], item: { type: "tool_call", - callId: "call_cli_stream", + callId: "call_cli_snapshot", name: "shell", status: "running", detail: { @@ -2297,69 +2436,91 @@ describe("DaemonClient", () => { error: null, }, }, - }, - }), - ); + ], + error: null, + }, + }), + ); - unsubscribe(); + unsubscribe(); - expect(received).toHaveLength(1); - const streamMsg = received[0] as { - payload: { - event: { - type: "timeline"; - item: { - type: "tool_call"; - status: string; - error: unknown; - detail: { - type: string; - }; + expect(received).toHaveLength(1); + const timelineMsg = received[0] as { + payload: { + entries: Array<{ + item: { + type: "tool_call"; + status: string; + error: unknown; + detail: { + type: string; }; }; - }; + }>; }; + }; - expect(streamMsg.payload.event.item.status).toBe("running"); - expect(streamMsg.payload.event.item.error).toBeNull(); - expect(streamMsg.payload.event.item.detail.type).toBe("shell"); - expect(logger.warn).not.toHaveBeenCalled(); + const firstEntry = timelineMsg.payload.entries[0]; + expect(firstEntry?.item.type).toBe("tool_call"); + if (firstEntry?.item.type === "tool_call") { + expect(firstEntry.item.status).toBe("running"); + expect(firstEntry.item.error).toBeNull(); + expect(firstEntry.item.detail.type).toBe("shell"); + } + expect(logger.warn).not.toHaveBeenCalled(); +}); + +test("drops invalid fetch_agent_timeline_response tool_call payloads and logs validation warning", 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 received: unknown[] = []; + const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { + received.push(msg); }); - test("drops legacy agent_stream tool_call payloads and logs validation warning", 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 received: unknown[] = []; - const unsubscribe = client.on("agent_stream", (msg) => { - received.push(msg); - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "agent_stream", - payload: { - agentId: "agent_cli", - timestamp: "2026-02-08T20:20:00.000Z", - event: { - type: "timeline", + mock.triggerMessage( + wrapSessionMessage({ + type: "fetch_agent_timeline_response", + payload: { + requestId: "req-invalid", + agentId: "agent_cli", + agent: null, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { epoch: "epoch-1", seq: 1 }, + endCursor: { epoch: "epoch-1", seq: 1 }, + hasOlder: false, + hasNewer: false, + entries: [ + { + timestamp: "2026-02-08T20:20:00.000Z", provider: "codex", + seqStart: 1, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + collapsed: [], item: { type: "tool_call", - callId: "call_cli_stream_legacy", + callId: "call_cli_invalid", name: "shell", status: "inProgress", detail: { @@ -2369,387 +2530,222 @@ describe("DaemonClient", () => { }, }, }, - }, - }), - ); - - unsubscribe(); - - expect(received).toHaveLength(0); - expect(logger.warn).toHaveBeenCalled(); - }); - - test("parses canonical fetch_agent_timeline_response payloads without crashing", 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 received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "fetch_agent_timeline_response", - payload: { - requestId: "req-1", - agentId: "agent_cli", - agent: null, - direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, - hasOlder: false, - hasNewer: false, - entries: [ - { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], - item: { - type: "tool_call", - callId: "call_cli_snapshot", - name: "shell", - status: "running", - detail: { - type: "shell", - command: "pwd", - }, - error: null, - }, - }, - ], - error: null, - }, - }), - ); - - unsubscribe(); - - expect(received).toHaveLength(1); - const timelineMsg = received[0] as { - payload: { - entries: Array<{ - item: { - type: "tool_call"; - status: string; - error: unknown; - detail: { - type: string; - }; - }; - }>; - }; - }; - - const firstEntry = timelineMsg.payload.entries[0]; - expect(firstEntry?.item.type).toBe("tool_call"); - if (firstEntry?.item.type === "tool_call") { - expect(firstEntry.item.status).toBe("running"); - expect(firstEntry.item.error).toBeNull(); - expect(firstEntry.item.detail.type).toBe("shell"); - } - expect(logger.warn).not.toHaveBeenCalled(); - }); - - test("drops invalid fetch_agent_timeline_response tool_call payloads and logs validation warning", 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 received: unknown[] = []; - const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => { - received.push(msg); - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "fetch_agent_timeline_response", - payload: { - requestId: "req-invalid", - agentId: "agent_cli", - agent: null, - direction: "tail", - projection: "projected", - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, - startCursor: { epoch: "epoch-1", seq: 1 }, - endCursor: { epoch: "epoch-1", seq: 1 }, - hasOlder: false, - hasNewer: false, - entries: [ - { - timestamp: "2026-02-08T20:20:00.000Z", - provider: "codex", - seqStart: 1, - seqEnd: 1, - sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], - collapsed: [], - item: { - type: "tool_call", - callId: "call_cli_invalid", - name: "shell", - status: "inProgress", - detail: { - type: "unknown", - input: { command: "pwd" }, - output: null, - }, - }, - }, - ], - error: null, - }, - }), - ); - - unsubscribe(); - - expect(received).toHaveLength(0); - expect(logger.warn).toHaveBeenCalled(); - }); - - test("sends subscribe/unsubscribe terminals messages", 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; - - client.subscribeTerminals({ cwd: "/tmp/project" }); - client.unsubscribeTerminals({ cwd: "/tmp/project" }); - - expect(mock.sent).toHaveLength(2); - expect(JSON.parse(String(mock.sent[0]))).toEqual({ - type: "session", - message: { - type: "subscribe_terminals_request", - cwd: "/tmp/project", - }, - }); - expect(JSON.parse(String(mock.sent[1]))).toEqual({ - type: "session", - message: { - type: "unsubscribe_terminals_request", - cwd: "/tmp/project", - }, - }); - }); - - test("dispatches terminals_changed events to typed listeners", 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 received: Array<{ cwd: string; names: string[] }> = []; - const unsubscribe = client.on("terminals_changed", (message) => { - received.push({ - cwd: message.payload.cwd, - names: message.payload.terminals.map((terminal) => terminal.name), - }); - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "terminals_changed", - payload: { - cwd: "/tmp/project", - terminals: [ - { - id: "term-1", - name: "Dev Server", - }, - ], - }, - }), - ); - - unsubscribe(); - - expect(received).toEqual([ - { - cwd: "/tmp/project", - names: ["Dev Server"], - }, - ]); - }); - - test("sends close_items_request and resolves close_items_response", 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 responsePromise = client.closeItems( - { - agentIds: ["agent-1"], - terminalIds: ["term-1"], - }, - "req-close-items", - ); - - expect(JSON.parse(String(mock.sent[0]))).toEqual({ - type: "session", - message: { - type: "close_items_request", - agentIds: ["agent-1"], - terminalIds: ["term-1"], - requestId: "req-close-items", - }, - }); - - mock.triggerMessage( - wrapSessionMessage({ - type: "close_items_response", - payload: { - agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], - terminals: [{ terminalId: "term-1", success: true }], - requestId: "req-close-items", - }, - }), - ); - - await expect(responsePromise).resolves.toEqual({ - agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], - terminals: [{ terminalId: "term-1", success: true }], - requestId: "req-close-items", - }); - }); - - test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => { - vi.useFakeTimers(); - try { - 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 waitPromise = client.waitForFinish("agent-wait-zero-timeout", 0); - - expect(mock.sent).toHaveLength(1); - const request = JSON.parse(String(mock.sent[0])) as { - type: "session"; - message: { - type: "wait_for_finish_request"; - requestId: string; - agentId: string; - timeoutMs?: number; - }; - }; - expect(request.message.type).toBe("wait_for_finish_request"); - expect(request.message.agentId).toBe("agent-wait-zero-timeout"); - expect(request.message).not.toHaveProperty("timeoutMs"); - - const settled = vi.fn(); - void waitPromise.then( - () => settled("resolved"), - () => settled("rejected"), - ); - - await vi.advanceTimersByTimeAsync(5 * 60 * 1000); - expect(settled).not.toHaveBeenCalled(); - - mock.triggerMessage( - wrapSessionMessage({ - type: "wait_for_finish_response", - payload: { - requestId: request.message.requestId, - status: "idle", - final: null, - error: null, - lastMessage: null, - }, - }), - ); - - await expect(waitPromise).resolves.toEqual({ - status: "idle", - final: null, + ], error: null, - lastMessage: null, - }); - } finally { - vi.useRealTimers(); - } + }, + }), + ); + + unsubscribe(); + + expect(received).toHaveLength(0); + expect(logger.warn).toHaveBeenCalled(); +}); + +test("sends subscribe/unsubscribe terminals messages", 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; + + client.subscribeTerminals({ cwd: "/tmp/project" }); + client.unsubscribeTerminals({ cwd: "/tmp/project" }); + + expect(mock.sent).toHaveLength(2); + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "subscribe_terminals_request", + cwd: "/tmp/project", + }, + }); + expect(JSON.parse(String(mock.sent[1]))).toEqual({ + type: "session", + message: { + type: "unsubscribe_terminals_request", + cwd: "/tmp/project", + }, }); }); + +test("dispatches terminals_changed events to typed listeners", 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 received: Array<{ cwd: string; names: string[] }> = []; + const unsubscribe = client.on("terminals_changed", (message) => { + received.push({ + cwd: message.payload.cwd, + names: message.payload.terminals.map((terminal) => terminal.name), + }); + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + cwd: "/tmp/project", + terminals: [ + { + id: "term-1", + name: "Dev Server", + }, + ], + }, + }), + ); + + unsubscribe(); + + expect(received).toEqual([ + { + cwd: "/tmp/project", + names: ["Dev Server"], + }, + ]); +}); + +test("sends close_items_request and resolves close_items_response", 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 responsePromise = client.closeItems( + { + agentIds: ["agent-1"], + terminalIds: ["term-1"], + }, + "req-close-items", + ); + + expect(JSON.parse(String(mock.sent[0]))).toEqual({ + type: "session", + message: { + type: "close_items_request", + agentIds: ["agent-1"], + terminalIds: ["term-1"], + requestId: "req-close-items", + }, + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "close_items_response", + payload: { + agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }, + }), + ); + + await expect(responsePromise).resolves.toEqual({ + agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }); +}); + +test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => { + vi.useFakeTimers(); + try { + 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 waitPromise = client.waitForFinish("agent-wait-zero-timeout", 0); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(String(mock.sent[0])) as { + type: "session"; + message: { + type: "wait_for_finish_request"; + requestId: string; + agentId: string; + timeoutMs?: number; + }; + }; + expect(request.message.type).toBe("wait_for_finish_request"); + expect(request.message.agentId).toBe("agent-wait-zero-timeout"); + expect(request.message).not.toHaveProperty("timeoutMs"); + + const settled = vi.fn(); + void waitPromise.then( + () => settled("resolved"), + () => settled("rejected"), + ); + + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + expect(settled).not.toHaveBeenCalled(); + + mock.triggerMessage( + wrapSessionMessage({ + type: "wait_for_finish_response", + payload: { + requestId: request.message.requestId, + status: "idle", + final: null, + error: null, + lastMessage: null, + }, + }), + ); + + await expect(waitPromise).resolves.toEqual({ + status: "idle", + final: null, + error: null, + lastMessage: null, + }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 9f02765e0f..37290369f7 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -295,4519 +295,4517 @@ class StreamingAssistantClient implements AgentClient { } } -describe("AgentManager", () => { - const logger = createTestLogger(); +const logger = createTestLogger(); - test("normalizeConfig injects the provider default model when omitted", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000101", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - expect(snapshot.config.model).toBe("gpt-5.4"); - expect(snapshot.config.modeId).toBe("auto"); +test("normalizeConfig injects the provider default model when omitted", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000101", }); - test("normalizeConfig strips legacy 'default' model id", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000102", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - model: "default", - }); - - expect(snapshot.config.model).toBe("gpt-5.4"); - expect(snapshot.config.modeId).toBe("auto"); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, }); - test("setAgentMode persists the selected mode across session reload", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); + expect(snapshot.config.model).toBe("gpt-5.4"); + expect(snapshot.config.modeId).toBe("auto"); +}); - class ModeAwareSession implements AgentSession { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - private currentMode: string | null; +test("normalizeConfig strips legacy 'default' model id", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000102", + }); - constructor(private readonly config: AgentSessionConfig) { - this.currentMode = config.modeId ?? null; - } + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + model: "default", + }); - async run(): Promise { - return { sessionId: this.id, finalText: "", timeline: [] }; - } + expect(snapshot.config.model).toBe("gpt-5.4"); + expect(snapshot.config.modeId).toBe("auto"); +}); - async startTurn(): Promise<{ turnId: string }> { - return { turnId: "turn-1" }; - } +test("setAgentMode persists the selected mode across session reload", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); - subscribe(): () => void { - return () => {}; - } + class ModeAwareSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private currentMode: string | null; - async *streamHistory(): AsyncGenerator {} - - async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.id, - model: this.config.model ?? null, - modeId: this.currentMode, - }; - } - - async getAvailableModes() { - return []; - } - - async getCurrentMode() { - return this.currentMode; - } - - async setMode(modeId: string): Promise { - this.currentMode = modeId; - } - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence() { - return { provider: this.provider, sessionId: this.id }; - } - - async interrupt(): Promise {} - async close(): Promise {} + constructor(private readonly config: AgentSessionConfig) { + this.currentMode = config.modeId ?? null; } - class ModeAwareClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new ModeAwareSession(config); - } - - async resumeSession( - _handle: AgentPersistenceHandle, - config?: Partial, - ): Promise { - return new ModeAwareSession({ - provider: "codex", - cwd: config?.cwd ?? workdir, - modeId: config?.modeId, - model: config?.model, - }); - } - - async listModels() { - return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }]; - } + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; } - const manager = new AgentManager({ - clients: { - codex: new ModeAwareClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000301", + async startTurn(): Promise<{ turnId: string }> { + return { turnId: "turn-1" }; + } + + subscribe(): () => void { + return () => {}; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? null, + modeId: this.currentMode, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return this.currentMode; + } + + async setMode(modeId: string): Promise { + this.currentMode = modeId; + } + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise {} + async close(): Promise {} + } + + class ModeAwareClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ModeAwareSession(config); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new ModeAwareSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + modeId: config?.modeId, + model: config?.model, + }); + } + + async listModels() { + return [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4", isDefault: true }]; + } + } + + const manager = new AgentManager({ + clients: { + codex: new ModeAwareClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000301", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + modeId: "auto", + }); + + await manager.setAgentMode(snapshot.id, "full-access"); + + const beforeReload = manager.getAgent(snapshot.id); + expect(beforeReload?.config.modeId).toBe("full-access"); + expect(beforeReload?.currentModeId).toBe("full-access"); + + const reloaded = await manager.reloadAgentSession(snapshot.id); + expect(reloaded.config.modeId).toBe("full-access"); + expect(reloaded.currentModeId).toBe("full-access"); +}); + +test("reloadAgentSession completes when the previous session close hangs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HangingCloseSession extends TestAgentSession { + closeCalled = false; + + override async close(): Promise { + this.closeCalled = true; + await new Promise(() => {}); + } + } + + class HangingCloseClient extends TestAgentClient { + readonly firstSession = new HangingCloseSession({ + provider: "codex", + cwd: workdir, }); + resumeSessionCalls = 0; + override async createSession(): Promise { + return this.firstSession; + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + + const client = new HangingCloseClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000302", + }); + + try { const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir, - modeId: "auto", }); - await manager.setAgentMode(snapshot.id, "full-access"); - - const beforeReload = manager.getAgent(snapshot.id); - expect(beforeReload?.config.modeId).toBe("full-access"); - expect(beforeReload?.currentModeId).toBe("full-access"); - const reloaded = await manager.reloadAgentSession(snapshot.id); - expect(reloaded.config.modeId).toBe("full-access"); - expect(reloaded.currentModeId).toBe("full-access"); - }); - test("reloadAgentSession completes when the previous session close hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingCloseSession extends TestAgentSession { - closeCalled = false; - - override async close(): Promise { - this.closeCalled = true; - await new Promise(() => {}); - } - } - - class HangingCloseClient extends TestAgentClient { - readonly firstSession = new HangingCloseSession({ - provider: "codex", - cwd: workdir, - }); - resumeSessionCalls = 0; - - override async createSession(): Promise { - return this.firstSession; - } - - override async resumeSession( - _handle: AgentPersistenceHandle, - config?: Partial, - ): Promise { - this.resumeSessionCalls += 1; - return new TestAgentSession({ - provider: "codex", - cwd: config?.cwd ?? workdir, - }); - } - } - - const client = new HangingCloseClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { reloadSessionCloseMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000302", - }); - - try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const reloaded = await manager.reloadAgentSession(snapshot.id); - - expect(reloaded.id).toBe(snapshot.id); - expect(client.firstSession.closeCalled).toBe(true); - expect(client.resumeSessionCalls).toBe(1); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("cancelAgentRun completes when provider interrupt hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-interrupt-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingInterruptSession extends TestAgentSession { - interruptCalled = false; - - override async interrupt(): Promise { - this.interruptCalled = true; - await new Promise(() => {}); - } - } - - class HangingInterruptClient extends TestAgentClient { - readonly session = new HangingInterruptSession({ - provider: "codex", - cwd: workdir, - }); - - override async createSession(): Promise { - return this.session; - } - } - - const client = new HangingInterruptClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { interruptSessionMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000303", - }); - - try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await new Promise((resolve) => { - const unsubscribe = manager.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === snapshot.id && - event.agent.lifecycle === "running" - ) { - unsubscribe(); - resolve(); - } - }, - { agentId: snapshot.id, replayState: false }, - ); - client.session.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "hanging-interrupt-turn", - }); - }); - - await expect(manager.cancelAgentRun(snapshot.id)).resolves.toBe(true); - expect(client.session.interruptCalled).toBe(true); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("listProviderAvailability uses registered client keys, including custom providers", async () => { - const customClient: AgentClient = { - provider: "zai", - capabilities: TEST_CAPABILITIES, - async isAvailable() { - return true; - }, - async createSession() { - throw new Error("not implemented"); - }, - async resumeSession() { - throw new Error("not implemented"); - }, - }; - - const manager = new AgentManager({ - clients: { - zai: customClient, - }, - logger, - }); - - await expect(manager.listProviderAvailability()).resolves.toEqual([ - { - provider: "zai", - available: true, - error: null, - }, - ]); - }); - - test("createAgent passes daemon launch env through the provider launch context", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class CaptureClient extends TestAgentClient { - lastConfig: AgentSessionConfig | null = null; - lastLaunchContext: AgentLaunchContext | undefined; - - override async createSession( - config: AgentSessionConfig, - launchContext?: AgentLaunchContext, - ): Promise { - this.lastConfig = config; - this.lastLaunchContext = launchContext; - return new TestAgentSession(config); - } - } - - const client = new CaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000103", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - expect(client.lastConfig).toEqual({ - provider: "codex", - cwd: workdir, - model: "gpt-5.4", - modeId: "auto", - }); - expect(client.lastLaunchContext).toEqual({ - env: { - PASEO_AGENT_ID: snapshot.id, - }, - }); - }); - - test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class CaptureClient extends TestAgentClient { - lastConfig: AgentSessionConfig | null = null; - - override async createSession(config: AgentSessionConfig): Promise { - this.lastConfig = config; - return new TestAgentSession(config); - } - } - - const client = new CaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - idFactory: () => "00000000-0000-4000-8000-000000000103", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - mcpServers: { - custom: { - type: "stdio", - command: "custom-mcp", - }, - }, - }); - - expect(snapshot.config.mcpServers).toEqual({ - paseo: { - type: "http", - url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, - }, - custom: { - type: "stdio", - command: "custom-mcp", - }, - }); - expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); - }); - - test("createAgent preserves a user-provided paseo MCP config", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class CaptureClient extends TestAgentClient { - lastConfig: AgentSessionConfig | null = null; - - override async createSession(config: AgentSessionConfig): Promise { - this.lastConfig = config; - return new TestAgentSession(config); - } - } - - const client = new CaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", - idFactory: () => "00000000-0000-4000-8000-000000000104", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - mcpServers: { - paseo: { - type: "http", - url: "https://example.com/custom-paseo", - }, - }, - }); - - expect(snapshot.config.mcpServers).toEqual({ - paseo: { - type: "http", - url: "https://example.com/custom-paseo", - }, - }); - expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); - }); - - test("createAgent fails when cwd does not exist", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - }); - - await expect( - manager.createAgent({ - provider: "codex", - cwd: join(workdir, "does-not-exist"), - }), - ).rejects.toThrow("Working directory does not exist"); - }); - - test("resumeAgentFromPersistence keeps metadata config, applies overrides, and passes launch env", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class ResumeCaptureClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - lastResumeOverrides: Partial | undefined; - lastResumeLaunchContext: AgentLaunchContext | undefined; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new TestAgentSession(config); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - launchContext?: AgentLaunchContext, - ): Promise { - this.lastResumeOverrides = overrides; - this.lastResumeLaunchContext = launchContext; - const metadata = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }; - return new TestAgentSession(merged); - } - } - - const client = new ResumeCaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000106", - }); - - const handle: AgentPersistenceHandle = { - provider: "codex", - sessionId: "resume-session-1", - metadata: { - provider: "codex", - cwd: workdir, - systemPrompt: "old prompt", - mcpServers: { - legacy: { - type: "stdio", - command: "legacy-bridge", - args: ["/tmp/legacy.sock"], - }, - }, - }, - }; - - const resumed = await manager.resumeAgentFromPersistence(handle, { - cwd: workdir, - systemPrompt: "new prompt", - mcpServers: { - paseo: { - type: "stdio", - command: "node", - args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], - }, - }, - }); - - expect(resumed.config.systemPrompt).toBe("new prompt"); - expect(resumed.config.mcpServers).toEqual({ - paseo: { - type: "stdio", - command: "node", - args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], - }, - }); - expect(client.lastResumeOverrides).toMatchObject({ - systemPrompt: "new prompt", - mcpServers: { - paseo: { - type: "stdio", - command: "node", - args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], - }, - }, - }); - expect(client.lastResumeLaunchContext).toEqual({ - env: { - PASEO_AGENT_ID: resumed.id, - }, - }); - }); - - test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-context-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class ReloadCaptureClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - lastCreateLaunchContext: AgentLaunchContext | undefined; - lastResumeLaunchContext: AgentLaunchContext | undefined; - - async isAvailable(): Promise { - return true; - } - - async createSession( - config: AgentSessionConfig, - launchContext?: AgentLaunchContext, - ): Promise { - this.lastCreateLaunchContext = launchContext; - return new TestAgentSession(config); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - launchContext?: AgentLaunchContext, - ): Promise { - this.lastResumeLaunchContext = launchContext; - const metadata = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }; - return new TestAgentSession(merged); - } - } - - const client = new ReloadCaptureClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000108", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - expect(client.lastCreateLaunchContext).toEqual({ - env: { - PASEO_AGENT_ID: snapshot.id, - }, - }); - - await manager.reloadAgentSession(snapshot.id, { - systemPrompt: "reloaded prompt", - }); - - expect(client.lastResumeLaunchContext).toEqual({ - env: { - PASEO_AGENT_ID: snapshot.id, - }, - }); - }); - - test("reloadAgentSession preserves timeline and does not force history replay", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HistoryProbeSession extends TestAgentSession { - constructor( - config: AgentSessionConfig, - private readonly historyText: string | null, - ) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - if (!this.historyText) { - return; - } - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: this.historyText }, - }; - } - } - - class HistoryProbeClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new HistoryProbeSession(config, null); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - ): Promise { - const metadata = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }; - return new HistoryProbeSession(merged, "history replay from provider"); - } - } - - const manager = new AgentManager({ - clients: { - codex: new HistoryProbeClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000113", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "keep this timeline in memory", - }); - await manager.hydrateTimelineFromProvider(snapshot.id); - const beforeReload = manager.getTimeline(snapshot.id); - expect(beforeReload).toHaveLength(1); - - await manager.reloadAgentSession(snapshot.id, { - systemPrompt: "reloaded prompt", - }); - const afterReload = manager.getTimeline(snapshot.id); - expect(afterReload).toEqual(beforeReload); - - // If reload resets historyPrimed, this would replay provider history and append another item. - await manager.hydrateTimelineFromProvider(snapshot.id); - const afterHydrate = manager.getTimeline(snapshot.id); - expect(afterHydrate).toEqual(beforeReload); - }); - - test("reloadAgentSession preserves current title when config title is unset", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000126", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - await manager.setTitle(snapshot.id, "Generated title"); - - const beforeReload = await storage.get(snapshot.id); - expect(beforeReload?.title).toBe("Generated title"); - expect(beforeReload?.config?.title).toBeUndefined(); - - await manager.reloadAgentSession(snapshot.id); - - const afterReload = await storage.get(snapshot.id); - expect(afterReload?.title).toBe("Generated title"); - expect(afterReload?.config?.title).toBeUndefined(); - }); - - test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000127", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const before = await storage.get(snapshot.id); - expect(before).not.toBeNull(); - - await manager.setTitle(snapshot.id, "Generated title"); - - const after = await storage.get(snapshot.id); - expect(after?.title).toBe("Generated title"); - expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); - - const live = manager.getAgent(snapshot.id); - expect(live).not.toBeNull(); - expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); - }); - - test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000132", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "plan", - model: "gpt-5.2-codex", - thinkingOptionId: "low", - }); - - await manager.setAgentMode(snapshot.id, "build"); - await manager.setAgentModel(snapshot.id, "gpt-5.4"); - await manager.setAgentThinkingOption(snapshot.id, "high"); - await manager.flush(); - - const persisted = await storage.get(snapshot.id); - expect(persisted).not.toBeNull(); - expect(persisted?.lastModeId).toBe("build"); - expect(persisted?.config?.model).toBe("gpt-5.4"); - expect(persisted?.config?.thinkingOptionId).toBe("high"); - expect(persisted?.runtimeInfo?.modeId).toBe("build"); - expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4"); - }); - - test("setLabels merges and persists labels", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000133", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Label test", - }); - - await manager.setLabels(snapshot.id, { surface: "mobile" }); - await manager.setLabels(snapshot.id, { phase: "1a" }); - - const persisted = await storage.get(snapshot.id); - expect(persisted?.labels).toEqual({ - surface: "mobile", - phase: "1a", - }); - }); - - test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000134", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Finished attention test", - }); - - await manager.runAgent(snapshot.id, "say hello"); - await manager.flush(); - - const persisted = await storage.get(snapshot.id); - expect(persisted?.lastStatus).toBe("idle"); - expect(persisted?.requiresAttention).toBe(true); - expect(persisted?.attentionReason).toBe("finished"); - expect(persisted?.attentionTimestamp).toEqual(expect.any(String)); - }); - - test("archiveSnapshot clears persisted attention and normalizes running status", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-attention-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000135", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Archive attention test", - }); - - const live = manager.getAgent(snapshot.id); - expect(live).not.toBeNull(); - live!.lifecycle = "running"; - live!.attention = { - requiresAttention: true, - attentionReason: "finished", - attentionTimestamp: new Date("2025-01-02T00:00:00.000Z"), - }; - - const archivedAt = "2025-01-03T00:00:00.000Z"; - const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); - - expect(archivedRecord.archivedAt).toBe(archivedAt); - expect(archivedRecord.lastStatus).toBe("idle"); - expect(archivedRecord.requiresAttention).toBe(false); - expect(archivedRecord.attentionReason).toBeNull(); - expect(archivedRecord.attentionTimestamp).toBeNull(); - - const persisted = await storage.get(snapshot.id); - expect(persisted?.archivedAt).toBe(archivedAt); - expect(persisted?.lastStatus).toBe("idle"); - expect(persisted?.requiresAttention).toBe(false); - expect(persisted?.attentionReason).toBeNull(); - expect(persisted?.attentionTimestamp).toBeNull(); - }); - - test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class DelayedPersistenceSession extends TestAgentSession { - private persistenceReady = false; - private delayedInterrupted = false; - private releaseGate: (() => void) | null = null; - private readonly gate = new Promise((resolve) => { - this.releaseGate = resolve; - }); - private activeTurnId: string | null = null; - - constructor( - config: AgentSessionConfig, - private readonly stableSessionId: string, - initiallyReady = false, - ) { - super(config); - this.persistenceReady = initiallyReady; - } - - override async startTurn(): Promise<{ turnId: string }> { - this.delayedInterrupted = false; - const turnId = `delayed-turn-${Date.now()}`; - this.activeTurnId = turnId; - // Push turn_started, then thread_started, then wait on gate - setTimeout(async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.persistenceReady = true; - this.pushEvent({ - type: "thread_started", - provider: this.provider, - sessionId: this.stableSessionId, - }); - await this.gate; - if (this.delayedInterrupted) { - this.pushEvent({ - type: "turn_canceled", - provider: this.provider, - reason: "Interrupted", - turnId, - }); - } else { - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - } - }, 0); - return { turnId }; - } - - async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.persistenceReady ? this.stableSessionId : null, - model: null, - modeId: null, - }; - } - - describePersistence() { - if (!this.persistenceReady) { - return null; - } - return { - provider: this.provider, - sessionId: this.stableSessionId, - }; - } - - override async interrupt(): Promise { - this.delayedInterrupted = true; - this.releaseGate?.(); - } - - async close(): Promise { - this.delayedInterrupted = true; - this.releaseGate?.(); - } - } - - class DelayedPersistenceClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - createSessionCalls = 0; - resumeSessionCalls = 0; - private nextSessionNumber = 1; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - const sessionId = `delayed-session-${this.nextSessionNumber++}`; - this.createSessionCalls += 1; - return new DelayedPersistenceSession(config, sessionId); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - ): Promise { - this.resumeSessionCalls += 1; - const metadata = (handle.metadata ?? {}) as Partial; - const merged: AgentSessionConfig = { - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }; - return new DelayedPersistenceSession(merged, handle.sessionId, true); - } - } - - const client = new DelayedPersistenceClient(); - const manager = new AgentManager({ - clients: { codex: client }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000114", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - expect(snapshot.persistence).toBeNull(); - - const stream = manager.streamAgent(snapshot.id, "hello"); - const first = await stream.next(); - expect(first.done).toBe(false); - expect(first.value?.type).toBe("turn_started"); - - // Wait for the thread_started event to propagate through subscribe - // (it's a session-level event, not forwarded to the foreground stream) - await vi.waitFor(() => { - const active = manager.getAgent(snapshot.id); - expect(active?.persistence?.sessionId).toBe("delayed-session-1"); - }); - - const active = manager.getAgent(snapshot.id); - expect(active?.lifecycle).toBe("running"); - - const reloaded = await manager.reloadAgentSession(snapshot.id, { - systemPrompt: "voice mode on", - }); - - expect(client.createSessionCalls).toBe(1); + expect(reloaded.id).toBe(snapshot.id); + expect(client.firstSession.closeCalled).toBe(true); expect(client.resumeSessionCalls).toBe(1); - expect(reloaded.persistence?.sessionId).toBe("delayed-session-1"); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); - // Drain stream after cancellation to ensure clean shutdown. - while (true) { - const next = await stream.next(); - if (next.done) { - break; - } +test("cancelAgentRun completes when provider interrupt hangs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-interrupt-timeout-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HangingInterruptSession extends TestAgentSession { + interruptCalled = false; + + override async interrupt(): Promise { + this.interruptCalled = true; + await new Promise(() => {}); } - }); + } - test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000118", - }); - - const snapshot = await manager.createAgent({ + class HangingInterruptClient extends TestAgentClient { + readonly session = new HangingInterruptSession({ provider: "codex", cwd: workdir, }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "one", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "two", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "three", - }); - - const baseline = manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 2, - }); - expect(baseline.rows).toHaveLength(2); - - const result = manager.fetchTimeline(snapshot.id, { - direction: "after", - cursor: { - epoch: "stale-epoch", - seq: baseline.rows[baseline.rows.length - 1]!.seq, - }, - limit: 1, - }); - - expect(result.reset).toBe(true); - expect(result.staleCursor).toBe(true); - expect(result.gap).toBe(false); - expect(result.rows).toHaveLength(3); - expect(result.rows[0]?.seq).toBe(1); - expect(result.rows[result.rows.length - 1]?.seq).toBe(3); - }); - - test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000140", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "row one", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "row two", - }); - - await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row one", - }, - }, - { - seq: 2, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row two", - }, - }, - ]); - }); - - test("getAgent does not expose committed history internals once manager owns the seam", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-boundary-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000138", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - manager.recordUserMessage(snapshot.id, "hello boundary", { - messageId: "msg-boundary-1", - emitState: false, - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "history stays behind manager", - }); - - const live = manager.getAgent(snapshot.id) as Record; - expect(live).not.toBeNull(); - expect("timeline" in live).toBe(false); - expect("timelineRows" in live).toBe(false); - expect("timelineNextSeq" in live).toBe(false); - - expect(manager.getTimeline(snapshot.id)).toEqual([ - { - type: "user_message", - text: "hello boundary", - messageId: "msg-boundary-1", - }, - { - type: "assistant_message", - text: "history stays behind manager", - }, - ]); - - const fetched = await manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }); - expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); - }); - - test("coalesces assistant chunks and persists the canonical row", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new StreamingAssistantClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000120", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const streamEvents: Array<{ - seq?: number; - epoch?: string; - eventType?: string; - itemType?: string; - text?: string; - }> = []; - manager.subscribe( - (event) => { - if (event.type !== "agent_stream") { - return; - } - streamEvents.push({ - seq: event.seq, - epoch: event.epoch, - eventType: event.event.type, - itemType: event.event.type === "timeline" ? event.event.item.type : undefined, - text: - event.event.type === "timeline" && event.event.item.type === "assistant_message" - ? event.event.item.text - : undefined, - }); - }, - { agentId: snapshot.id, replayState: false }, - ); - - const stream = manager.streamAgent(snapshot.id, "hello"); - while (true) { - const next = await stream.next(); - if (next.done) { - break; - } + override async createSession(): Promise { + return this.session; } + } - const assistantTimelineEvents = streamEvents.filter( - (event) => event.itemType === "assistant_message", - ); - expect(assistantTimelineEvents).toHaveLength(1); - expect(assistantTimelineEvents[0]).toMatchObject({ - eventType: "timeline", - itemType: "assistant_message", - text: "final reply", - seq: 1, - epoch: expect.any(String), - }); - - expect(manager.getTimeline(snapshot.id)).toEqual([ - { - type: "assistant_message", - text: "final reply", - }, - ]); - const fetched = await manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }); - expect(fetched.rows).toHaveLength(1); - expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); - expect(fetched.rows[0]?.item).toEqual({ - type: "assistant_message", - text: "final reply", - }); + const client = new HangingInterruptClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + rescueTimeouts: { interruptSessionMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000303", }); - test("fetchTimeline supports older-history pagination with before seq", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-before-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000119", - }); - + try { const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir, }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "first", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "second", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "third", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "fourth", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "fifth", - }); - - const result = await manager.fetchTimeline(snapshot.id, { - direction: "before", - cursor: { - seq: 5, - }, - limit: 2, - }); - - expect(result.rows).toHaveLength(2); - expect(result.rows[0]?.seq).toBe(3); - expect(result.rows[1]?.seq).toBe(4); - expect(result.hasOlder).toBe(true); - expect(result.hasNewer).toBe(true); - }); - - test("does not trim committed history", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-unbounded-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000120", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "first", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "second", - }); - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "third", - }); - - const fetched = await manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }); - expect(fetched.rows).toHaveLength(3); - expect(fetched.window.minSeq).toBe(1); - expect(fetched.window.maxSeq).toBe(3); - }); - - test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline history", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class ChunkedAssistantHistorySession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "chunk one " }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "chunk two" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "reasoning", text: "internal" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { - type: "tool_call", - callId: "call-history-1", - name: "shell", - status: "completed", - detail: { - type: "shell", - command: "echo hi", - output: "hi\n", - exitCode: 0, - }, - error: null, - }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "final answer" }, - }; - } - } - - class ChunkedAssistantHistoryClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new ChunkedAssistantHistorySession(config); - } - - async resumeSession(): Promise { - throw new Error("Not used in this test"); - } - } - - const manager = new AgentManager({ - clients: { - codex: new ChunkedAssistantHistoryClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000121", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.hydrateTimelineFromProvider(snapshot.id); - - expect(manager.getTimeline(snapshot.id)).toEqual([ - { type: "assistant_message", text: "chunk one " }, - { type: "assistant_message", text: "chunk two" }, - { type: "reasoning", text: "internal" }, - { - type: "tool_call", - callId: "call-history-1", - name: "shell", - status: "completed", - detail: { - type: "shell", - command: "echo hi", - output: "hi\n", - exitCode: 0, - }, - error: null, - }, - { type: "assistant_message", text: "final answer" }, - ]); - }); - - test("hydrateTimeline preserves reasoning between assistant chunks", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class ReasoningInterleavedHistorySession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "before reasoning " }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "reasoning", text: "internal step" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "after reasoning" }, - }; - } - } - - class ReasoningInterleavedHistoryClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new ReasoningInterleavedHistorySession(config); - } - - async resumeSession(): Promise { - throw new Error("Not used in this test"); - } - } - - const manager = new AgentManager({ - clients: { - codex: new ReasoningInterleavedHistoryClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000122", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.hydrateTimelineFromProvider(snapshot.id); - - expect(manager.getTimeline(snapshot.id)).toEqual([ - { - type: "assistant_message", - text: "before reasoning ", - }, - { type: "reasoning", text: "internal step" }, - { type: "assistant_message", text: "after reasoning" }, - ]); - }); - - test("createAgent fails when generated agent ID is not a UUID", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "not-a-uuid", - }); - - await expect( - manager.createAgent({ - provider: "codex", - cwd: workdir, - }), - ).rejects.toThrow("createAgent: agentId must be a UUID"); - }); - - test("createAgent fails when explicit agent ID is not a UUID", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - }); - - await expect( - manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - "not-a-uuid", - ), - ).rejects.toThrow("createAgent: agentId must be a UUID"); - }); - - test("createAgent persists provided title before returning", async () => { - const agentId = "00000000-0000-4000-8000-000000000102"; - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => agentId, - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Fix Login Bug", - }); - - expect(snapshot.id).toBe(agentId); - expect(snapshot.lifecycle).toBe("idle"); - - const persisted = await storage.get(agentId); - expect(persisted?.title).toBe("Fix Login Bug"); - expect(persisted?.id).toBe(agentId); - }); - - test("createAgent populates runtimeInfo after session creation", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000103", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - model: "gpt-5.2-codex", - modeId: "full-access", - }); - - expect(snapshot.runtimeInfo).toBeDefined(); - expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex"); - expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.persistence?.sessionId); - }); - - test("runAgent refreshes runtimeInfo after completion", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000104", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4"); - - await manager.runAgent(snapshot.id, "hello"); - - const refreshed = manager.getAgent(snapshot.id); - expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex"); - }); - - test("waitForAgentEvent does not resolve idle until foreground turn is finalized", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const releaseTurnCompleted = deferred(); - - class SlowTerminalSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - this.interrupted = false; - const turnId = `turn-${++this.turnIdCounter}`; - void (async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - await releaseTurnCompleted.promise; - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - })(); - return { turnId }; - } - } - - class SlowTerminalClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new SlowTerminalSession(config); - } - } - - const manager = new AgentManager({ - clients: { - codex: new SlowTerminalClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000124", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const stream = manager.streamAgent(snapshot.id, "hello"); - const consumePromise = (async () => { - for await (const _event of stream) { - // Drain events so manager lifecycle progresses naturally. - } - })(); - - // Wait for the turn to start - await new Promise((resolve) => setTimeout(resolve, 20)); - - const waitPromise = manager.waitForAgentEvent(snapshot.id); - - // Should still be pending because turn_completed hasn't arrived - const earlyResolution = await Promise.race([ - waitPromise.then(() => "resolved"), - new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), - ]); - expect(earlyResolution).toBe("pending"); - - // Release the turn_completed event - releaseTurnCompleted.resolve(); - const waited = await waitPromise; - expect(waited.status).toBe("idle"); - - await consumePromise; - }); - - test("waitForAgentRunStart resolves while a foreground run is still only pending", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fast-start-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000124", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const run = manager.streamAgent(snapshot.id, "fast"); - const drainRun = (async () => { - for await (const _event of run) { - // Drain the fast foreground turn. - } - })(); - - await expect(manager.waitForAgentRunStart(snapshot.id)).resolves.toBeUndefined(); - - await drainRun; - expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); - }); - - test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const allowFirstRunToEnd = deferred(); - const allowSecondRunToEnd = deferred(); - - class ReplaceRunSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - this.interrupted = false; - const turnId = `turn-${++this.turnIdCounter}`; - const turnNum = this.turnIdCounter; - - void (async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - if (turnNum === 1) { - await allowFirstRunToEnd.promise; - this.pushEvent({ - type: "turn_canceled", - provider: this.provider, - reason: "interrupted", - turnId, - }); - } else { - await allowSecondRunToEnd.promise; - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - } - })(); - return { turnId }; - } - - override async interrupt(): Promise { - this.interrupted = true; - allowFirstRunToEnd.resolve(); - } - } - - class ReplaceRunClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new ReplaceRunSession(config); - } - } - - const manager = new AgentManager({ - clients: { - codex: new ReplaceRunClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000125", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const lifecycleUpdates: string[] = []; - const unsubscribe = manager.subscribe( - (event) => { - if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { - return; - } - lifecycleUpdates.push(event.agent.lifecycle); - }, - { agentId: snapshot.id, replayState: false }, - ); - - const firstRun = manager.streamAgent(snapshot.id, "first run"); - const firstRunDrain = (async () => { - for await (const _event of firstRun) { - // Drain events so lifecycle updates are applied. - } - })(); - - await manager.waitForAgentRunStart(snapshot.id); - - const waitPromise = manager.waitForAgentEvent(snapshot.id); - const secondRun = manager.replaceAgentRun(snapshot.id, "second run"); - const secondRunDrain = (async () => { - for await (const _event of secondRun) { - // Drain replacement run. - } - })(); - - await manager.waitForAgentRunStart(snapshot.id); - - const prematureResolution = await Promise.race([ - waitPromise.then(() => "resolved"), - new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), - ]); - expect(prematureResolution).toBe("pending"); - - const runningIndexes = lifecycleUpdates.reduce((indexes, status, index) => { - if (status === "running") { - indexes.push(index); - } - return indexes; - }, []); - expect(runningIndexes.length).toBeGreaterThanOrEqual(2); - - const firstReplacementRunningIndex = runningIndexes[1]!; - expect(lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")).toBe(false); - - allowSecondRunToEnd.resolve(); - - const waited = await waitPromise; - expect(waited.status).toBe("idle"); - - await firstRunDrain; - await secondRunDrain; - unsubscribe(); - }); - - test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const secondStartEntered = deferred(); - const interruptStarted = deferred(); - const allowInterruptToFinish = deferred(); - const allowSecondStartToResolve = deferred(); - let capturedSession: StaleReplacementSession | null = null; - - class StaleReplacementSession extends TestAgentSession { - private localTurnCounter = 0; - - override async startTurn(): Promise<{ turnId: string }> { - const turnId = `turn-${++this.localTurnCounter}`; - const turnNum = this.localTurnCounter; - - if (turnNum === 1) { - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - - secondStartEntered.resolve(); - await allowSecondStartToResolve.promise; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - - override async interrupt(): Promise { - interruptStarted.resolve(); - await allowInterruptToFinish.promise; - this.pushEvent({ - type: "turn_canceled", - provider: this.provider, - reason: "Interrupted", - turnId: "turn-1", - }); - } - } - - class StaleReplacementClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - capturedSession = new StaleReplacementSession(config); - return capturedSession; - } - } - - const manager = new AgentManager({ - clients: { - codex: new StaleReplacementClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000126", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const stateUpdates: Array<{ lifecycle: string; updatedAt: number }> = []; - const unsubscribe = manager.subscribe( - (event) => { - if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { - return; - } - stateUpdates.push({ - lifecycle: event.agent.lifecycle, - updatedAt: event.agent.updatedAt.getTime(), - }); - }, - { agentId: snapshot.id, replayState: false }, - ); - - const firstRun = manager.streamAgent(snapshot.id, "first run"); - const firstRunDrain = (async () => { - for await (const _event of firstRun) { - // Drain events so lifecycle updates are applied. - } - })(); - - await manager.waitForAgentRunStart(snapshot.id); - - const replaceUpdatesStart = stateUpdates.length; - const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0; - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement run"); - const secondRunDrain = (async () => { - for await (const _event of secondRun) { - // Drain replacement run. - } - })(); - - await interruptStarted.promise; - const replacementUpdates = stateUpdates.slice(replaceUpdatesStart); - expect( - replacementUpdates.some( - (update) => update.lifecycle === "running" && update.updatedAt > beforeReplaceUpdatedAt, - ), - ).toBe(true); - expect(replacementUpdates.map((update) => update.lifecycle)).not.toContain("idle"); - allowInterruptToFinish.resolve(); - - await secondStartEntered.promise; - - const replaceGapSnapshot = manager.getAgent(snapshot.id) as - | { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string } - | undefined; - expect(replaceGapSnapshot?.pendingReplacement).toBe(false); - expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull(); - expect(replaceGapSnapshot?.lifecycle).toBe("running"); - - capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("running"); - expect(stateUpdates.at(-1)?.lifecycle).toBe("running"); - expect(stateUpdates.slice(replaceUpdatesStart).map((update) => update.lifecycle)).not.toContain( - "idle", - ); - - allowSecondStartToResolve.resolve(); - - await manager.waitForAgentRunStart(snapshot.id); - await firstRunDrain; - await secondRunDrain; - unsubscribe(); - }); - - test("applies live autonomous events while no foreground run is active", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - let capturedSession: TestAgentSession | null = null; - - class LiveEventClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - capturedSession = new TestAgentSession(config); - return capturedSession; - } - } - - const manager = new AgentManager({ - clients: { - codex: new LiveEventClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000125", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const lifecycleUpdates: string[] = []; - let sawRunningState = false; - let resolveSettled!: () => void; - const settled = new Promise((resolve) => { - resolveSettled = resolve; - }); - manager.subscribe( - (event) => { - if (event.type === "agent_state" && event.agent.id === snapshot.id) { - lifecycleUpdates.push(event.agent.lifecycle); - if (event.agent.lifecycle === "running") { - sawRunningState = true; - } - if (sawRunningState && event.agent.lifecycle === "idle") { - resolveSettled(); - } - } - }, - { agentId: snapshot.id, replayState: false }, - ); - - // Push autonomous events through the session's subscribe() callbacks - const autonomousTurnId = "autonomous-turn-1"; - capturedSession!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" }, - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "turn_completed", - provider: "codex", - turnId: autonomousTurnId, - }); - await settled; - - const updated = manager.getAgent(snapshot.id); - expect(updated?.lifecycle).toBe("idle"); - expect(manager.getTimeline(snapshot.id)).toContainEqual({ - type: "assistant_message", - text: "AUTONOMOUS_PUMP_MESSAGE", - }); - expect(lifecycleUpdates).toContain("running"); - expect(lifecycleUpdates).toContain("idle"); - }); - - test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class LiveInterruptSession extends TestAgentSession { - public interruptCount = 0; - - override async interrupt(): Promise { - this.interruptCount += 1; - } - } - - class LiveInterruptClient extends TestAgentClient { - lastSession: LiveInterruptSession | null = null; - - override async createSession(config: AgentSessionConfig): Promise { - const session = new LiveInterruptSession(config); - this.lastSession = session; - return session; - } - } - - const client = new LiveInterruptClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000129", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const capturedSession = client.lastSession!; - await new Promise((resolve) => { const unsubscribe = manager.subscribe( - (event) => { - if (event.type !== "agent_state") { - return; - } - if (event.agent.id !== snapshot.id) { - return; - } - if (event.agent.lifecycle !== "running") { - return; - } - unsubscribe(); - resolve(); - }, - { agentId: snapshot.id, replayState: false }, - ); - capturedSession.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: "autonomous-cancel-1", - }); - }); - - const beforeCancel = manager.getAgent(snapshot.id); - expect(beforeCancel?.lifecycle).toBe("running"); - expect(beforeCancel?.activeForegroundTurnId).toBeNull(); - - const cancelled = await manager.cancelAgentRun(snapshot.id); - expect(cancelled).toBe(true); - expect(client.lastSession?.interruptCount).toBe(1); - }); - - test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - let capturedSession: TestAgentSession | null = null; - - class LiveEventClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - const session = new TestAgentSession(config); - capturedSession = session; - return session; - } - } - - const manager = new AgentManager({ - clients: { - codex: new LiveEventClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000126", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const autonomousTurnId = "autonomous-wait-1"; - const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true }); - capturedSession!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "turn_completed", - provider: "codex", - turnId: autonomousTurnId, - }); - - const result = await waitPromise; - expect(result.status).toBe("idle"); - }); - - test("autonomous events arriving during foreground run are processed via subscribe", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-during-fg-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const releaseForeground = deferred(); - - let capturedSession: TestAgentSession | null = null; - - class ForegroundSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "fg-turn-1"; - setTimeout(async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - await releaseForeground.promise; - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class ForegroundClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - const session = new ForegroundSession(config); - capturedSession = session; - return session; - } - } - - const manager = new AgentManager({ - clients: { - codex: new ForegroundClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000127", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const foreground = manager.streamAgent(snapshot.id, "foreground run"); - const foregroundResults = (async () => { - const events: AgentStreamEvent[] = []; - for await (const event of foreground) { - events.push(event); - } - return events; - })(); - - // Wait for the foreground turn to start (lifecycle -> running) - await new Promise((resolve) => { - const unsub = manager.subscribe( (event) => { if ( event.type === "agent_state" && event.agent.id === snapshot.id && event.agent.lifecycle === "running" ) { - unsub(); + unsubscribe(); resolve(); } }, - { agentId: snapshot.id, replayState: true }, - ); - }); - - // Push autonomous events while foreground is active - const autonomousTurnId = "autonomous-during-fg-1"; - capturedSession!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" }, - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "turn_completed", - provider: "codex", - turnId: autonomousTurnId, - }); - - releaseForeground.resolve(); - const foregroundEvents = await foregroundResults; - - // Foreground stream should contain its own turn events but NOT autonomous events - expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); - expect( - foregroundEvents.some( - (event) => - event.type === "timeline" && - event.item.type === "assistant_message" && - event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND"), - ), - ).toBe(false); - - // Autonomous timeline item should still be recorded in the agent timeline - expect(manager.getTimeline(snapshot.id)).toContainEqual({ - type: "assistant_message", - text: "AUTONOMOUS_DURING_FOREGROUND", - }); - }); - - test("subscribe error isolation: throwing subscriber does not break event flow", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-subscribe-isolation-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - let capturedSession: TestAgentSession | null = null; - - class IsolationClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - const session = new TestAgentSession(config); - capturedSession = session; - return session; - } - } - - const manager = new AgentManager({ - clients: { - codex: new IsolationClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000128", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const receivedEvents: string[] = []; - const settled = new Promise((resolve) => { - manager.subscribe( - (event) => { - if ( - event.type === "agent_state" && - event.agent.id === snapshot.id && - event.agent.lifecycle === "idle" - ) { - resolve(); - } - if (event.type === "agent_stream" && event.agentId === snapshot.id) { - receivedEvents.push(event.event.type); - } - }, { agentId: snapshot.id, replayState: false }, ); + client.session.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "hanging-interrupt-turn", + }); }); - const autonomousTurnId = "autonomous-isolation-1"; - capturedSession!.pushEvent({ - type: "turn_started", - provider: "codex", - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "timeline", - provider: "codex", - item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" }, - turnId: autonomousTurnId, - }); - capturedSession!.pushEvent({ - type: "turn_completed", - provider: "codex", - turnId: autonomousTurnId, - }); + await expect(manager.cancelAgentRun(snapshot.id)).resolves.toBe(true); + expect(client.session.interruptCalled).toBe(true); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); - await settled; +test("listProviderAvailability uses registered client keys, including custom providers", async () => { + const customClient: AgentClient = { + provider: "zai", + capabilities: TEST_CAPABILITIES, + async isAvailable() { + return true; + }, + async createSession() { + throw new Error("not implemented"); + }, + async resumeSession() { + throw new Error("not implemented"); + }, + }; - expect(receivedEvents).toContain("turn_started"); - expect(receivedEvents).toContain("timeline"); - expect(receivedEvents).toContain("turn_completed"); - expect(manager.getTimeline(snapshot.id)).toContainEqual({ - type: "assistant_message", - text: "EVENT_AFTER_ERROR", - }); + const manager = new AgentManager({ + clients: { + zai: customClient, + }, + logger, }); - test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000120", - }); + await expect(manager.listProviderAvailability()).resolves.toEqual([ + { + provider: "zai", + available: true, + error: null, + }, + ]); +}); - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); +test("createAgent passes daemon launch env through the provider launch context", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000); - try { - manager.recordUserMessage(snapshot.id, "hello"); - const afterMessage = manager.getAgent(snapshot.id); - expect(afterMessage).toBeDefined(); - const messageUpdatedAt = afterMessage!.updatedAt.getTime(); + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + lastLaunchContext: AgentLaunchContext | undefined; - const stream = manager.streamAgent(snapshot.id, "hello"); - // Advance the generator so startTurn runs and lifecycle transitions to running - await stream.next(); - const afterRunStart = manager.getAgent(snapshot.id); - expect(afterRunStart).toBeDefined(); - expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt); - - // Drain the rest of the stream - while (true) { - const next = await stream.next(); - if (next.done) break; - } - } finally { - nowSpy.mockRestore(); + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastConfig = config; + this.lastLaunchContext = launchContext; + return new TestAgentSession(config); } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000103", }); - test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000121", - }); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); - const snapshot = await manager.createAgent({ + expect(client.lastConfig).toEqual({ + provider: "codex", + cwd: workdir, + model: "gpt-5.4", + modeId: "auto", + }); + expect(client.lastLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); +}); + +test("createAgent injects paseo MCP server when manager has an MCP base URL", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + mcpServers: { + custom: { + type: "stdio", + command: "custom-mcp", + }, + }, + }); + + expect(snapshot.config.mcpServers).toEqual({ + paseo: { + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${snapshot.id}`, + }, + custom: { + type: "stdio", + command: "custom-mcp", + }, + }); + expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); +}); + +test("createAgent preserves a user-provided paseo MCP config", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + mcpServers: { + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, + }, + }); + + expect(snapshot.config.mcpServers).toEqual({ + paseo: { + type: "http", + url: "https://example.com/custom-paseo", + }, + }); + expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers); +}); + +test("createAgent fails when cwd does not exist", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + await expect( + manager.createAgent({ + provider: "codex", + cwd: join(workdir, "does-not-exist"), + }), + ).rejects.toThrow("Working directory does not exist"); +}); + +test("resumeAgentFromPersistence keeps metadata config, applies overrides, and passes launch env", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ResumeCaptureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + lastResumeOverrides: Partial | undefined; + lastResumeLaunchContext: AgentLaunchContext | undefined; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new TestAgentSession(config); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastResumeOverrides = overrides; + this.lastResumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new TestAgentSession(merged); + } + } + + const client = new ResumeCaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000106", + }); + + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "resume-session-1", + metadata: { provider: "codex", cwd: workdir, - }); + systemPrompt: "old prompt", + mcpServers: { + legacy: { + type: "stdio", + command: "legacy-bridge", + args: ["/tmp/legacy.sock"], + }, + }, + }, + }; - const lifecycleUpdates: string[] = []; - manager.subscribe((event) => { - if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + const resumed = await manager.resumeAgentFromPersistence(handle, { + cwd: workdir, + systemPrompt: "new prompt", + mcpServers: { + paseo: { + type: "stdio", + command: "node", + args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], + }, + }, + }); + + expect(resumed.config.systemPrompt).toBe("new prompt"); + expect(resumed.config.mcpServers).toEqual({ + paseo: { + type: "stdio", + command: "node", + args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], + }, + }); + expect(client.lastResumeOverrides).toMatchObject({ + systemPrompt: "new prompt", + mcpServers: { + paseo: { + type: "stdio", + command: "node", + args: ["/tmp/mcp-bridge.mjs", "--socket", "/tmp/paseo.sock"], + }, + }, + }); + expect(client.lastResumeLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: resumed.id, + }, + }); +}); + +test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-context-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ReloadCaptureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + lastCreateLaunchContext: AgentLaunchContext | undefined; + lastResumeLaunchContext: AgentLaunchContext | undefined; + + async isAvailable(): Promise { + return true; + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastCreateLaunchContext = launchContext; + return new TestAgentSession(config); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastResumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new TestAgentSession(merged); + } + } + + const client = new ReloadCaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000108", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(client.lastCreateLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); + + await manager.reloadAgentSession(snapshot.id, { + systemPrompt: "reloaded prompt", + }); + + expect(client.lastResumeLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); +}); + +test("reloadAgentSession preserves timeline and does not force history replay", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HistoryProbeSession extends TestAgentSession { + constructor( + config: AgentSessionConfig, + private readonly historyText: string | null, + ) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + if (!this.historyText) { return; } - lifecycleUpdates.push(event.agent.lifecycle); - }); - lifecycleUpdates.length = 0; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: this.historyText }, + }; + } + } - manager.recordUserMessage(snapshot.id, "hello", { emitState: false }); + class HistoryProbeClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; - expect(lifecycleUpdates).toEqual([]); + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new HistoryProbeSession(config, null); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new HistoryProbeSession(merged, "history replay from provider"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new HistoryProbeClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000113", }); - test("runAgent assembles finalText from trailing assistant chunks", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const expectedFinalText = - '```json\n{"message":"Reserve space for archive button in sidebar agent list"}\n```'; - - class ChunkedAssistantSession implements AgentSession { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - private subs = new Set<(event: AgentStreamEvent) => void>(); - private turnCounter = 0; - - async run(): Promise { - return { - sessionId: this.id, - finalText: "", - timeline: [], - }; - } - - async startTurn(): Promise<{ turnId: string }> { - const turnId = `chunked-turn-${++this.turnCounter}`; - setTimeout(() => { - for (const cb of this.subs) { - cb({ type: "turn_started", provider: this.provider, turnId }); - cb({ - type: "timeline", - provider: this.provider, - item: { - type: "assistant_message", - text: '```json\n{"message":"Reserve space for archive button in side', - }, - turnId, - }); - cb({ - type: "timeline", - provider: this.provider, - item: { - type: "assistant_message", - text: 'bar agent list"}\n```', - }, - turnId, - }); - cb({ type: "turn_completed", provider: this.provider, turnId }); - } - }, 0); - return { turnId }; - } - - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subs.add(callback); - return () => { - this.subs.delete(callback); - }; - } - - async *streamHistory(): AsyncGenerator {} - - async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.id, - model: null, - modeId: null, - }; - } - - async getAvailableModes() { - return []; - } - - async getCurrentMode() { - return null; - } - - async setMode(): Promise {} - - getPendingPermissions() { - return []; - } - - async respondToPermission(): Promise {} - - describePersistence() { - return { - provider: this.provider, - sessionId: this.id, - }; - } - - async interrupt(): Promise {} - - async close(): Promise {} - } - - class ChunkedAssistantClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(): Promise { - return new ChunkedAssistantSession(); - } - - async resumeSession(): Promise { - return new ChunkedAssistantSession(); - } - } - - const manager = new AgentManager({ - clients: { - codex: new ChunkedAssistantClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000113", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const result = await manager.runAgent(snapshot.id, "generate commit message"); - expect(result.finalText).toBe(expectedFinalText); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, }); - test("listAgents excludes internal agents", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const generatedAgentIds = [ - "00000000-0000-4000-8000-000000000105", - "00000000-0000-4000-8000-000000000106", - ]; - let agentCounter = 0; - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(), - }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "keep this timeline in memory", + }); + await manager.hydrateTimelineFromProvider(snapshot.id); + const beforeReload = manager.getTimeline(snapshot.id); + expect(beforeReload).toHaveLength(1); - // Create a normal agent - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Normal Agent", - }); + await manager.reloadAgentSession(snapshot.id, { + systemPrompt: "reloaded prompt", + }); + const afterReload = manager.getTimeline(snapshot.id); + expect(afterReload).toEqual(beforeReload); - // Create an internal agent - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + // If reload resets historyPrimed, this would replay provider history and append another item. + await manager.hydrateTimelineFromProvider(snapshot.id); + const afterHydrate = manager.getTimeline(snapshot.id); + expect(afterHydrate).toEqual(beforeReload); +}); - const agents = manager.listAgents(); - expect(agents).toHaveLength(1); - expect(agents[0]?.config.title).toBe("Normal Agent"); +test("reloadAgentSession preserves current title when config title is unset", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000126", }); - test("getAgent returns internal agents by ID", async () => { - const internalAgentId = "00000000-0000-4000-8000-000000000107"; - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => internalAgentId, - }); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + await manager.setTitle(snapshot.id, "Generated title"); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + const beforeReload = await storage.get(snapshot.id); + expect(beforeReload?.title).toBe("Generated title"); + expect(beforeReload?.config?.title).toBeUndefined(); - const agent = manager.getAgent(internalAgentId); - expect(agent).not.toBeNull(); - expect(agent?.internal).toBe(true); + await manager.reloadAgentSession(snapshot.id); + + const afterReload = await storage.get(snapshot.id); + expect(afterReload?.title).toBe("Generated title"); + expect(afterReload?.config?.title).toBeUndefined(); +}); + +test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000127", }); - test("subscribe does not emit state events for internal agents to global subscribers", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const generatedAgentIds = [ - "00000000-0000-4000-8000-000000000108", - "00000000-0000-4000-8000-000000000109", - ]; - let agentCounter = 0; - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(), - }); - - const receivedEvents: string[] = []; - manager.subscribe((event) => { - if (event.type === "agent_state") { - receivedEvents.push(event.agent.id); - } - }); - - // Create a normal agent - should emit - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Normal Agent", - }); - - // Create an internal agent - should NOT emit to global subscriber - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); - - // Should only have events from the normal agent - expect(receivedEvents.filter((id) => id === generatedAgentIds[0]).length).toBeGreaterThan(0); - expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, }); - test("subscribe emits state events for internal agents when subscribed by agentId", async () => { - const internalAgentId = "00000000-0000-4000-8000-000000000110"; - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => internalAgentId, - }); + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); - const receivedEvents: string[] = []; - // Subscribe specifically to the internal agent - manager.subscribe( - (event) => { - if (event.type === "agent_state") { - receivedEvents.push(event.agent.id); - } - }, - { agentId: internalAgentId, replayState: false }, - ); + await manager.setTitle(snapshot.id, "Generated title"); - await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); + expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); - // Should receive events when subscribed by specific agentId - expect(receivedEvents.filter((id) => id === internalAgentId).length).toBeGreaterThan(0); + const live = manager.getAgent(snapshot.id); + expect(live).not.toBeNull(); + expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); +}); + +test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000132", }); - test("subscribe fails when filter agentId is not a UUID", () => { - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - logger, - }); - - expect(() => - manager.subscribe(() => {}, { - agentId: "invalid-agent-id", - }), - ).toThrow("subscribe: agentId must be a UUID"); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + modeId: "plan", + model: "gpt-5.2-codex", + thinkingOptionId: "low", }); - test("onAgentAttention is not called for internal agents", async () => { - const internalAgentId = "00000000-0000-4000-8000-000000000111"; - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const attentionCalls: string[] = []; - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => internalAgentId, - onAgentAttention: ({ agentId }) => { - attentionCalls.push(agentId); - }, - }); + await manager.setAgentMode(snapshot.id, "build"); + await manager.setAgentModel(snapshot.id, "gpt-5.4"); + await manager.setAgentThinkingOption(snapshot.id, "high"); + await manager.flush(); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Internal Agent", - internal: true, - }); + const persisted = await storage.get(snapshot.id); + expect(persisted).not.toBeNull(); + expect(persisted?.lastModeId).toBe("build"); + expect(persisted?.config?.model).toBe("gpt-5.4"); + expect(persisted?.config?.thinkingOptionId).toBe("high"); + expect(persisted?.runtimeInfo?.modeId).toBe("build"); + expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4"); +}); - // Run and complete the agent (which normally triggers attention) - await manager.runAgent(agent.id, "hello"); - - // Should NOT have triggered attention callback for internal agent - expect(attentionCalls).toHaveLength(0); +test("setLabels merges and persists labels", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-labels-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000133", }); - test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class FailingSession extends TestAgentSession { - private attempt = 0; - - override async startTurn(): Promise<{ turnId: string }> { - this.attempt += 1; - const attempt = this.attempt; - const turnId = `fail-turn-${attempt}`; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "turn_failed", - provider: this.provider, - error: `boom-${attempt}`, - turnId, - }); - }, 0); - return { turnId }; - } - } - - class FailingClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new FailingSession(config); - } - - async resumeSession(config?: Partial): Promise { - return new FailingSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - }); - } - } - - const attentionReasons: Array<"finished" | "error" | "permission"> = []; - const manager = new AgentManager({ - clients: { - codex: new FailingClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000130", - onAgentAttention: ({ reason }) => { - attentionReasons.push(reason); - }, - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Attention transition test", - }); - - await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1"); - await manager.flush(); - - const afterFirstFailure = manager.getAgent(agent.id); - expect(afterFirstFailure?.lifecycle).toBe("error"); - expect(afterFirstFailure?.attention.requiresAttention).toBe(true); - expect(afterFirstFailure?.attention).toMatchObject({ - requiresAttention: true, - attentionReason: "error", - }); - - const persistedAfterFirstFailure = await storage.get(agent.id); - expect(persistedAfterFirstFailure?.lastStatus).toBe("error"); - expect(persistedAfterFirstFailure?.requiresAttention).toBe(true); - expect(persistedAfterFirstFailure?.attentionReason).toBe("error"); - - await manager.clearAgentAttention(agent.id); - manager.notifyAgentState(agent.id); - await manager.flush(); - - const afterClear = manager.getAgent(agent.id); - expect(afterClear?.lifecycle).toBe("error"); - expect(afterClear?.attention).toEqual({ requiresAttention: false }); - - const persistedAfterClear = await storage.get(agent.id); - expect(persistedAfterClear?.lastStatus).toBe("error"); - expect(persistedAfterClear?.requiresAttention).toBe(false); - expect(persistedAfterClear?.attentionReason).toBeNull(); - - await expect(manager.runAgent(agent.id, "fail again")).rejects.toThrow("boom-2"); - await manager.flush(); - - const afterSecondFailure = manager.getAgent(agent.id); - expect(afterSecondFailure?.lifecycle).toBe("error"); - expect(afterSecondFailure?.attention).toMatchObject({ - requiresAttention: true, - attentionReason: "error", - }); - expect(attentionReasons).toEqual(["error", "error"]); - - const persistedAfterSecondFailure = await storage.get(agent.id); - expect(persistedAfterSecondFailure?.lastStatus).toBe("error"); - expect(persistedAfterSecondFailure?.requiresAttention).toBe(true); - expect(persistedAfterSecondFailure?.attentionReason).toBe("error"); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Label test", }); - test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000131", - }); + await manager.setLabels(snapshot.id, { surface: "mobile" }); + await manager.setLabels(snapshot.id, { phase: "1a" }); - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Archive target", - }); + const persisted = await storage.get(snapshot.id); + expect(persisted?.labels).toEqual({ + surface: "mobile", + phase: "1a", + }); +}); - const lifecycles: string[] = []; - manager.subscribe( - (event) => { - if (event.type === "agent_state" && event.agent.id === agent.id) { - lifecycles.push(event.agent.lifecycle); - } - }, - { agentId: agent.id, replayState: false }, - ); - - const { archivedAt } = await manager.archiveAgent(agent.id); - const stored = await storage.get(agent.id); - - expect(stored).toMatchObject({ - id: agent.id, - archivedAt, - lastStatus: "closed", - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - }); - expect( - Math.abs(new Date(stored!.updatedAt).getTime() - new Date(archivedAt).getTime()), - ).toBeLessThanOrEqual(5); - expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]); +test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000134", }); - test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class TurnFailedSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-failed-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "turn_failed", - provider: this.provider, - error: "invalid model id", - turnId, - }); - }, 0); - return { turnId }; - } - } - - class TurnFailedClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new TurnFailedSession(config); - } - - async resumeSession(config?: Partial): Promise { - return new TurnFailedSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - }); - } - } - - const manager = new AgentManager({ - clients: { - codex: new TurnFailedClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000131", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Turn failed test", - }); - - await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("invalid model id"); - - const snapshot = manager.getAgent(agent.id); - expect(snapshot?.lifecycle).toBe("error"); - expect(snapshot?.lastError).toBe("invalid model id"); - - const systemErrors = manager - .getTimeline(agent.id) - .filter( - (item): item is Extract => - item.type === "assistant_message" && item.text.includes("[System Error]"), - ); - expect(systemErrors).toHaveLength(1); - expect(systemErrors[0]?.text).toContain("invalid model id"); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Finished attention test", }); - test("turn_failed surfaces provider code and diagnostic in system error message", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-detail-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); + await manager.runAgent(snapshot.id, "say hello"); + await manager.flush(); - class DetailedFailureSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-detailed-fail-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "turn_failed", - provider: this.provider, - error: "Provider execution failed", - code: "126", - diagnostic: "No preset version installed for command claude", - turnId, - }); - }, 0); - return { turnId }; - } - } + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.requiresAttention).toBe(true); + expect(persisted?.attentionReason).toBe("finished"); + expect(persisted?.attentionTimestamp).toEqual(expect.any(String)); +}); - class DetailedFailureClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new DetailedFailureSession(config); - } - - async resumeSession(config?: Partial): Promise { - return new DetailedFailureSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - }); - } - } - - const manager = new AgentManager({ - clients: { - codex: new DetailedFailureClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000132", - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Detailed failure test", - }); - - await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed"); - - const systemError = manager - .getTimeline(agent.id) - .find( - (item): item is Extract => - item.type === "assistant_message" && item.text.includes("[System Error]"), - ); - expect(systemError?.text).toContain("Provider execution failed"); - expect(systemError?.text).toContain("code: 126"); - expect(systemError?.text).toContain("No preset version installed for command claude"); +test("archiveSnapshot clears persisted attention and normalizes running status", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-attention-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000135", }); - test("permission request notifies once without forcing unread attention state", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - const releasePermissionResolution = deferred(); - - class PermissionSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-perm-1"; - setTimeout(async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "permission_requested", - provider: this.provider, - request: { - id: "perm-1", - provider: this.provider, - kind: "tool", - name: "Read file", - }, - turnId, - }); - await releasePermissionResolution.promise; - this.pushEvent({ - type: "permission_resolved", - provider: this.provider, - requestId: "perm-1", - resolution: { behavior: "allow" }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class PermissionClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new PermissionSession(config); - } - - async resumeSession(config?: Partial): Promise { - return new PermissionSession({ - provider: "codex", - cwd: config?.cwd ?? process.cwd(), - }); - } - } - - const attentionReasons: Array<"finished" | "error" | "permission"> = []; - const manager = new AgentManager({ - clients: { - codex: new PermissionClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000131", - onAgentAttention: ({ reason }) => { - attentionReasons.push(reason); - }, - }); - - const agent = await manager.createAgent({ - provider: "codex", - cwd: workdir, - title: "Permission transition test", - }); - - const stream = manager.streamAgent(agent.id, "permission flow"); - await stream.next(); // turn_started - await stream.next(); // permission_requested - - const withPermissionPending = manager.getAgent(agent.id); - expect(withPermissionPending?.pendingPermissions.size).toBe(1); - expect(withPermissionPending?.attention).toEqual({ requiresAttention: false }); - - // Release permission resolution and drain the rest of the stream - releasePermissionResolution.resolve(); - while (!(await stream.next()).done) { - // no-op - } - - expect(attentionReasons).toContain("permission"); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Archive attention test", }); - test("respondToPermission updates currentModeId after plan approval", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); + const live = manager.getAgent(snapshot.id); + expect(live).not.toBeNull(); + live!.lifecycle = "running"; + live!.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date("2025-01-02T00:00:00.000Z"), + }; - // Create a session that simulates plan approval mode change - let sessionMode = "plan"; - class PlanModeTestSession implements AgentSession { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - private subs = new Set<(event: AgentStreamEvent) => void>(); - private turnCounter = 0; + const archivedAt = "2025-01-03T00:00:00.000Z"; + const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); - async run(): Promise { - return { sessionId: this.id, finalText: "", timeline: [] }; - } + expect(archivedRecord.archivedAt).toBe(archivedAt); + expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.requiresAttention).toBe(false); + expect(archivedRecord.attentionReason).toBeNull(); + expect(archivedRecord.attentionTimestamp).toBeNull(); - async startTurn(): Promise<{ turnId: string }> { - const turnId = `plan-turn-${++this.turnCounter}`; - setTimeout(() => { - for (const cb of this.subs) { - cb({ type: "turn_started", provider: this.provider, turnId }); - cb({ type: "turn_completed", provider: this.provider, turnId }); - } - }, 0); - return { turnId }; - } + const persisted = await storage.get(snapshot.id); + expect(persisted?.archivedAt).toBe(archivedAt); + expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.requiresAttention).toBe(false); + expect(persisted?.attentionReason).toBeNull(); + expect(persisted?.attentionTimestamp).toBeNull(); +}); - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subs.add(callback); - return () => { - this.subs.delete(callback); - }; - } +test("reloadAgentSession cancels active run and resumes existing session once thread_started is observed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-active-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); - async *streamHistory(): AsyncGenerator {} + class DelayedPersistenceSession extends TestAgentSession { + private persistenceReady = false; + private delayedInterrupted = false; + private releaseGate: (() => void) | null = null; + private readonly gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + private activeTurnId: string | null = null; - async getRuntimeInfo() { - return { provider: this.provider, sessionId: this.id, model: null, modeId: sessionMode }; - } - - async getAvailableModes() { - return [ - { id: "plan", label: "Plan" }, - { id: "acceptEdits", label: "Accept Edits" }, - ]; - } - - async getCurrentMode() { - return sessionMode; - } - - async setMode(modeId: string): Promise { - sessionMode = modeId; - } - - getPendingPermissions() { - return []; - } - - async respondToPermission(_requestId: string, response: { behavior: string }): Promise { - // Simulate what claude-agent.ts does: when plan permission is approved, - // it calls setMode("acceptEdits") internally - if (response.behavior === "allow") { - sessionMode = "acceptEdits"; - } - } - - describePersistence() { - return { provider: this.provider, sessionId: this.id }; - } - - async interrupt(): Promise {} - async close(): Promise {} + constructor( + config: AgentSessionConfig, + private readonly stableSessionId: string, + initiallyReady = false, + ) { + super(config); + this.persistenceReady = initiallyReady; } - class PlanModeTestClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(): Promise { - return new PlanModeTestSession(); - } - - async resumeSession(): Promise { - return new PlanModeTestSession(); - } - } - - const manager = new AgentManager({ - clients: { - codex: new PlanModeTestClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000112", - }); - - // Create agent in plan mode - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - modeId: "plan", - }); - - expect(snapshot.currentModeId).toBe("plan"); - - // Simulate a pending plan permission request - const agent = manager.getAgent(snapshot.id)!; - const permissionRequest = { - id: "perm-123", - provider: "codex" as const, - name: "ExitPlanMode", - kind: "plan" as const, - input: { plan: "Test plan" }, - }; - agent.pendingPermissions.set(permissionRequest.id, permissionRequest); - - // Approve the plan permission - await manager.respondToPermission(snapshot.id, "perm-123", { - behavior: "allow", - }); - - // The session's mode has changed to "acceptEdits" internally - // The manager should have updated currentModeId to reflect this - const updatedAgent = manager.getAgent(snapshot.id); - expect(updatedAgent?.currentModeId).toBe("acceptEdits"); - - await manager.flush(); - const persisted = await storage.get(snapshot.id); - expect(persisted?.lastModeId).toBe("acceptEdits"); - }); - - test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class RefreshingPermissionSession extends TestAgentSession { - private featureState: AgentFeature[] = [ - createFeature({ id: "fast_mode", label: "Fast", value: true }), - createFeature({ id: "plan_mode", label: "Plan", value: true }), - ]; - private modeId = "auto"; - private pending = [ - { - id: "perm-plan-1", - provider: "codex" as const, - name: "CodexPlanApproval", - kind: "plan" as const, - input: { plan: "- Implement the feature" }, - }, - ]; - - get features(): AgentFeature[] { - return this.featureState; - } - - override async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.id, - model: "gpt-5.4", - modeId: this.modeId, - extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" }, - }; - } - - override async getCurrentMode() { - return this.modeId; - } - - override getPendingPermissions() { - return this.pending; - } - - override async respondToPermission(): Promise { - this.modeId = "auto"; - this.pending = []; - this.featureState = [ - createFeature({ id: "fast_mode", label: "Fast", value: true }), - createFeature({ id: "plan_mode", label: "Plan", value: false }), - ]; - } - } - - class RefreshingPermissionClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new RefreshingPermissionSession(config); - } - } - - const manager = new AgentManager({ - clients: { - codex: new RefreshingPermissionClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000133", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const agent = manager.getAgent(snapshot.id); - if (!agent) { - throw new Error("Expected managed agent"); - } - agent.pendingPermissions.set("perm-plan-1", { - id: "perm-plan-1", - provider: "codex", - name: "CodexPlanApproval", - kind: "plan", - input: { plan: "- Implement the feature" }, - }); - - await manager.respondToPermission(snapshot.id, "perm-plan-1", { - behavior: "allow", - selectedActionId: "implement", - }); - - const updated = manager.getAgent(snapshot.id); - expect(updated?.pendingPermissions.size).toBe(0); - expect(updated?.features).toEqual([ - createFeature({ id: "fast_mode", label: "Fast", value: true }), - createFeature({ id: "plan_mode", label: "Plan", value: false }), - ]); - expect(updated?.runtimeInfo).toMatchObject({ - model: "gpt-5.4", - extra: { collaborationMode: "Code" }, - }); - - const persisted = await storage.get(snapshot.id); - expect(persisted?.features).toEqual([ - createFeature({ id: "fast_mode", label: "Fast", value: true }), - createFeature({ id: "plan_mode", label: "Plan", value: false }), - ]); - }); - - test("respondToPermission emits refreshed state before permission_resolved", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class OrderedPermissionSession extends TestAgentSession { - private featureState: AgentFeature[] = [ - createFeature({ id: "fast_mode", label: "Fast", value: true }), - ]; - private modeId = "plan"; - private pending = [ - { - id: "perm-order-1", - provider: "codex" as const, - name: "ExitPlanMode", - kind: "plan" as const, - input: { plan: "- Do the work" }, - }, - ]; - - get features(): AgentFeature[] { - return this.featureState; - } - - override async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.id, - model: "gpt-5.4", - modeId: this.modeId, - }; - } - - override async getCurrentMode() { - return this.modeId; - } - - override getPendingPermissions() { - return this.pending; - } - - override async respondToPermission(): Promise { + override async startTurn(): Promise<{ turnId: string }> { + this.delayedInterrupted = false; + const turnId = `delayed-turn-${Date.now()}`; + this.activeTurnId = turnId; + // Push turn_started, then thread_started, then wait on gate + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.persistenceReady = true; this.pushEvent({ - type: "permission_resolved", + type: "thread_started", provider: this.provider, - requestId: "perm-order-1", - resolution: { behavior: "allow" }, + sessionId: this.stableSessionId, }); - this.modeId = "acceptEdits"; - this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })]; - this.pending = []; - } - } - - class OrderedPermissionClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new OrderedPermissionSession(config); - } - } - - const manager = new AgentManager({ - clients: { - codex: new OrderedPermissionClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000134", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const seen: string[] = []; - manager.subscribe((event) => { - if ("agentId" in event && event.agentId !== snapshot.id) { - return; - } - if (event.type === "agent_state" && event.agent.id === snapshot.id) { - const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode"); - seen.push( - `state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`, - ); - return; - } - if (event.type === "agent_stream" && event.event.type === "permission_resolved") { - seen.push(`resolved:${event.event.requestId}`); - } - }); - - await manager.respondToPermission(snapshot.id, "perm-order-1", { - behavior: "allow", - }); - - const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false"); - const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1"); - expect(refreshedStateIndex).toBeGreaterThanOrEqual(0); - expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex); - }); - - test("close during in-flight stream does not clear persistence sessionId", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class CloseRaceSession implements AgentSession { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - readonly id = randomUUID(); - private threadId: string | null = this.id; - private closed = false; - private subscribers = new Set<(event: AgentStreamEvent) => void>(); - private turnIdCounter = 0; - - async run(): Promise { - return { sessionId: this.id, finalText: "", timeline: [] }; - } - - async startTurn(): Promise<{ turnId: string }> { - const turnId = `turn-${++this.turnIdCounter}`; - // Push turn_started, then block until closed - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - // The turn will be canceled when close() is called - }, 0); - return { turnId }; - } - - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } - - private pushEvent(event: AgentStreamEvent): void { - for (const cb of this.subscribers) { - try { - cb(event); - } catch { - /* isolation */ - } + await this.gate; + if (this.delayedInterrupted) { + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "Interrupted", + turnId, + }); + } else { + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); } - } + }, 0); + return { turnId }; + } - async *streamHistory(): AsyncGenerator {} + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.persistenceReady ? this.stableSessionId : null, + model: null, + modeId: null, + }; + } - async getRuntimeInfo() { - return { - provider: this.provider, - sessionId: this.threadId, - model: null, - modeId: null, - }; - } - - async getAvailableModes() { - return []; - } - - async getCurrentMode() { + describePersistence() { + if (!this.persistenceReady) { return null; } + return { + provider: this.provider, + sessionId: this.stableSessionId, + }; + } - async setMode(): Promise {} + override async interrupt(): Promise { + this.delayedInterrupted = true; + this.releaseGate?.(); + } - getPendingPermissions() { - return []; + async close(): Promise { + this.delayedInterrupted = true; + this.releaseGate?.(); + } + } + + class DelayedPersistenceClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + createSessionCalls = 0; + resumeSessionCalls = 0; + private nextSessionNumber = 1; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + const sessionId = `delayed-session-${this.nextSessionNumber++}`; + this.createSessionCalls += 1; + return new DelayedPersistenceSession(config, sessionId); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new DelayedPersistenceSession(merged, handle.sessionId, true); + } + } + + const client = new DelayedPersistenceClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000114", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + expect(snapshot.persistence).toBeNull(); + + const stream = manager.streamAgent(snapshot.id, "hello"); + const first = await stream.next(); + expect(first.done).toBe(false); + expect(first.value?.type).toBe("turn_started"); + + // Wait for the thread_started event to propagate through subscribe + // (it's a session-level event, not forwarded to the foreground stream) + await vi.waitFor(() => { + const active = manager.getAgent(snapshot.id); + expect(active?.persistence?.sessionId).toBe("delayed-session-1"); + }); + + const active = manager.getAgent(snapshot.id); + expect(active?.lifecycle).toBe("running"); + + const reloaded = await manager.reloadAgentSession(snapshot.id, { + systemPrompt: "voice mode on", + }); + + expect(client.createSessionCalls).toBe(1); + expect(client.resumeSessionCalls).toBe(1); + expect(reloaded.persistence?.sessionId).toBe("delayed-session-1"); + + // Drain stream after cancellation to ensure clean shutdown. + while (true) { + const next = await stream.next(); + if (next.done) { + break; + } + } +}); + +test("fetchTimeline returns full timeline with reset when cursor epoch is stale", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-stale-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000118", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "one", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "two", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "three", + }); + + const baseline = manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 2, + }); + expect(baseline.rows).toHaveLength(2); + + const result = manager.fetchTimeline(snapshot.id, { + direction: "after", + cursor: { + epoch: "stale-epoch", + seq: baseline.rows[baseline.rows.length - 1]!.seq, + }, + limit: 1, + }); + + expect(result.reset).toBe(true); + expect(result.staleCursor).toBe(true); + expect(result.gap).toBe(false); + expect(result.rows).toHaveLength(3); + expect(result.rows[0]?.seq).toBe(1); + expect(result.rows[result.rows.length - 1]?.seq).toBe(3); +}); + +test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000140", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "row one", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "row two", + }); + + await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ + { + seq: 1, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "row one", + }, + }, + { + seq: 2, + timestamp: expect.any(String), + item: { + type: "assistant_message", + text: "row two", + }, + }, + ]); +}); + +test("getAgent does not expose committed history internals once manager owns the seam", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-boundary-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000138", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + manager.recordUserMessage(snapshot.id, "hello boundary", { + messageId: "msg-boundary-1", + emitState: false, + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "history stays behind manager", + }); + + const live = manager.getAgent(snapshot.id) as Record; + expect(live).not.toBeNull(); + expect("timeline" in live).toBe(false); + expect("timelineRows" in live).toBe(false); + expect("timelineNextSeq" in live).toBe(false); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "user_message", + text: "hello boundary", + messageId: "msg-boundary-1", + }, + { + type: "assistant_message", + text: "history stays behind manager", + }, + ]); + + const fetched = await manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); +}); + +test("coalesces assistant chunks and persists the canonical row", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new StreamingAssistantClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const streamEvents: Array<{ + seq?: number; + epoch?: string; + eventType?: string; + itemType?: string; + text?: string; + }> = []; + manager.subscribe( + (event) => { + if (event.type !== "agent_stream") { + return; } + streamEvents.push({ + seq: event.seq, + epoch: event.epoch, + eventType: event.event.type, + itemType: event.event.type === "timeline" ? event.event.item.type : undefined, + text: + event.event.type === "timeline" && event.event.item.type === "assistant_message" + ? event.event.item.text + : undefined, + }); + }, + { agentId: snapshot.id, replayState: false }, + ); - async respondToPermission(): Promise {} + const stream = manager.streamAgent(snapshot.id, "hello"); + while (true) { + const next = await stream.next(); + if (next.done) { + break; + } + } - describePersistence() { - if (!this.threadId) { - return null; - } - return { provider: this.provider, sessionId: this.threadId }; - } + const assistantTimelineEvents = streamEvents.filter( + (event) => event.itemType === "assistant_message", + ); + expect(assistantTimelineEvents).toHaveLength(1); + expect(assistantTimelineEvents[0]).toMatchObject({ + eventType: "timeline", + itemType: "assistant_message", + text: "final reply", + seq: 1, + epoch: expect.any(String), + }); - async interrupt(): Promise { - this.closed = true; - // Push turn_canceled for any active turn - if (this.turnIdCounter > 0) { + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "assistant_message", + text: "final reply", + }, + ]); + const fetched = await manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows).toHaveLength(1); + expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); + expect(fetched.rows[0]?.item).toEqual({ + type: "assistant_message", + text: "final reply", + }); +}); + +test("fetchTimeline supports older-history pagination with before seq", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-before-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000119", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "first", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "second", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "third", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "fourth", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "fifth", + }); + + const result = await manager.fetchTimeline(snapshot.id, { + direction: "before", + cursor: { + seq: 5, + }, + limit: 2, + }); + + expect(result.rows).toHaveLength(2); + expect(result.rows[0]?.seq).toBe(3); + expect(result.rows[1]?.seq).toBe(4); + expect(result.hasOlder).toBe(true); + expect(result.hasNewer).toBe(true); +}); + +test("does not trim committed history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-unbounded-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "first", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "second", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "third", + }); + + const fetched = await manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows).toHaveLength(3); + expect(fetched.window.minSeq).toBe(1); + expect(fetched.window.maxSeq).toBe(3); +}); + +test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ChunkedAssistantHistorySession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "chunk one " }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "chunk two" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "reasoning", text: "internal" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { + type: "tool_call", + callId: "call-history-1", + name: "shell", + status: "completed", + detail: { + type: "shell", + command: "echo hi", + output: "hi\n", + exitCode: 0, + }, + error: null, + }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "final answer" }, + }; + } + } + + class ChunkedAssistantHistoryClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ChunkedAssistantHistorySession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ChunkedAssistantHistoryClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000121", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { type: "assistant_message", text: "chunk one " }, + { type: "assistant_message", text: "chunk two" }, + { type: "reasoning", text: "internal" }, + { + type: "tool_call", + callId: "call-history-1", + name: "shell", + status: "completed", + detail: { + type: "shell", + command: "echo hi", + output: "hi\n", + exitCode: 0, + }, + error: null, + }, + { type: "assistant_message", text: "final answer" }, + ]); +}); + +test("hydrateTimeline preserves reasoning between assistant chunks", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ReasoningInterleavedHistorySession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "before reasoning " }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "reasoning", text: "internal step" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "after reasoning" }, + }; + } + } + + class ReasoningInterleavedHistoryClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new ReasoningInterleavedHistorySession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ReasoningInterleavedHistoryClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000122", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + expect(manager.getTimeline(snapshot.id)).toEqual([ + { + type: "assistant_message", + text: "before reasoning ", + }, + { type: "reasoning", text: "internal step" }, + { type: "assistant_message", text: "after reasoning" }, + ]); +}); + +test("createAgent fails when generated agent ID is not a UUID", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "not-a-uuid", + }); + + await expect( + manager.createAgent({ + provider: "codex", + cwd: workdir, + }), + ).rejects.toThrow("createAgent: agentId must be a UUID"); +}); + +test("createAgent fails when explicit agent ID is not a UUID", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + await expect( + manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + "not-a-uuid", + ), + ).rejects.toThrow("createAgent: agentId must be a UUID"); +}); + +test("createAgent persists provided title before returning", async () => { + const agentId = "00000000-0000-4000-8000-000000000102"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => agentId, + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Fix Login Bug", + }); + + expect(snapshot.id).toBe(agentId); + expect(snapshot.lifecycle).toBe("idle"); + + const persisted = await storage.get(agentId); + expect(persisted?.title).toBe("Fix Login Bug"); + expect(persisted?.id).toBe(agentId); +}); + +test("createAgent populates runtimeInfo after session creation", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + model: "gpt-5.2-codex", + modeId: "full-access", + }); + + expect(snapshot.runtimeInfo).toBeDefined(); + expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex"); + expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.persistence?.sessionId); +}); + +test("runAgent refreshes runtimeInfo after completion", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000104", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(snapshot.runtimeInfo?.model).toBe("gpt-5.4"); + + await manager.runAgent(snapshot.id, "hello"); + + const refreshed = manager.getAgent(snapshot.id); + expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex"); +}); + +test("waitForAgentEvent does not resolve idle until foreground turn is finalized", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-wait-coherence-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const releaseTurnCompleted = deferred(); + + class SlowTerminalSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + void (async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await releaseTurnCompleted.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + })(); + return { turnId }; + } + } + + class SlowTerminalClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new SlowTerminalSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new SlowTerminalClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000124", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const stream = manager.streamAgent(snapshot.id, "hello"); + const consumePromise = (async () => { + for await (const _event of stream) { + // Drain events so manager lifecycle progresses naturally. + } + })(); + + // Wait for the turn to start + await new Promise((resolve) => setTimeout(resolve, 20)); + + const waitPromise = manager.waitForAgentEvent(snapshot.id); + + // Should still be pending because turn_completed hasn't arrived + const earlyResolution = await Promise.race([ + waitPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), + ]); + expect(earlyResolution).toBe("pending"); + + // Release the turn_completed event + releaseTurnCompleted.resolve(); + const waited = await waitPromise; + expect(waited.status).toBe("idle"); + + await consumePromise; +}); + +test("waitForAgentRunStart resolves while a foreground run is still only pending", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-fast-start-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000124", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const run = manager.streamAgent(snapshot.id, "fast"); + const drainRun = (async () => { + for await (const _event of run) { + // Drain the fast foreground turn. + } + })(); + + await expect(manager.waitForAgentRunStart(snapshot.id)).resolves.toBeUndefined(); + + await drainRun; + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); +}); + +test("replaceAgentRun does not emit idle or resolve waiters between interrupted and replacement runs", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-run-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const allowFirstRunToEnd = deferred(); + const allowSecondRunToEnd = deferred(); + + class ReplaceRunSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + const turnNum = this.turnIdCounter; + + void (async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + if (turnNum === 1) { + await allowFirstRunToEnd.promise; this.pushEvent({ type: "turn_canceled", provider: this.provider, reason: "interrupted", - turnId: `turn-${this.turnIdCounter}`, + turnId, }); + } else { + await allowSecondRunToEnd.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); } - } - - async close(): Promise { - this.closed = true; - this.threadId = null; - // Push turn_canceled for any active turn - if (this.turnIdCounter > 0) { - this.pushEvent({ - type: "turn_canceled", - provider: this.provider, - reason: "closed", - turnId: `turn-${this.turnIdCounter}`, - }); - } - } + })(); + return { turnId }; } - class CloseRaceClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; + override async interrupt(): Promise { + this.interrupted = true; + allowFirstRunToEnd.resolve(); + } + } - async isAvailable(): Promise { - return true; + class ReplaceRunClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ReplaceRunSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ReplaceRunClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000125", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const lifecycleUpdates: string[] = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + return; + } + lifecycleUpdates.push(event.agent.lifecycle); + }, + { agentId: snapshot.id, replayState: false }, + ); + + const firstRun = manager.streamAgent(snapshot.id, "first run"); + const firstRunDrain = (async () => { + for await (const _event of firstRun) { + // Drain events so lifecycle updates are applied. + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const waitPromise = manager.waitForAgentEvent(snapshot.id); + const secondRun = manager.replaceAgentRun(snapshot.id, "second run"); + const secondRunDrain = (async () => { + for await (const _event of secondRun) { + // Drain replacement run. + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const prematureResolution = await Promise.race([ + waitPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 50)), + ]); + expect(prematureResolution).toBe("pending"); + + const runningIndexes = lifecycleUpdates.reduce((indexes, status, index) => { + if (status === "running") { + indexes.push(index); + } + return indexes; + }, []); + expect(runningIndexes.length).toBeGreaterThanOrEqual(2); + + const firstReplacementRunningIndex = runningIndexes[1]!; + expect(lifecycleUpdates.slice(0, firstReplacementRunningIndex).includes("idle")).toBe(false); + + allowSecondRunToEnd.resolve(); + + const waited = await waitPromise; + expect(waited.status).toBe("idle"); + + await firstRunDrain; + await secondRunDrain; + unsubscribe(); +}); + +test("replaceAgentRun stays running when a stale old terminal arrives before the replacement turn is current", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-replace-stale-terminal-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const secondStartEntered = deferred(); + const interruptStarted = deferred(); + const allowInterruptToFinish = deferred(); + const allowSecondStartToResolve = deferred(); + let capturedSession: StaleReplacementSession | null = null; + + class StaleReplacementSession extends TestAgentSession { + private localTurnCounter = 0; + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = `turn-${++this.localTurnCounter}`; + const turnNum = this.localTurnCounter; + + if (turnNum === 1) { + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + }, 0); + return { turnId }; } - async createSession(): Promise { - return new CloseRaceSession(); - } - - async resumeSession(): Promise { - return new CloseRaceSession(); - } + secondStartEntered.resolve(); + await allowSecondStartToResolve.promise; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; } - const manager = new AgentManager({ - clients: { - codex: new CloseRaceClient(), + override async interrupt(): Promise { + interruptStarted.resolve(); + await allowInterruptToFinish.promise; + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "Interrupted", + turnId: "turn-1", + }); + } + } + + class StaleReplacementClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new StaleReplacementSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { + codex: new StaleReplacementClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000126", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const stateUpdates: Array<{ lifecycle: string; updatedAt: number }> = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + return; + } + stateUpdates.push({ + lifecycle: event.agent.lifecycle, + updatedAt: event.agent.updatedAt.getTime(), + }); + }, + { agentId: snapshot.id, replayState: false }, + ); + + const firstRun = manager.streamAgent(snapshot.id, "first run"); + const firstRunDrain = (async () => { + for await (const _event of firstRun) { + // Drain events so lifecycle updates are applied. + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const replaceUpdatesStart = stateUpdates.length; + const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0; + const secondRun = manager.replaceAgentRun(snapshot.id, "replacement run"); + const secondRunDrain = (async () => { + for await (const _event of secondRun) { + // Drain replacement run. + } + })(); + + await interruptStarted.promise; + const replacementUpdates = stateUpdates.slice(replaceUpdatesStart); + expect( + replacementUpdates.some( + (update) => update.lifecycle === "running" && update.updatedAt > beforeReplaceUpdatedAt, + ), + ).toBe(true); + expect(replacementUpdates.map((update) => update.lifecycle)).not.toContain("idle"); + allowInterruptToFinish.resolve(); + + await secondStartEntered.promise; + + const replaceGapSnapshot = manager.getAgent(snapshot.id) as + | { pendingReplacement: boolean; activeForegroundTurnId: string | null; lifecycle: string } + | undefined; + expect(replaceGapSnapshot?.pendingReplacement).toBe(false); + expect(replaceGapSnapshot?.activeForegroundTurnId).toBeNull(); + expect(replaceGapSnapshot?.lifecycle).toBe("running"); + + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("running"); + expect(stateUpdates.at(-1)?.lifecycle).toBe("running"); + expect(stateUpdates.slice(replaceUpdatesStart).map((update) => update.lifecycle)).not.toContain( + "idle", + ); + + allowSecondStartToResolve.resolve(); + + await manager.waitForAgentRunStart(snapshot.id); + await firstRunDrain; + await secondRunDrain; + unsubscribe(); +}); + +test("applies live autonomous events while no foreground run is active", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new TestAgentSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { + codex: new LiveEventClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000125", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const lifecycleUpdates: string[] = []; + let sawRunningState = false; + let resolveSettled!: () => void; + const settled = new Promise((resolve) => { + resolveSettled = resolve; + }); + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === snapshot.id) { + lifecycleUpdates.push(event.agent.lifecycle); + if (event.agent.lifecycle === "running") { + sawRunningState = true; + } + if (sawRunningState && event.agent.lifecycle === "idle") { + resolveSettled(); + } + } + }, + { agentId: snapshot.id, replayState: false }, + ); + + // Push autonomous events through the session's subscribe() callbacks + const autonomousTurnId = "autonomous-turn-1"; + capturedSession!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE" }, + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: autonomousTurnId, + }); + await settled; + + const updated = manager.getAgent(snapshot.id); + expect(updated?.lifecycle).toBe("idle"); + expect(manager.getTimeline(snapshot.id)).toContainEqual({ + type: "assistant_message", + text: "AUTONOMOUS_PUMP_MESSAGE", + }); + expect(lifecycleUpdates).toContain("running"); + expect(lifecycleUpdates).toContain("idle"); +}); + +test("cancelAgentRun can interrupt autonomous running state without a foreground activeForegroundTurnId", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class LiveInterruptSession extends TestAgentSession { + public interruptCount = 0; + + override async interrupt(): Promise { + this.interruptCount += 1; + } + } + + class LiveInterruptClient extends TestAgentClient { + lastSession: LiveInterruptSession | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + const session = new LiveInterruptSession(config); + this.lastSession = session; + return session; + } + } + + const client = new LiveInterruptClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000129", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const capturedSession = client.lastSession!; + + await new Promise((resolve) => { + const unsubscribe = manager.subscribe( + (event) => { + if (event.type !== "agent_state") { + return; + } + if (event.agent.id !== snapshot.id) { + return; + } + if (event.agent.lifecycle !== "running") { + return; + } + unsubscribe(); + resolve(); }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000113", - }); - - const snapshot = await manager.createAgent({ + { agentId: snapshot.id, replayState: false }, + ); + capturedSession.pushEvent({ + type: "turn_started", provider: "codex", - cwd: workdir, + turnId: "autonomous-cancel-1", }); + }); + + const beforeCancel = manager.getAgent(snapshot.id); + expect(beforeCancel?.lifecycle).toBe("running"); + expect(beforeCancel?.activeForegroundTurnId).toBeNull(); + + const cancelled = await manager.cancelAgentRun(snapshot.id); + expect(cancelled).toBe(true); + expect(client.lastSession?.interruptCount).toBe(1); +}); + +test("waitForAgentEvent waitForActive resolves for autonomous live-event run", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-wait-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = new TestAgentSession(config); + capturedSession = session; + return session; + } + } + + const manager = new AgentManager({ + clients: { + codex: new LiveEventClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000126", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const autonomousTurnId = "autonomous-wait-1"; + const waitPromise = manager.waitForAgentEvent(snapshot.id, { waitForActive: true }); + capturedSession!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: autonomousTurnId, + }); + + const result = await waitPromise; + expect(result.status).toBe("idle"); +}); + +test("autonomous events arriving during foreground run are processed via subscribe", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-during-fg-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const releaseForeground = deferred(); + + let capturedSession: TestAgentSession | null = null; + + class ForegroundSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "fg-turn-1"; + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await releaseForeground.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class ForegroundClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = new ForegroundSession(config); + capturedSession = session; + return session; + } + } + + const manager = new AgentManager({ + clients: { + codex: new ForegroundClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000127", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const foreground = manager.streamAgent(snapshot.id, "foreground run"); + const foregroundResults = (async () => { + const events: AgentStreamEvent[] = []; + for await (const event of foreground) { + events.push(event); + } + return events; + })(); + + // Wait for the foreground turn to start (lifecycle -> running) + await new Promise((resolve) => { + const unsub = manager.subscribe( + (event) => { + if ( + event.type === "agent_state" && + event.agent.id === snapshot.id && + event.agent.lifecycle === "running" + ) { + unsub(); + resolve(); + } + }, + { agentId: snapshot.id, replayState: true }, + ); + }); + + // Push autonomous events while foreground is active + const autonomousTurnId = "autonomous-during-fg-1"; + capturedSession!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "AUTONOMOUS_DURING_FOREGROUND" }, + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: autonomousTurnId, + }); + + releaseForeground.resolve(); + const foregroundEvents = await foregroundResults; + + // Foreground stream should contain its own turn events but NOT autonomous events + expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); + expect( + foregroundEvents.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND"), + ), + ).toBe(false); + + // Autonomous timeline item should still be recorded in the agent timeline + expect(manager.getTimeline(snapshot.id)).toContainEqual({ + type: "assistant_message", + text: "AUTONOMOUS_DURING_FOREGROUND", + }); +}); + +test("subscribe error isolation: throwing subscriber does not break event flow", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-subscribe-isolation-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + let capturedSession: TestAgentSession | null = null; + + class IsolationClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + const session = new TestAgentSession(config); + capturedSession = session; + return session; + } + } + + const manager = new AgentManager({ + clients: { + codex: new IsolationClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const receivedEvents: string[] = []; + const settled = new Promise((resolve) => { + manager.subscribe( + (event) => { + if ( + event.type === "agent_state" && + event.agent.id === snapshot.id && + event.agent.lifecycle === "idle" + ) { + resolve(); + } + if (event.type === "agent_stream" && event.agentId === snapshot.id) { + receivedEvents.push(event.event.type); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + }); + + const autonomousTurnId = "autonomous-isolation-1"; + capturedSession!.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "EVENT_AFTER_ERROR" }, + turnId: autonomousTurnId, + }); + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: autonomousTurnId, + }); + + await settled; + + expect(receivedEvents).toContain("turn_started"); + expect(receivedEvents).toContain("timeline"); + expect(receivedEvents).toContain("turn_completed"); + expect(manager.getTimeline(snapshot.id)).toContainEqual({ + type: "assistant_message", + text: "EVENT_AFTER_ERROR", + }); +}); + +test("keeps updatedAt monotonic when user message and run start happen in the same millisecond", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000); + try { + manager.recordUserMessage(snapshot.id, "hello"); + const afterMessage = manager.getAgent(snapshot.id); + expect(afterMessage).toBeDefined(); + const messageUpdatedAt = afterMessage!.updatedAt.getTime(); const stream = manager.streamAgent(snapshot.id, "hello"); + // Advance the generator so startTurn runs and lifecycle transitions to running await stream.next(); + const afterRunStart = manager.getAgent(snapshot.id); + expect(afterRunStart).toBeDefined(); + expect(afterRunStart!.updatedAt.getTime()).toBeGreaterThan(messageUpdatedAt); - await manager.closeAgent(snapshot.id); - - // Drain stream finalizer path after close(). + // Drain the rest of the stream while (true) { const next = await stream.next(); - if (next.done) { - break; + if (next.done) break; + } + } finally { + nowSpy.mockRestore(); + } +}); + +test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000121", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const lifecycleUpdates: string[] = []; + manager.subscribe((event) => { + if (event.type !== "agent_state" || event.agent.id !== snapshot.id) { + return; + } + lifecycleUpdates.push(event.agent.lifecycle); + }); + lifecycleUpdates.length = 0; + + manager.recordUserMessage(snapshot.id, "hello", { emitState: false }); + + expect(lifecycleUpdates).toEqual([]); +}); + +test("runAgent assembles finalText from trailing assistant chunks", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const expectedFinalText = + '```json\n{"message":"Reserve space for archive button in sidebar agent list"}\n```'; + + class ChunkedAssistantSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private subs = new Set<(event: AgentStreamEvent) => void>(); + private turnCounter = 0; + + async run(): Promise { + return { + sessionId: this.id, + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + const turnId = `chunked-turn-${++this.turnCounter}`; + setTimeout(() => { + for (const cb of this.subs) { + cb({ type: "turn_started", provider: this.provider, turnId }); + cb({ + type: "timeline", + provider: this.provider, + item: { + type: "assistant_message", + text: '```json\n{"message":"Reserve space for archive button in side', + }, + turnId, + }); + cb({ + type: "timeline", + provider: this.provider, + item: { + type: "assistant_message", + text: 'bar agent list"}\n```', + }, + turnId, + }); + cb({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subs.add(callback); + return () => { + this.subs.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: null, + modeId: null, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { + provider: this.provider, + sessionId: this.id, + }; + } + + async interrupt(): Promise {} + + async close(): Promise {} + } + + class ChunkedAssistantClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + return new ChunkedAssistantSession(); + } + + async resumeSession(): Promise { + return new ChunkedAssistantSession(); + } + } + + const manager = new AgentManager({ + clients: { + codex: new ChunkedAssistantClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000113", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const result = await manager.runAgent(snapshot.id, "generate commit message"); + expect(result.finalText).toBe(expectedFinalText); +}); + +test("listAgents excludes internal agents", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const generatedAgentIds = [ + "00000000-0000-4000-8000-000000000105", + "00000000-0000-4000-8000-000000000106", + ]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(), + }); + + // Create a normal agent + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Normal Agent", + }); + + // Create an internal agent + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }); + + const agents = manager.listAgents(); + expect(agents).toHaveLength(1); + expect(agents[0]?.config.title).toBe("Normal Agent"); +}); + +test("getAgent returns internal agents by ID", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000107"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }); + + const agent = manager.getAgent(internalAgentId); + expect(agent).not.toBeNull(); + expect(agent?.internal).toBe(true); +}); + +test("subscribe does not emit state events for internal agents to global subscribers", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const generatedAgentIds = [ + "00000000-0000-4000-8000-000000000108", + "00000000-0000-4000-8000-000000000109", + ]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => generatedAgentIds[agentCounter++] ?? randomUUID(), + }); + + const receivedEvents: string[] = []; + manager.subscribe((event) => { + if (event.type === "agent_state") { + receivedEvents.push(event.agent.id); + } + }); + + // Create a normal agent - should emit + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Normal Agent", + }); + + // Create an internal agent - should NOT emit to global subscriber + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }); + + // Should only have events from the normal agent + expect(receivedEvents.filter((id) => id === generatedAgentIds[0]).length).toBeGreaterThan(0); + expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0); +}); + +test("subscribe emits state events for internal agents when subscribed by agentId", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000110"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + + const receivedEvents: string[] = []; + // Subscribe specifically to the internal agent + manager.subscribe( + (event) => { + if (event.type === "agent_state") { + receivedEvents.push(event.agent.id); + } + }, + { agentId: internalAgentId, replayState: false }, + ); + + await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }); + + // Should receive events when subscribed by specific agentId + expect(receivedEvents.filter((id) => id === internalAgentId).length).toBeGreaterThan(0); +}); + +test("subscribe fails when filter agentId is not a UUID", () => { + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + logger, + }); + + expect(() => + manager.subscribe(() => {}, { + agentId: "invalid-agent-id", + }), + ).toThrow("subscribe: agentId must be a UUID"); +}); + +test("onAgentAttention is not called for internal agents", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000111"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const attentionCalls: string[] = []; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => internalAgentId, + onAgentAttention: ({ agentId }) => { + attentionCalls.push(agentId); + }, + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }); + + // Run and complete the agent (which normally triggers attention) + await manager.runAgent(agent.id, "hello"); + + // Should NOT have triggered attention callback for internal agent + expect(attentionCalls).toHaveLength(0); +}); + +test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class FailingSession extends TestAgentSession { + private attempt = 0; + + override async startTurn(): Promise<{ turnId: string }> { + this.attempt += 1; + const attempt = this.attempt; + const turnId = `fail-turn-${attempt}`; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "turn_failed", + provider: this.provider, + error: `boom-${attempt}`, + turnId, + }); + }, 0); + return { turnId }; + } + } + + class FailingClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new FailingSession(config); + } + + async resumeSession(config?: Partial): Promise { + return new FailingSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } + } + + const attentionReasons: Array<"finished" | "error" | "permission"> = []; + const manager = new AgentManager({ + clients: { + codex: new FailingClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000130", + onAgentAttention: ({ reason }) => { + attentionReasons.push(reason); + }, + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Attention transition test", + }); + + await expect(manager.runAgent(agent.id, "fail once")).rejects.toThrow("boom-1"); + await manager.flush(); + + const afterFirstFailure = manager.getAgent(agent.id); + expect(afterFirstFailure?.lifecycle).toBe("error"); + expect(afterFirstFailure?.attention.requiresAttention).toBe(true); + expect(afterFirstFailure?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + + const persistedAfterFirstFailure = await storage.get(agent.id); + expect(persistedAfterFirstFailure?.lastStatus).toBe("error"); + expect(persistedAfterFirstFailure?.requiresAttention).toBe(true); + expect(persistedAfterFirstFailure?.attentionReason).toBe("error"); + + await manager.clearAgentAttention(agent.id); + manager.notifyAgentState(agent.id); + await manager.flush(); + + const afterClear = manager.getAgent(agent.id); + expect(afterClear?.lifecycle).toBe("error"); + expect(afterClear?.attention).toEqual({ requiresAttention: false }); + + const persistedAfterClear = await storage.get(agent.id); + expect(persistedAfterClear?.lastStatus).toBe("error"); + expect(persistedAfterClear?.requiresAttention).toBe(false); + expect(persistedAfterClear?.attentionReason).toBeNull(); + + await expect(manager.runAgent(agent.id, "fail again")).rejects.toThrow("boom-2"); + await manager.flush(); + + const afterSecondFailure = manager.getAgent(agent.id); + expect(afterSecondFailure?.lifecycle).toBe("error"); + expect(afterSecondFailure?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(attentionReasons).toEqual(["error", "error"]); + + const persistedAfterSecondFailure = await storage.get(agent.id); + expect(persistedAfterSecondFailure?.lastStatus).toBe("error"); + expect(persistedAfterSecondFailure?.requiresAttention).toBe(true); + expect(persistedAfterSecondFailure?.attentionReason).toBe("error"); +}); + +test("archiveAgent persists archivedAt and updatedAt before emitting closed state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000131", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Archive target", + }); + + const lifecycles: string[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === agent.id) { + lifecycles.push(event.agent.lifecycle); + } + }, + { agentId: agent.id, replayState: false }, + ); + + const { archivedAt } = await manager.archiveAgent(agent.id); + const stored = await storage.get(agent.id); + + expect(stored).toMatchObject({ + id: agent.id, + archivedAt, + lastStatus: "closed", + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + }); + expect( + Math.abs(new Date(stored!.updatedAt).getTime() - new Date(archivedAt).getTime()), + ).toBeLessThanOrEqual(5); + expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]); +}); + +test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class TurnFailedSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-failed-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "turn_failed", + provider: this.provider, + error: "invalid model id", + turnId, + }); + }, 0); + return { turnId }; + } + } + + class TurnFailedClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new TurnFailedSession(config); + } + + async resumeSession(config?: Partial): Promise { + return new TurnFailedSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } + } + + const manager = new AgentManager({ + clients: { + codex: new TurnFailedClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000131", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Turn failed test", + }); + + await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("invalid model id"); + + const snapshot = manager.getAgent(agent.id); + expect(snapshot?.lifecycle).toBe("error"); + expect(snapshot?.lastError).toBe("invalid model id"); + + const systemErrors = manager + .getTimeline(agent.id) + .filter( + (item): item is Extract => + item.type === "assistant_message" && item.text.includes("[System Error]"), + ); + expect(systemErrors).toHaveLength(1); + expect(systemErrors[0]?.text).toContain("invalid model id"); +}); + +test("turn_failed surfaces provider code and diagnostic in system error message", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-detail-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class DetailedFailureSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-detailed-fail-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "turn_failed", + provider: this.provider, + error: "Provider execution failed", + code: "126", + diagnostic: "No preset version installed for command claude", + turnId, + }); + }, 0); + return { turnId }; + } + } + + class DetailedFailureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new DetailedFailureSession(config); + } + + async resumeSession(config?: Partial): Promise { + return new DetailedFailureSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } + } + + const manager = new AgentManager({ + clients: { + codex: new DetailedFailureClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000132", + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Detailed failure test", + }); + + await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("Provider execution failed"); + + const systemError = manager + .getTimeline(agent.id) + .find( + (item): item is Extract => + item.type === "assistant_message" && item.text.includes("[System Error]"), + ); + expect(systemError?.text).toContain("Provider execution failed"); + expect(systemError?.text).toContain("code: 126"); + expect(systemError?.text).toContain("No preset version installed for command claude"); +}); + +test("permission request notifies once without forcing unread attention state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + const releasePermissionResolution = deferred(); + + class PermissionSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-perm-1"; + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "permission_requested", + provider: this.provider, + request: { + id: "perm-1", + provider: this.provider, + kind: "tool", + name: "Read file", + }, + turnId, + }); + await releasePermissionResolution.promise; + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId: "perm-1", + resolution: { behavior: "allow" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class PermissionClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new PermissionSession(config); + } + + async resumeSession(config?: Partial): Promise { + return new PermissionSession({ + provider: "codex", + cwd: config?.cwd ?? process.cwd(), + }); + } + } + + const attentionReasons: Array<"finished" | "error" | "permission"> = []; + const manager = new AgentManager({ + clients: { + codex: new PermissionClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000131", + onAgentAttention: ({ reason }) => { + attentionReasons.push(reason); + }, + }); + + const agent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Permission transition test", + }); + + const stream = manager.streamAgent(agent.id, "permission flow"); + await stream.next(); // turn_started + await stream.next(); // permission_requested + + const withPermissionPending = manager.getAgent(agent.id); + expect(withPermissionPending?.pendingPermissions.size).toBe(1); + expect(withPermissionPending?.attention).toEqual({ requiresAttention: false }); + + // Release permission resolution and drain the rest of the stream + releasePermissionResolution.resolve(); + while (!(await stream.next()).done) { + // no-op + } + + expect(attentionReasons).toContain("permission"); +}); + +test("respondToPermission updates currentModeId after plan approval", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + // Create a session that simulates plan approval mode change + let sessionMode = "plan"; + class PlanModeTestSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private subs = new Set<(event: AgentStreamEvent) => void>(); + private turnCounter = 0; + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(): Promise<{ turnId: string }> { + const turnId = `plan-turn-${++this.turnCounter}`; + setTimeout(() => { + for (const cb of this.subs) { + cb({ type: "turn_started", provider: this.provider, turnId }); + cb({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subs.add(callback); + return () => { + this.subs.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { provider: this.provider, sessionId: this.id, model: null, modeId: sessionMode }; + } + + async getAvailableModes() { + return [ + { id: "plan", label: "Plan" }, + { id: "acceptEdits", label: "Accept Edits" }, + ]; + } + + async getCurrentMode() { + return sessionMode; + } + + async setMode(modeId: string): Promise { + sessionMode = modeId; + } + + getPendingPermissions() { + return []; + } + + async respondToPermission(_requestId: string, response: { behavior: string }): Promise { + // Simulate what claude-agent.ts does: when plan permission is approved, + // it calls setMode("acceptEdits") internally + if (response.behavior === "allow") { + sessionMode = "acceptEdits"; } } + describePersistence() { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise {} + async close(): Promise {} + } + + class PlanModeTestClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + return new PlanModeTestSession(); + } + + async resumeSession(): Promise { + return new PlanModeTestSession(); + } + } + + const manager = new AgentManager({ + clients: { + codex: new PlanModeTestClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000112", + }); + + // Create agent in plan mode + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + modeId: "plan", + }); + + expect(snapshot.currentModeId).toBe("plan"); + + // Simulate a pending plan permission request + const agent = manager.getAgent(snapshot.id)!; + const permissionRequest = { + id: "perm-123", + provider: "codex" as const, + name: "ExitPlanMode", + kind: "plan" as const, + input: { plan: "Test plan" }, + }; + agent.pendingPermissions.set(permissionRequest.id, permissionRequest); + + // Approve the plan permission + await manager.respondToPermission(snapshot.id, "perm-123", { + behavior: "allow", + }); + + // The session's mode has changed to "acceptEdits" internally + // The manager should have updated currentModeId to reflect this + const updatedAgent = manager.getAgent(snapshot.id); + expect(updatedAgent?.currentModeId).toBe("acceptEdits"); + + await manager.flush(); + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastModeId).toBe("acceptEdits"); +}); + +test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class RefreshingPermissionSession extends TestAgentSession { + private featureState: AgentFeature[] = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: true }), + ]; + private modeId = "auto"; + private pending = [ + { + id: "perm-plan-1", + provider: "codex" as const, + name: "CodexPlanApproval", + kind: "plan" as const, + input: { plan: "- Implement the feature" }, + }, + ]; + + get features(): AgentFeature[] { + return this.featureState; + } + + override async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4", + modeId: this.modeId, + extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" }, + }; + } + + override async getCurrentMode() { + return this.modeId; + } + + override getPendingPermissions() { + return this.pending; + } + + override async respondToPermission(): Promise { + this.modeId = "auto"; + this.pending = []; + this.featureState = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]; + } + } + + class RefreshingPermissionClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new RefreshingPermissionSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new RefreshingPermissionClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000133", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const agent = manager.getAgent(snapshot.id); + if (!agent) { + throw new Error("Expected managed agent"); + } + agent.pendingPermissions.set("perm-plan-1", { + id: "perm-plan-1", + provider: "codex", + name: "CodexPlanApproval", + kind: "plan", + input: { plan: "- Implement the feature" }, + }); + + await manager.respondToPermission(snapshot.id, "perm-plan-1", { + behavior: "allow", + selectedActionId: "implement", + }); + + const updated = manager.getAgent(snapshot.id); + expect(updated?.pendingPermissions.size).toBe(0); + expect(updated?.features).toEqual([ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]); + expect(updated?.runtimeInfo).toMatchObject({ + model: "gpt-5.4", + extra: { collaborationMode: "Code" }, + }); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.features).toEqual([ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]); +}); + +test("respondToPermission emits refreshed state before permission_resolved", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class OrderedPermissionSession extends TestAgentSession { + private featureState: AgentFeature[] = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + ]; + private modeId = "plan"; + private pending = [ + { + id: "perm-order-1", + provider: "codex" as const, + name: "ExitPlanMode", + kind: "plan" as const, + input: { plan: "- Do the work" }, + }, + ]; + + get features(): AgentFeature[] { + return this.featureState; + } + + override async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4", + modeId: this.modeId, + }; + } + + override async getCurrentMode() { + return this.modeId; + } + + override getPendingPermissions() { + return this.pending; + } + + override async respondToPermission(): Promise { + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId: "perm-order-1", + resolution: { behavior: "allow" }, + }); + this.modeId = "acceptEdits"; + this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })]; + this.pending = []; + } + } + + class OrderedPermissionClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new OrderedPermissionSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new OrderedPermissionClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000134", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const seen: string[] = []; + manager.subscribe((event) => { + if ("agentId" in event && event.agentId !== snapshot.id) { + return; + } + if (event.type === "agent_state" && event.agent.id === snapshot.id) { + const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode"); + seen.push( + `state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`, + ); + return; + } + if (event.type === "agent_stream" && event.event.type === "permission_resolved") { + seen.push(`resolved:${event.event.requestId}`); + } + }); + + await manager.respondToPermission(snapshot.id, "perm-order-1", { + behavior: "allow", + }); + + const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false"); + const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1"); + expect(refreshedStateIndex).toBeGreaterThanOrEqual(0); + expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex); +}); + +test("close during in-flight stream does not clear persistence sessionId", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CloseRaceSession implements AgentSession { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + readonly id = randomUUID(); + private threadId: string | null = this.id; + private closed = false; + private subscribers = new Set<(event: AgentStreamEvent) => void>(); + private turnIdCounter = 0; + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(): Promise<{ turnId: string }> { + const turnId = `turn-${++this.turnIdCounter}`; + // Push turn_started, then block until closed + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // The turn will be canceled when close() is called + }, 0); + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const cb of this.subscribers) { + try { + cb(event); + } catch { + /* isolation */ + } + } + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.threadId, + model: null, + modeId: null, + }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + if (!this.threadId) { + return null; + } + return { provider: this.provider, sessionId: this.threadId }; + } + + async interrupt(): Promise { + this.closed = true; + // Push turn_canceled for any active turn + if (this.turnIdCounter > 0) { + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "interrupted", + turnId: `turn-${this.turnIdCounter}`, + }); + } + } + + async close(): Promise { + this.closed = true; + this.threadId = null; + // Push turn_canceled for any active turn + if (this.turnIdCounter > 0) { + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "closed", + turnId: `turn-${this.turnIdCounter}`, + }); + } + } + } + + class CloseRaceClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + return new CloseRaceSession(); + } + + async resumeSession(): Promise { + return new CloseRaceSession(); + } + } + + const manager = new AgentManager({ + clients: { + codex: new CloseRaceClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000113", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const stream = manager.streamAgent(snapshot.id, "hello"); + await stream.next(); + + await manager.closeAgent(snapshot.id); + + // Drain stream finalizer path after close(). + while (true) { + const next = await stream.next(); + if (next.done) { + break; + } + } + + await manager.flush(); + await storage.flush(); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId); +}); + +test("closeAgent persists one final closed snapshot", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-no-persist-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const applySnapshotSpy = vi.spyOn(storage, "applySnapshot"); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000112", + }); + + try { + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + await manager.flush(); - await storage.flush(); - - const persisted = await storage.get(snapshot.id); - expect(persisted?.persistence?.sessionId).toBe(snapshot.persistence?.sessionId); - }); - - test("closeAgent persists one final closed snapshot", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-no-persist-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const applySnapshotSpy = vi.spyOn(storage, "applySnapshot"); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000112", - }); - - try { - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.flush(); - const persistCountBeforeClose = applySnapshotSpy.mock.calls.length; - - await manager.closeAgent(snapshot.id); - await manager.flush(); - - expect(applySnapshotSpy).toHaveBeenCalledTimes(persistCountBeforeClose + 1); - } finally { - applySnapshotSpy.mockRestore(); - await manager.flush().catch(() => undefined); - await storage.flush().catch(() => undefined); - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - // Session whose streamHistory yields user_message + assistant_message items. - // This simulates Codex provider replaying its thread history on resume. - class HistoryWithUserMessagesSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "hello from user", messageId: "msg_client_1" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "hi there" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "second question", messageId: "msg_client_2" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "second answer" }, - }; - } - } - - class HistoryUserMessageClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - // Fresh session yields history with user messages (simulates Codex resume) - return new HistoryWithUserMessagesSession(config); - } - - async resumeSession(): Promise { - throw new Error("Not used in this test"); - } - } - - const manager = new AgentManager({ - clients: { - codex: new HistoryUserMessageClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000200", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - // Simulate canonical user messages already recorded by recordUserMessage - // (the path that session.ts takes when user sends a message) - manager.recordUserMessage(snapshot.id, "hello from user", { - messageId: "msg_client_1", - }); - manager.recordUserMessage(snapshot.id, "second question", { - messageId: "msg_client_2", - }); - - const beforeHydrate = manager.getTimeline(snapshot.id); - const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message"); - expect(userMessagesBefore).toHaveLength(2); - - // hydrateTimeline replays provider history which includes user_message - // items. These should NOT create duplicate rows since recordUserMessage - // already created canonical entries. - await manager.hydrateTimelineFromProvider(snapshot.id); - - const afterHydrate = manager.getTimeline(snapshot.id); - const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message"); - - // Should still have exactly 2 user messages, not 4 - expect(userMessagesAfter).toHaveLength(2); - - // Non-user_message items from history should still be replayed - const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message"); - expect(assistantMessages).toHaveLength(2); - }); - - test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HistoryWithUserMessagesSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "hello from user", messageId: "msg_history_1" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "hi there" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "second question", messageId: "msg_history_2" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "second answer" }, - }; - } - } - - class HistoryUserMessageClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - - async isAvailable(): Promise { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - return new HistoryWithUserMessagesSession(config); - } - - async resumeSession(): Promise { - throw new Error("Not used in this test"); - } - } - - const manager = new AgentManager({ - clients: { - codex: new HistoryUserMessageClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000203", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.hydrateTimelineFromProvider(snapshot.id); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter((item) => item.type === "user_message"); - const assistantMessages = timeline.filter((item) => item.type === "assistant_message"); - expect(userMessages).toHaveLength(2); - expect(assistantMessages).toHaveLength(2); - }); - - test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HistoryWithMixedUserMessagesSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - async *streamHistory(): AsyncGenerator { - yield { - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_client_hello", - }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "hi there" }, - }; - yield { - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_provider_distinct", - }, - }; - } - } - - class HistoryMixedClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - async isAvailable(): Promise { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new HistoryWithMixedUserMessagesSession(config); - } - async resumeSession(): Promise { - throw new Error("Not used in this test"); - } - } - - const manager = new AgentManager({ - clients: { codex: new HistoryMixedClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000204", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - // Canonical user message that should dedupe the matching history item. - manager.recordUserMessage(snapshot.id, "hello from user", { - messageId: "msg_client_hello", - }); - - await manager.hydrateTimelineFromProvider(snapshot.id); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter((item) => item.type === "user_message"); - expect(userMessages).toHaveLength(2); - expect( - userMessages.map( - (item) => (item as Extract).messageId, - ), - ).toEqual(["msg_client_hello", "msg_provider_distinct"]); - expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]); - }); - - test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-blank-msgid-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000201", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - // Empty string should be treated as missing - manager.recordUserMessage(snapshot.id, "test empty", { - messageId: "", - }); - - // Whitespace-only should be treated as missing - manager.recordUserMessage(snapshot.id, "test whitespace", { - messageId: " ", - }); - - // Valid messageId should be preserved - manager.recordUserMessage(snapshot.id, "test valid", { - messageId: "msg_valid_123", - }); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter( - (item): item is Extract => - item.type === "user_message", - ); - - expect(userMessages).toHaveLength(3); - // Empty string → undefined (not empty string) - expect(userMessages[0]!.messageId).toBeUndefined(); - // Whitespace → undefined - expect(userMessages[1]!.messageId).toBeUndefined(); - // Valid → preserved - expect(userMessages[2]!.messageId).toBe("msg_valid_123"); - }); - - test("recordUserMessage preserves provided messageId in timeline item and dispatched event", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-msgid-passthrough-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000202", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const events: AgentStreamEvent[] = []; - manager.subscribe((event) => { - if (event.type === "agent_stream") { - events.push(event.event); - } - }); - - const clientMsgId = "msg_abc_123_def"; - manager.recordUserMessage(snapshot.id, "hello", { - messageId: clientMsgId, - }); - - // Timeline item should have the messageId - const timeline = manager.getTimeline(snapshot.id); - const userMsg = timeline.find( - (item): item is Extract => - item.type === "user_message", - ); - expect(userMsg).toBeDefined(); - expect(userMsg!.messageId).toBe(clientMsgId); - - // Dispatched stream event should also carry the messageId - const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message"); - expect(streamEvent).toBeDefined(); - if (streamEvent?.type === "timeline") { - expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe( - clientMsgId, - ); - } - }); - - test("live provider user_message echo is suppressed when recordUserMessage was called first", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-echo-dedup-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - // Session whose live turn echoes the user message (as Claude does) - class EchoUserMessageSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-echo-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - // Provider echoes user message during live run - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_client_echo_1", - }, - turnId, - }); - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "hello from assistant" }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class EchoClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - async isAvailable(): Promise { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new EchoUserMessageSession(config); - } - async resumeSession(): Promise { - throw new Error("unused"); - } - } - - const manager = new AgentManager({ - clients: { codex: new EchoClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000400", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - // Canonical recording (what session.ts does before starting stream) - manager.recordUserMessage(snapshot.id, "hello from user", { - messageId: "msg_client_echo_1", - }); - - // Run triggers startTurn(), which echoes user_message - await manager.runAgent(snapshot.id, { text: "hello from user" }); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter((item) => item.type === "user_message"); - - // Should be exactly 1 (canonical), not 2 (canonical + provider echo) - expect(userMessages).toHaveLength(1); - // The canonical one must carry the client messageId for optimistic matching - expect( - (userMessages[0] as Extract).messageId, - ).toBe("msg_client_echo_1"); - - // Assistant messages from the run should still appear - const assistantMessages = timeline.filter((item) => item.type === "assistant_message"); - expect(assistantMessages).toHaveLength(1); - }); - - test("live provider user_message with different messageId is NOT suppressed", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-different-msgid-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class DifferentMessageIdSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-diff-msgid-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { - type: "user_message", - text: "hello from user", - messageId: "msg_provider_other", - }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class DifferentMessageIdClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - async isAvailable(): Promise { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new DifferentMessageIdSession(config); - } - async resumeSession(): Promise { - throw new Error("unused"); - } - } - - const manager = new AgentManager({ - clients: { codex: new DifferentMessageIdClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000402", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - manager.recordUserMessage(snapshot.id, "hello from user", { - messageId: "msg_client_echo_2", - }); - - await manager.runAgent(snapshot.id, { text: "hello from user" }); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter( - (item): item is Extract => - item.type === "user_message", - ); - expect(userMessages).toHaveLength(2); - expect(userMessages.map((item) => item.messageId)).toEqual([ - "msg_client_echo_2", - "msg_provider_other", - ]); - }); - - test("live provider user_message without messageId is NOT suppressed", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-no-msgid-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class NoMessageIdSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-no-msgid-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "hello from user" }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class NoMessageIdClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - async isAvailable(): Promise { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new NoMessageIdSession(config); - } - async resumeSession(): Promise { - throw new Error("unused"); - } - } - - const manager = new AgentManager({ - clients: { codex: new NoMessageIdClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000403", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - manager.recordUserMessage(snapshot.id, "hello from user", { - messageId: "msg_client_echo_3", - }); - - await manager.runAgent(snapshot.id, { text: "hello from user" }); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter((item) => item.type === "user_message"); - expect(userMessages).toHaveLength(2); - }); - - test("provider user_message is NOT suppressed when no prior recordUserMessage", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - // Session whose live turn yields a user_message without prior canonical recording - class UnexpectedUserMessageSession extends TestAgentSession { - constructor(config: AgentSessionConfig) { - super(config); - } - - override async startTurn(): Promise<{ turnId: string }> { - const turnId = "turn-unexpected-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - // Provider yields user_message (e.g., system continuation) - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "continuation prompt" }, - turnId, - }); - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "continuation reply" }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); - return { turnId }; - } - } - - class UnexpectedUserMsgClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; - async isAvailable(): Promise { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new UnexpectedUserMessageSession(config); - } - async resumeSession(): Promise { - throw new Error("unused"); - } - } - - const manager = new AgentManager({ - clients: { codex: new UnexpectedUserMsgClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000401", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - // No recordUserMessage — run directly - await manager.runAgent(snapshot.id, { text: "do something" }); - - const timeline = manager.getTimeline(snapshot.id); - const userMessages = timeline.filter((item) => item.type === "user_message"); - - // Provider's user_message should be recorded (no canonical to dedup against) - expect(userMessages).toHaveLength(1); - expect((userMessages[0] as Extract).text).toBe( - "continuation prompt", - ); - }); - - test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - const allowSecondRunToEnd = deferred(); - - // Session where the first foreground turn never emits a terminal event - // (simulates the claude-agent pendingInterruptAbort suppression bug), - // and interrupt() does not produce events either. - class StaleForegroundSession extends TestAgentSession { - override async startTurn(): Promise<{ turnId: string }> { - this.interrupted = false; - const turnId = `turn-${++this.turnIdCounter}`; - const turnNum = this.turnIdCounter; - - setTimeout(async () => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - if (turnNum === 1) { - // First turn: emit turn_started but NEVER emit a terminal event. - // This simulates the provider suppressing the result. - } else { - // Subsequent turns: complete normally - await allowSecondRunToEnd.promise; - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - } - }, 0); - return { turnId }; - } - - override async interrupt(): Promise { - this.interrupted = true; - // No events produced — the terminal event was suppressed - } - } - - class StaleForegroundClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new StaleForegroundSession(config); - } - } - - const manager = new AgentManager({ - clients: { codex: new StaleForegroundClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000500", - }); - - const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); - - // Start first foreground run — it will hang (no terminal event) - const firstRun = manager.streamAgent(snapshot.id, "hanging prompt"); - const firstRunDrain = (async () => { - for await (const _event of firstRun) { - // Draining — will hang until force-cleaned - } - })(); - - await manager.waitForAgentRunStart(snapshot.id); - - const beforeReplace = manager.getAgent(snapshot.id); - expect(beforeReplace?.lifecycle).toBe("running"); - expect(beforeReplace?.activeForegroundTurnId).toBe("turn-1"); - - // Replace the hung run. cancelAgentRun will time out after 2s because - // no terminal event arrives. After the fix, it should force-clear the - // stale foreground state so streamAgent can proceed. - const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt"); - const collectedEvents: AgentStreamEvent[] = []; - const secondRunDrain = (async () => { - for await (const event of secondRun) { - collectedEvents.push(event); - } - })(); - - await manager.waitForAgentRunStart(snapshot.id); - allowSecondRunToEnd.resolve(); - - await secondRunDrain; - await firstRunDrain; - - expect(collectedEvents.some((e) => e.type === "turn_completed")).toBe(true); - expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); - expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull(); - }, 10_000); + const persistCountBeforeClose = applySnapshotSpy.mock.calls.length; + + await manager.closeAgent(snapshot.id); + await manager.flush(); + + expect(applySnapshotSpy).toHaveBeenCalledTimes(persistCountBeforeClose + 1); + } finally { + applySnapshotSpy.mockRestore(); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } }); + +test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + // Session whose streamHistory yields user_message + assistant_message items. + // This simulates Codex provider replaying its thread history on resume. + class HistoryWithUserMessagesSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "hello from user", messageId: "msg_client_1" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "hi there" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "second question", messageId: "msg_client_2" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "second answer" }, + }; + } + } + + class HistoryUserMessageClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + // Fresh session yields history with user messages (simulates Codex resume) + return new HistoryWithUserMessagesSession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new HistoryUserMessageClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000200", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + // Simulate canonical user messages already recorded by recordUserMessage + // (the path that session.ts takes when user sends a message) + manager.recordUserMessage(snapshot.id, "hello from user", { + messageId: "msg_client_1", + }); + manager.recordUserMessage(snapshot.id, "second question", { + messageId: "msg_client_2", + }); + + const beforeHydrate = manager.getTimeline(snapshot.id); + const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message"); + expect(userMessagesBefore).toHaveLength(2); + + // hydrateTimeline replays provider history which includes user_message + // items. These should NOT create duplicate rows since recordUserMessage + // already created canonical entries. + await manager.hydrateTimelineFromProvider(snapshot.id); + + const afterHydrate = manager.getTimeline(snapshot.id); + const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message"); + + // Should still have exactly 2 user messages, not 4 + expect(userMessagesAfter).toHaveLength(2); + + // Non-user_message items from history should still be replayed + const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message"); + expect(assistantMessages).toHaveLength(2); +}); + +test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HistoryWithUserMessagesSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "hello from user", messageId: "msg_history_1" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "hi there" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "second question", messageId: "msg_history_2" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "second answer" }, + }; + } + } + + class HistoryUserMessageClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + + async isAvailable(): Promise { + return true; + } + + async createSession(config: AgentSessionConfig): Promise { + return new HistoryWithUserMessagesSession(config); + } + + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { + codex: new HistoryUserMessageClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000203", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter((item) => item.type === "user_message"); + const assistantMessages = timeline.filter((item) => item.type === "assistant_message"); + expect(userMessages).toHaveLength(2); + expect(assistantMessages).toHaveLength(2); +}); + +test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HistoryWithMixedUserMessagesSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_client_hello", + }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "hi there" }, + }; + yield { + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_provider_distinct", + }, + }; + } + } + + class HistoryMixedClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { + return new HistoryWithMixedUserMessagesSession(config); + } + async resumeSession(): Promise { + throw new Error("Not used in this test"); + } + } + + const manager = new AgentManager({ + clients: { codex: new HistoryMixedClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000204", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + // Canonical user message that should dedupe the matching history item. + manager.recordUserMessage(snapshot.id, "hello from user", { + messageId: "msg_client_hello", + }); + + await manager.hydrateTimelineFromProvider(snapshot.id); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter((item) => item.type === "user_message"); + expect(userMessages).toHaveLength(2); + expect( + userMessages.map( + (item) => (item as Extract).messageId, + ), + ).toEqual(["msg_client_hello", "msg_provider_distinct"]); + expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]); +}); + +test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-blank-msgid-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000201", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + // Empty string should be treated as missing + manager.recordUserMessage(snapshot.id, "test empty", { + messageId: "", + }); + + // Whitespace-only should be treated as missing + manager.recordUserMessage(snapshot.id, "test whitespace", { + messageId: " ", + }); + + // Valid messageId should be preserved + manager.recordUserMessage(snapshot.id, "test valid", { + messageId: "msg_valid_123", + }); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter( + (item): item is Extract => + item.type === "user_message", + ); + + expect(userMessages).toHaveLength(3); + // Empty string → undefined (not empty string) + expect(userMessages[0]!.messageId).toBeUndefined(); + // Whitespace → undefined + expect(userMessages[1]!.messageId).toBeUndefined(); + // Valid → preserved + expect(userMessages[2]!.messageId).toBe("msg_valid_123"); +}); + +test("recordUserMessage preserves provided messageId in timeline item and dispatched event", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-msgid-passthrough-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000202", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const events: AgentStreamEvent[] = []; + manager.subscribe((event) => { + if (event.type === "agent_stream") { + events.push(event.event); + } + }); + + const clientMsgId = "msg_abc_123_def"; + manager.recordUserMessage(snapshot.id, "hello", { + messageId: clientMsgId, + }); + + // Timeline item should have the messageId + const timeline = manager.getTimeline(snapshot.id); + const userMsg = timeline.find( + (item): item is Extract => + item.type === "user_message", + ); + expect(userMsg).toBeDefined(); + expect(userMsg!.messageId).toBe(clientMsgId); + + // Dispatched stream event should also carry the messageId + const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message"); + expect(streamEvent).toBeDefined(); + if (streamEvent?.type === "timeline") { + expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe( + clientMsgId, + ); + } +}); + +test("live provider user_message echo is suppressed when recordUserMessage was called first", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-echo-dedup-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + // Session whose live turn echoes the user message (as Claude does) + class EchoUserMessageSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-echo-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // Provider echoes user message during live run + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_client_echo_1", + }, + turnId, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "hello from assistant" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class EchoClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { + return new EchoUserMessageSession(config); + } + async resumeSession(): Promise { + throw new Error("unused"); + } + } + + const manager = new AgentManager({ + clients: { codex: new EchoClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000400", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + // Canonical recording (what session.ts does before starting stream) + manager.recordUserMessage(snapshot.id, "hello from user", { + messageId: "msg_client_echo_1", + }); + + // Run triggers startTurn(), which echoes user_message + await manager.runAgent(snapshot.id, { text: "hello from user" }); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter((item) => item.type === "user_message"); + + // Should be exactly 1 (canonical), not 2 (canonical + provider echo) + expect(userMessages).toHaveLength(1); + // The canonical one must carry the client messageId for optimistic matching + expect((userMessages[0] as Extract).messageId).toBe( + "msg_client_echo_1", + ); + + // Assistant messages from the run should still appear + const assistantMessages = timeline.filter((item) => item.type === "assistant_message"); + expect(assistantMessages).toHaveLength(1); +}); + +test("live provider user_message with different messageId is NOT suppressed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-different-msgid-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class DifferentMessageIdSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-diff-msgid-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { + type: "user_message", + text: "hello from user", + messageId: "msg_provider_other", + }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class DifferentMessageIdClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { + return new DifferentMessageIdSession(config); + } + async resumeSession(): Promise { + throw new Error("unused"); + } + } + + const manager = new AgentManager({ + clients: { codex: new DifferentMessageIdClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000402", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + manager.recordUserMessage(snapshot.id, "hello from user", { + messageId: "msg_client_echo_2", + }); + + await manager.runAgent(snapshot.id, { text: "hello from user" }); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter( + (item): item is Extract => + item.type === "user_message", + ); + expect(userMessages).toHaveLength(2); + expect(userMessages.map((item) => item.messageId)).toEqual([ + "msg_client_echo_2", + "msg_provider_other", + ]); +}); + +test("live provider user_message without messageId is NOT suppressed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-no-msgid-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class NoMessageIdSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-no-msgid-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "hello from user" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class NoMessageIdClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { + return new NoMessageIdSession(config); + } + async resumeSession(): Promise { + throw new Error("unused"); + } + } + + const manager = new AgentManager({ + clients: { codex: new NoMessageIdClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000403", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + manager.recordUserMessage(snapshot.id, "hello from user", { + messageId: "msg_client_echo_3", + }); + + await manager.runAgent(snapshot.id, { text: "hello from user" }); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter((item) => item.type === "user_message"); + expect(userMessages).toHaveLength(2); +}); + +test("provider user_message is NOT suppressed when no prior recordUserMessage", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + // Session whose live turn yields a user_message without prior canonical recording + class UnexpectedUserMessageSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + } + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "turn-unexpected-1"; + setTimeout(() => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // Provider yields user_message (e.g., system continuation) + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "continuation prompt" }, + turnId, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "continuation reply" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + }, 0); + return { turnId }; + } + } + + class UnexpectedUserMsgClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + async isAvailable(): Promise { + return true; + } + async createSession(config: AgentSessionConfig): Promise { + return new UnexpectedUserMessageSession(config); + } + async resumeSession(): Promise { + throw new Error("unused"); + } + } + + const manager = new AgentManager({ + clients: { codex: new UnexpectedUserMsgClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000401", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + // No recordUserMessage — run directly + await manager.runAgent(snapshot.id, { text: "do something" }); + + const timeline = manager.getTimeline(snapshot.id); + const userMessages = timeline.filter((item) => item.type === "user_message"); + + // Provider's user_message should be recorded (no canonical to dedup against) + expect(userMessages).toHaveLength(1); + expect((userMessages[0] as Extract).text).toBe( + "continuation prompt", + ); +}); + +test("replaceAgentRun succeeds when foreground turn terminal event is never delivered", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-fg-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const allowSecondRunToEnd = deferred(); + + // Session where the first foreground turn never emits a terminal event + // (simulates the claude-agent pendingInterruptAbort suppression bug), + // and interrupt() does not produce events either. + class StaleForegroundSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + this.interrupted = false; + const turnId = `turn-${++this.turnIdCounter}`; + const turnNum = this.turnIdCounter; + + setTimeout(async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + if (turnNum === 1) { + // First turn: emit turn_started but NEVER emit a terminal event. + // This simulates the provider suppressing the result. + } else { + // Subsequent turns: complete normally + await allowSecondRunToEnd.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + }, 0); + return { turnId }; + } + + override async interrupt(): Promise { + this.interrupted = true; + // No events produced — the terminal event was suppressed + } + } + + class StaleForegroundClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new StaleForegroundSession(config); + } + } + + const manager = new AgentManager({ + clients: { codex: new StaleForegroundClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000500", + }); + + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }); + + // Start first foreground run — it will hang (no terminal event) + const firstRun = manager.streamAgent(snapshot.id, "hanging prompt"); + const firstRunDrain = (async () => { + for await (const _event of firstRun) { + // Draining — will hang until force-cleaned + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + + const beforeReplace = manager.getAgent(snapshot.id); + expect(beforeReplace?.lifecycle).toBe("running"); + expect(beforeReplace?.activeForegroundTurnId).toBe("turn-1"); + + // Replace the hung run. cancelAgentRun will time out after 2s because + // no terminal event arrives. After the fix, it should force-clear the + // stale foreground state so streamAgent can proceed. + const secondRun = manager.replaceAgentRun(snapshot.id, "replacement prompt"); + const collectedEvents: AgentStreamEvent[] = []; + const secondRunDrain = (async () => { + for await (const event of secondRun) { + collectedEvents.push(event); + } + })(); + + await manager.waitForAgentRunStart(snapshot.id); + allowSecondRunToEnd.resolve(); + + await secondRunDrain; + await firstRunDrain; + + expect(collectedEvents.some((e) => e.type === "turn_completed")).toBe(true); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); + expect(manager.getAgent(snapshot.id)?.activeForegroundTurnId).toBeNull(); +}, 10_000); diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 077462e0de..0a7945a0d2 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -105,613 +105,611 @@ async function waitFor(options: { throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`); } -describe("MCP parity end-to-end", () => { - let tempRoot: string; - let daemonHandle: TestPaseoDaemon; - let topLevelClient: McpClient; - let agentScopedClient: McpClient; - let parentAgentId: string; - let parentAgentCwd: string; - let worktreeRepoCwd: string; +let tempRoot: string; +let daemonHandle: TestPaseoDaemon; +let topLevelClient: McpClient; +let agentScopedClient: McpClient; +let parentAgentId: string; +let parentAgentCwd: string; +let worktreeRepoCwd: string; - async function makeCwd(prefix: string): Promise { - return await mkdtemp(path.join(tempRoot, `${prefix}-`)); +async function makeCwd(prefix: string): Promise { + return await mkdtemp(path.join(tempRoot, `${prefix}-`)); +} + +async function createTopLevelAgent(args?: Partial): Promise { + const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd")); + const payload = await callToolStructured(topLevelClient, "create_agent", { + cwd, + title: "Parity agent", + provider: "claude/claude-test-model", + initialPrompt: "say done and stop", + mode: "bypassPermissions", + background: true, + ...args, + }); + return payload.agentId as string; +} + +async function createChildAgent(args?: Partial): Promise { + const payload = await callToolStructured(agentScopedClient, "create_agent", { + title: "Parity child", + provider: "claude/claude-test-model", + initialPrompt: "say done and stop", + background: true, + ...args, + }); + return payload.agentId as string; +} + +async function archiveAgentIfPresent(agentId: string | null | undefined): Promise { + if (!agentId) { + return; } + try { + await topLevelClient.callTool({ name: "archive_agent", args: { agentId } }); + } catch { + // ignore cleanup errors + } +} - async function createTopLevelAgent(args?: Partial): Promise { - const cwd = (args?.cwd as string | undefined) ?? (await makeCwd("agent-cwd")); - const payload = await callToolStructured(topLevelClient, "create_agent", { - cwd, - title: "Parity agent", - provider: "claude/claude-test-model", - initialPrompt: "say done and stop", - mode: "bypassPermissions", - background: true, - ...args, +async function deleteScheduleIfPresent(id: string | null | undefined): Promise { + if (!id) { + return; + } + try { + await topLevelClient.callTool({ name: "delete_schedule", args: { id } }); + } catch { + // ignore cleanup errors + } +} + +async function killTerminalIfPresent(terminalId: string | null | undefined): Promise { + if (!terminalId) { + return; + } + try { + await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } }); + } catch { + // ignore cleanup errors + } +} + +async function archiveWorktreeIfPresent(params: { + cwd: string; + worktreePath?: string | null; + worktreeSlug?: string | null; +}): Promise { + if (!params.worktreePath && !params.worktreeSlug) { + return; + } + try { + await topLevelClient.callTool({ + name: "archive_worktree", + args: { + cwd: params.cwd, + ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), + ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), + }, }); - return payload.agentId as string; + } catch { + // ignore cleanup errors } +} - async function createChildAgent(args?: Partial): Promise { - const payload = await callToolStructured(agentScopedClient, "create_agent", { - title: "Parity child", - provider: "claude/claude-test-model", - initialPrompt: "say done and stop", - background: true, - ...args, - }); - return payload.agentId as string; - } +beforeAll(async () => { + tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); + parentAgentCwd = await makeCwd("parent-agent-cwd"); + worktreeRepoCwd = await makeCwd("worktree-repo"); - async function archiveAgentIfPresent(agentId: string | null | undefined): Promise { - if (!agentId) { - return; - } + daemonHandle = await createTestPaseoDaemon(); + topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`); + + const parentPayload = await callToolStructured(topLevelClient, "create_agent", { + cwd: parentAgentCwd, + title: "MCP parity parent", + provider: "claude/claude-test-model", + initialPrompt: "say done and stop", + mode: "bypassPermissions", + background: true, + }); + parentAgentId = parentPayload.agentId as string; + + agentScopedClient = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`, + ); + + execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" }); + await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8"); + execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'init'", { + cwd: worktreeRepoCwd, + stdio: "pipe", + }); +}, 30_000); + +afterAll(async () => { + await archiveAgentIfPresent(parentAgentId); + await agentScopedClient?.close(); + await topLevelClient?.close(); + await daemonHandle?.close(); + await rm(tempRoot, { recursive: true, force: true }); +}); + +describe("Suite A: Core Fixes", () => { + test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { + expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); + }); + + test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => { + let agentId: string | null = null; try { - await topLevelClient.callTool({ name: "archive_agent", args: { agentId } }); - } catch { - // ignore cleanup errors - } - } - - async function deleteScheduleIfPresent(id: string | null | undefined): Promise { - if (!id) { - return; - } - try { - await topLevelClient.callTool({ name: "delete_schedule", args: { id } }); - } catch { - // ignore cleanup errors - } - } - - async function killTerminalIfPresent(terminalId: string | null | undefined): Promise { - if (!terminalId) { - return; - } - try { - await agentScopedClient.callTool({ name: "kill_terminal", args: { terminalId } }); - } catch { - // ignore cleanup errors - } - } - - async function archiveWorktreeIfPresent(params: { - cwd: string; - worktreePath?: string | null; - worktreeSlug?: string | null; - }): Promise { - if (!params.worktreePath && !params.worktreeSlug) { - return; - } - try { - await topLevelClient.callTool({ - name: "archive_worktree", - args: { - cwd: params.cwd, - ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), - ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), - }, + agentId = await createChildAgent(); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.labels).toMatchObject({ + "paseo.parent-agent-id": parentAgentId, }); - } catch { - // ignore cleanup errors + } finally { + await archiveAgentIfPresent(agentId); } - } - - beforeAll(async () => { - tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); - parentAgentCwd = await makeCwd("parent-agent-cwd"); - worktreeRepoCwd = await makeCwd("worktree-repo"); - - daemonHandle = await createTestPaseoDaemon(); - topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`); - - const parentPayload = await callToolStructured(topLevelClient, "create_agent", { - cwd: parentAgentCwd, - title: "MCP parity parent", - provider: "claude/claude-test-model", - initialPrompt: "say done and stop", - mode: "bypassPermissions", - background: true, - }); - parentAgentId = parentPayload.agentId as string; - - agentScopedClient = await createMcpClient( - `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${parentAgentId}`, - ); - - execSync("git init -b main", { cwd: worktreeRepoCwd, stdio: "pipe" }); - execSync("git config user.email 'test@example.com'", { cwd: worktreeRepoCwd, stdio: "pipe" }); - execSync("git config user.name 'Test User'", { cwd: worktreeRepoCwd, stdio: "pipe" }); - await writeFile(path.join(worktreeRepoCwd, "README.md"), "# repo\n", "utf8"); - execSync("git add README.md", { cwd: worktreeRepoCwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'init'", { - cwd: worktreeRepoCwd, - stdio: "pipe", - }); - }, 30_000); - - afterAll(async () => { - await archiveAgentIfPresent(parentAgentId); - await agentScopedClient?.close(); - await topLevelClient?.close(); - await daemonHandle?.close(); - await rm(tempRoot, { recursive: true, force: true }); }); - describe("Suite A: Core Fixes", () => { - test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { - expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); - }); + test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => { + let agentId: string | null = null; + try { + const listenTarget = daemonHandle.daemon.getListenTarget(); + expect(listenTarget?.type).toBe("tcp"); - test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => { - let agentId: string | null = null; - try { - agentId = await createChildAgent(); - const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); - expect(snapshot?.labels).toMatchObject({ - "paseo.parent-agent-id": parentAgentId, - }); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - - test("agentManager.createAgent injects paseo MCP using the daemon listen target", async () => { - let agentId: string | null = null; - try { - const listenTarget = daemonHandle.daemon.getListenTarget(); - expect(listenTarget?.type).toBe("tcp"); - - const snapshot = await daemonHandle.daemon.agentManager.createAgent({ - provider: "claude", - cwd: await makeCwd("manager-direct-agent-cwd"), - title: "Manager direct parity agent", - modeId: "bypassPermissions", - }); - agentId = snapshot.id; - - const expectedUrl = buildExpectedAgentMcpUrl({ - host: listenTarget!.host, - port: listenTarget!.port, - agentId, - }); - - expect(snapshot.config.mcpServers).toMatchObject({ - paseo: { - type: "http", - url: expectedUrl, - }, - }); - - const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId); - expect(liveAgent?.config.mcpServers).toMatchObject({ - paseo: { - type: "http", - url: expectedUrl, - }, - }); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - - test("create_agent accepts provider/model syntax", async () => { - let agentId: string | null = null; - try { - agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" }); - const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); - expect(snapshot?.config.model).toBe("claude-test-model"); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - - test("create_agent accepts labels param", async () => { - let agentId: string | null = null; - try { - agentId = await createTopLevelAgent({ labels: { team: "infra" } }); - const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); - expect(snapshot?.labels).toMatchObject({ team: "infra" }); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - - test("archive_agent archives an agent", async () => { - let agentId: string | null = null; - try { - agentId = await createTopLevelAgent(); - const archivedAgentId = agentId; - await callToolStructured(topLevelClient, "archive_agent", { agentId }); - agentId = null; - - const agents = daemonHandle.daemon.agentManager.listAgents(); - expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - - test("update_agent updates name and labels", async () => { - let agentId: string | null = null; - try { - agentId = await createTopLevelAgent(); - await callToolStructured(topLevelClient, "update_agent", { - agentId, - name: "Renamed parity agent", - labels: { team: "infra", surface: "mcp" }, - }); - - const stored = await daemonHandle.daemon.agentStorage.get(agentId); - const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); - expect(stored?.title).toBe("Renamed parity agent"); - expect(snapshot?.labels).toMatchObject({ - team: "infra", - surface: "mcp", - }); - } finally { - await archiveAgentIfPresent(agentId); - } - }); - }); - - describe("Suite B: Terminal Tools", () => { - test("create_terminal and list_terminals", async () => { - let terminalId: string | null = null; - try { - const created = await callToolStructured(agentScopedClient, "create_terminal", { - name: "Parity terminal", - }); - terminalId = created.id as string; - - const listed = await callToolStructured(agentScopedClient, "list_terminals"); - const terminals = listed.terminals as Array; - expect(terminals).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: terminalId, - name: "Parity terminal", - cwd: parentAgentCwd, - }), - ]), - ); - } finally { - await killTerminalIfPresent(terminalId); - } - }); - - test("send_terminal_keys and capture_terminal", async () => { - let terminalId: string | null = null; - try { - const created = await callToolStructured(agentScopedClient, "create_terminal", { - name: "Parity capture terminal", - }); - terminalId = created.id as string; - - await callToolStructured(agentScopedClient, "send_terminal_keys", { - terminalId, - keys: "echo hello\r", - literal: true, - }); - await sleep(500); - - const captured = await waitFor({ - timeoutMs: 10_000, - intervalMs: 100, - label: "terminal output to contain hello", - check: async () => { - const payload = await callToolStructured(agentScopedClient, "capture_terminal", { - terminalId, - scrollback: true, - }); - const lines = (payload.lines as string[] | undefined) ?? []; - return lines.some((line) => line.includes("hello")) ? payload : null; - }, - }); - - expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")])); - } finally { - await killTerminalIfPresent(terminalId); - } - }); - - test("kill_terminal removes terminal", async () => { - let terminalId: string | null = null; - try { - const created = await callToolStructured(agentScopedClient, "create_terminal", { - name: "Parity kill terminal", - }); - terminalId = created.id as string; - - await callToolStructured(agentScopedClient, "kill_terminal", { terminalId }); - terminalId = null; - - const listed = await waitFor({ - timeoutMs: 5_000, - intervalMs: 100, - label: "terminal removal", - check: async () => { - const payload = await callToolStructured(agentScopedClient, "list_terminals"); - const terminals = payload.terminals as Array; - return terminals.some((terminal) => terminal.id === created.id) ? null : payload; - }, - }); - const terminals = listed.terminals as Array; - expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false); - } finally { - await killTerminalIfPresent(terminalId); - } - }); - - test("kill_terminal with invalid id throws", async () => { - await expectToolError( - agentScopedClient, - "kill_terminal", - { terminalId: "missing-terminal-id" }, - /not found/i, - ); - }); - }); - - describe("Suite C: Schedule Tools", () => { - test("create_schedule and list_schedules", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(topLevelClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity schedule list", - }); - scheduleId = created.id as string; - - const listed = await callToolStructured(topLevelClient, "list_schedules"); - const schedules = listed.schedules as Array; - expect(schedules).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: scheduleId, - name: "Parity schedule list", - }), - ]), - ); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("create_schedule accepts provider/model syntax", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(topLevelClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity provider schedule", - provider: "codex/gpt-5.4", - }); - scheduleId = created.id as string; - expect(created.target).toMatchObject({ - type: "new-agent", - config: { - provider: "codex", - model: "gpt-5.4", - }, - }); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("inspect_schedule returns details", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(topLevelClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity inspect schedule", - }); - scheduleId = created.id as string; - - const inspected = await callToolStructured(topLevelClient, "inspect_schedule", { - id: scheduleId, - }); - expect(inspected).toMatchObject({ - id: scheduleId, - name: "Parity inspect schedule", - prompt: "say hello", - status: "active", - }); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("pause and resume schedule", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(topLevelClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity pause schedule", - }); - scheduleId = created.id as string; - - await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId }); - const paused = await callToolStructured(topLevelClient, "inspect_schedule", { - id: scheduleId, - }); - expect(paused.status).toBe("paused"); - - await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId }); - const resumed = await callToolStructured(topLevelClient, "inspect_schedule", { - id: scheduleId, - }); - expect(resumed.status).toBe("active"); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("delete_schedule removes schedule", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(topLevelClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity delete schedule", - }); - scheduleId = created.id as string; - - await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId }); - scheduleId = null; - - const listed = await callToolStructured(topLevelClient, "list_schedules"); - const schedules = listed.schedules as Array; - expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("create_schedule target self with callerAgentId", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(agentScopedClient, "create_schedule", { - prompt: "say hello", - every: "5m", - name: "Parity self schedule", - target: "self", - }); - scheduleId = created.id as string; - expect(created.target).toMatchObject({ - type: "agent", - agentId: parentAgentId, - }); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => { - let scheduleId: string | null = null; - try { - const created = await callToolStructured(agentScopedClient, "create_schedule", { - prompt: "say hello", - every: "5m", - provider: "codex/gpt-5.4", - }); - scheduleId = created.id as string; - expect(created.target).toMatchObject({ - type: "new-agent", - config: { - provider: "codex", - model: "gpt-5.4", - }, - }); - } finally { - await deleteScheduleIfPresent(scheduleId); - } - }); - - test("create_schedule target self without callerAgentId throws", async () => { - await expectToolError( - topLevelClient, - "create_schedule", - { - prompt: "say hello", - every: "5m", - target: "self", - }, - /requires a caller agent/i, - ); - }); - }); - - describe("Suite D: Provider Tools", () => { - test("list_providers returns providers", async () => { - const payload = await callToolStructured(topLevelClient, "list_providers"); - const providers = payload.providers as Array; - expect(Array.isArray(providers)).toBe(true); - expect(providers.length).toBeGreaterThan(0); - expect(providers[0]).toEqual( - expect.objectContaining({ - id: expect.any(String), - label: expect.any(String), - modes: expect.any(Array), - }), - ); - }); - - test("list_models returns models for provider", async () => { - const payload = await callToolStructured(topLevelClient, "list_models", { + const snapshot = await daemonHandle.daemon.agentManager.createAgent({ provider: "claude", + cwd: await makeCwd("manager-direct-agent-cwd"), + title: "Manager direct parity agent", + modeId: "bypassPermissions", }); - expect(payload.provider).toBe("claude"); - expect(Array.isArray(payload.models)).toBe(true); - }); + agentId = snapshot.id; + + const expectedUrl = buildExpectedAgentMcpUrl({ + host: listenTarget!.host, + port: listenTarget!.port, + agentId, + }); + + expect(snapshot.config.mcpServers).toMatchObject({ + paseo: { + type: "http", + url: expectedUrl, + }, + }); + + const liveAgent = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(liveAgent?.config.mcpServers).toMatchObject({ + paseo: { + type: "http", + url: expectedUrl, + }, + }); + } finally { + await archiveAgentIfPresent(agentId); + } }); - describe("Suite E: Worktree Tools", () => { - test("list_worktrees on empty repo", async () => { - const payload = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, + test("create_agent accepts provider/model syntax", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent({ provider: "claude/claude-test-model" }); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.config.model).toBe("claude-test-model"); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("create_agent accepts labels param", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent({ labels: { team: "infra" } }); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(snapshot?.labels).toMatchObject({ team: "infra" }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("archive_agent archives an agent", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent(); + const archivedAgentId = agentId; + await callToolStructured(topLevelClient, "archive_agent", { agentId }); + agentId = null; + + const agents = daemonHandle.daemon.agentManager.listAgents(); + expect(agents.some((agent) => agent.id === archivedAgentId)).toBe(false); + } finally { + await archiveAgentIfPresent(agentId); + } + }); + + test("update_agent updates name and labels", async () => { + let agentId: string | null = null; + try { + agentId = await createTopLevelAgent(); + await callToolStructured(topLevelClient, "update_agent", { + agentId, + name: "Renamed parity agent", + labels: { team: "infra", surface: "mcp" }, }); - expect(payload.worktrees).toEqual([]); - }); - test("create_worktree and list_worktrees", async () => { - let worktreePath: string | null = null; - const branchName = `parity-create-${Date.now()}`; - try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - branchName, - baseBranch: "main", - }); - worktreePath = created.worktreePath as string; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = listed.worktrees as Array; - expect(worktrees).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: worktreePath, - branchName, - }), - ]), - ); - } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); - } + const stored = await daemonHandle.daemon.agentStorage.get(agentId); + const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); + expect(stored?.title).toBe("Renamed parity agent"); + expect(snapshot?.labels).toMatchObject({ + team: "infra", + surface: "mcp", + }); + } finally { + await archiveAgentIfPresent(agentId); + } + }); +}); + +describe("Suite B: Terminal Tools", () => { + test("create_terminal and list_terminals", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity terminal", + }); + terminalId = created.id as string; + + const listed = await callToolStructured(agentScopedClient, "list_terminals"); + const terminals = listed.terminals as Array; + expect(terminals).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: terminalId, + name: "Parity terminal", + cwd: parentAgentCwd, + }), + ]), + ); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("send_terminal_keys and capture_terminal", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity capture terminal", + }); + terminalId = created.id as string; + + await callToolStructured(agentScopedClient, "send_terminal_keys", { + terminalId, + keys: "echo hello\r", + literal: true, + }); + await sleep(500); + + const captured = await waitFor({ + timeoutMs: 10_000, + intervalMs: 100, + label: "terminal output to contain hello", + check: async () => { + const payload = await callToolStructured(agentScopedClient, "capture_terminal", { + terminalId, + scrollback: true, + }); + const lines = (payload.lines as string[] | undefined) ?? []; + return lines.some((line) => line.includes("hello")) ? payload : null; + }, + }); + + expect(captured.lines).toEqual(expect.arrayContaining([expect.stringContaining("hello")])); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("kill_terminal removes terminal", async () => { + let terminalId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_terminal", { + name: "Parity kill terminal", + }); + terminalId = created.id as string; + + await callToolStructured(agentScopedClient, "kill_terminal", { terminalId }); + terminalId = null; + + const listed = await waitFor({ + timeoutMs: 5_000, + intervalMs: 100, + label: "terminal removal", + check: async () => { + const payload = await callToolStructured(agentScopedClient, "list_terminals"); + const terminals = payload.terminals as Array; + return terminals.some((terminal) => terminal.id === created.id) ? null : payload; + }, + }); + const terminals = listed.terminals as Array; + expect(terminals.some((terminal) => terminal.id === created.id)).toBe(false); + } finally { + await killTerminalIfPresent(terminalId); + } + }); + + test("kill_terminal with invalid id throws", async () => { + await expectToolError( + agentScopedClient, + "kill_terminal", + { terminalId: "missing-terminal-id" }, + /not found/i, + ); + }); +}); + +describe("Suite C: Schedule Tools", () => { + test("create_schedule and list_schedules", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity schedule list", + }); + scheduleId = created.id as string; + + const listed = await callToolStructured(topLevelClient, "list_schedules"); + const schedules = listed.schedules as Array; + expect(schedules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: scheduleId, + name: "Parity schedule list", + }), + ]), + ); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule accepts provider/model syntax", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity provider schedule", + provider: "codex/gpt-5.4", + }); + scheduleId = created.id as string; + expect(created.target).toMatchObject({ + type: "new-agent", + config: { + provider: "codex", + model: "gpt-5.4", + }, + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("inspect_schedule returns details", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity inspect schedule", + }); + scheduleId = created.id as string; + + const inspected = await callToolStructured(topLevelClient, "inspect_schedule", { + id: scheduleId, + }); + expect(inspected).toMatchObject({ + id: scheduleId, + name: "Parity inspect schedule", + prompt: "say hello", + status: "active", + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("pause and resume schedule", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity pause schedule", + }); + scheduleId = created.id as string; + + await callToolStructured(topLevelClient, "pause_schedule", { id: scheduleId }); + const paused = await callToolStructured(topLevelClient, "inspect_schedule", { + id: scheduleId, + }); + expect(paused.status).toBe("paused"); + + await callToolStructured(topLevelClient, "resume_schedule", { id: scheduleId }); + const resumed = await callToolStructured(topLevelClient, "inspect_schedule", { + id: scheduleId, + }); + expect(resumed.status).toBe("active"); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("delete_schedule removes schedule", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(topLevelClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity delete schedule", + }); + scheduleId = created.id as string; + + await callToolStructured(topLevelClient, "delete_schedule", { id: scheduleId }); + scheduleId = null; + + const listed = await callToolStructured(topLevelClient, "list_schedules"); + const schedules = listed.schedules as Array; + expect(schedules.some((schedule) => schedule.id === created.id)).toBe(false); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule target self with callerAgentId", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_schedule", { + prompt: "say hello", + every: "5m", + name: "Parity self schedule", + target: "self", + }); + scheduleId = created.id as string; + expect(created.target).toMatchObject({ + type: "agent", + agentId: parentAgentId, + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => { + let scheduleId: string | null = null; + try { + const created = await callToolStructured(agentScopedClient, "create_schedule", { + prompt: "say hello", + every: "5m", + provider: "codex/gpt-5.4", + }); + scheduleId = created.id as string; + expect(created.target).toMatchObject({ + type: "new-agent", + config: { + provider: "codex", + model: "gpt-5.4", + }, + }); + } finally { + await deleteScheduleIfPresent(scheduleId); + } + }); + + test("create_schedule target self without callerAgentId throws", async () => { + await expectToolError( + topLevelClient, + "create_schedule", + { + prompt: "say hello", + every: "5m", + target: "self", + }, + /requires a caller agent/i, + ); + }); +}); + +describe("Suite D: Provider Tools", () => { + test("list_providers returns providers", async () => { + const payload = await callToolStructured(topLevelClient, "list_providers"); + const providers = payload.providers as Array; + expect(Array.isArray(providers)).toBe(true); + expect(providers.length).toBeGreaterThan(0); + expect(providers[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + label: expect.any(String), + modes: expect.any(Array), + }), + ); + }); + + test("list_models returns models for provider", async () => { + const payload = await callToolStructured(topLevelClient, "list_models", { + provider: "claude", }); + expect(payload.provider).toBe("claude"); + expect(Array.isArray(payload.models)).toBe(true); + }); +}); - test("archive_worktree removes worktree", async () => { - let worktreePath: string | null = null; - const branchName = `parity-archive-${Date.now()}`; - try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - branchName, - baseBranch: "main", - }); - worktreePath = created.worktreePath as string; - - await callToolStructured(topLevelClient, "archive_worktree", { - cwd: worktreeRepoCwd, - worktreePath, - }); - worktreePath = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = listed.worktrees as Array; - expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); - } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); - } +describe("Suite E: Worktree Tools", () => { + test("list_worktrees on empty repo", async () => { + const payload = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, }); + expect(payload.worktrees).toEqual([]); + }); + + test("create_worktree and list_worktrees", async () => { + let worktreePath: string | null = null; + const branchName = `parity-create-${Date.now()}`; + try { + const created = await callToolStructured(topLevelClient, "create_worktree", { + cwd: worktreeRepoCwd, + branchName, + baseBranch: "main", + }); + worktreePath = created.worktreePath as string; + + const listed = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, + }); + const worktrees = listed.worktrees as Array; + expect(worktrees).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: worktreePath, + branchName, + }), + ]), + ); + } finally { + await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + } + }); + + test("archive_worktree removes worktree", async () => { + let worktreePath: string | null = null; + const branchName = `parity-archive-${Date.now()}`; + try { + const created = await callToolStructured(topLevelClient, "create_worktree", { + cwd: worktreeRepoCwd, + branchName, + baseBranch: "main", + }); + worktreePath = created.worktreePath as string; + + await callToolStructured(topLevelClient, "archive_worktree", { + cwd: worktreeRepoCwd, + worktreePath, + }); + worktreePath = null; + + const listed = await callToolStructured(topLevelClient, "list_worktrees", { + cwd: worktreeRepoCwd, + }); + const worktrees = listed.worktrees as Array; + expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); + } finally { + await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + } }); }); diff --git a/packages/server/src/server/agent/mcp-shared.test.ts b/packages/server/src/server/agent/mcp-shared.test.ts index 2494e17a9d..e8b286a821 100644 --- a/packages/server/src/server/agent/mcp-shared.test.ts +++ b/packages/server/src/server/agent/mcp-shared.test.ts @@ -5,74 +5,72 @@ import { setupFinishNotification } from "./mcp-shared.js"; import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; -describe("setupFinishNotification", () => { - it("does not notify archived callers", async () => { - let subscriber: ((event: AgentManagerEvent) => void) | null = null; +it("does not notify archived callers", async () => { + let subscriber: ((event: AgentManagerEvent) => void) | null = null; - const childAgent = { - id: "child-agent", - lifecycle: "idle", - config: { title: "Child Agent" }, - } as ManagedAgent; + const childAgent = { + id: "child-agent", + lifecycle: "idle", + config: { title: "Child Agent" }, + } as ManagedAgent; - const agentManager = { - getAgent: vi.fn((agentId: string) => { - if (agentId === "child-agent") { - return childAgent; - } - if (agentId === "caller-agent") { - return { - id: "caller-agent", - lifecycle: "idle", - config: { title: "Caller Agent" }, - } as ManagedAgent; - } - return null; - }), - subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => { - subscriber = callback; - return () => { - subscriber = null; - }; - }), - hasInFlightRun: vi.fn().mockReturnValue(false), - streamAgent: vi.fn(() => (async function* noop() {})()), - replaceAgentRun: vi.fn(() => (async function* noop() {})()), - } as unknown as AgentManager; + const agentManager = { + getAgent: vi.fn((agentId: string) => { + if (agentId === "child-agent") { + return childAgent; + } + if (agentId === "caller-agent") { + return { + id: "caller-agent", + lifecycle: "idle", + config: { title: "Caller Agent" }, + } as ManagedAgent; + } + return null; + }), + subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => { + subscriber = callback; + return () => { + subscriber = null; + }; + }), + hasInFlightRun: vi.fn().mockReturnValue(false), + streamAgent: vi.fn(() => (async function* noop() {})()), + replaceAgentRun: vi.fn(() => (async function* noop() {})()), + } as unknown as AgentManager; - const agentStorage = { - get: vi.fn(async (agentId: string) => - agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, - ), - } as unknown as AgentStorage; + const agentStorage = { + get: vi.fn(async (agentId: string) => + agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, + ), + } as unknown as AgentStorage; - setupFinishNotification({ - agentManager, - agentStorage, - childAgentId: "child-agent", - callerAgentId: "caller-agent", - logger: createTestLogger(), - }); - - expect(subscriber).not.toBeNull(); - - childAgent.lifecycle = "running"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - childAgent.lifecycle = "idle"; - subscriber?.({ - type: "agent_state", - agent: childAgent, - }); - - await vi.waitFor(() => { - expect(agentStorage.get).toHaveBeenCalledWith("caller-agent"); - }); - - expect((agentManager as any).streamAgent).not.toHaveBeenCalled(); - expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled(); + setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: "child-agent", + callerAgentId: "caller-agent", + logger: createTestLogger(), }); + + expect(subscriber).not.toBeNull(); + + childAgent.lifecycle = "running"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + childAgent.lifecycle = "idle"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + await vi.waitFor(() => { + expect(agentStorage.get).toHaveBeenCalledWith("caller-agent"); + }); + + expect((agentManager as any).streamAgent).not.toHaveBeenCalled(); + expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled(); }); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index e4f83929a0..14f26ae638 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -319,564 +319,562 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ import { AGENT_PROVIDER_DEFINITIONS, buildProviderRegistry } from "./provider-registry.js"; -describe("buildProviderRegistry", () => { - const logger = createTestLogger(); +const logger = createTestLogger(); - beforeEach(() => { - mockState.reset(); +beforeEach(() => { + mockState.reset(); +}); + +test("builds registry with no overrides — same as built-in count", () => { + const registry = buildProviderRegistry(logger); + + expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length); +}); + +test("includes mock provider only for development builds", () => { + expect(buildProviderRegistry(logger).mock).toBeUndefined(); + expect(buildProviderRegistry(logger, { isDev: false }).mock).toBeUndefined(); + + const registry = buildProviderRegistry(logger, { isDev: true }); + + expect(registry.mock).toMatchObject({ + id: "mock", + label: "Mock Load Test", + defaultModeId: "load-test", }); +}); - test("builds registry with no overrides — same as built-in count", () => { - const registry = buildProviderRegistry(logger); - - expect(Object.keys(registry)).toHaveLength(AGENT_PROVIDER_DEFINITIONS.length); - }); - - test("includes mock provider only for development builds", () => { - expect(buildProviderRegistry(logger).mock).toBeUndefined(); - expect(buildProviderRegistry(logger, { isDev: false }).mock).toBeUndefined(); - - const registry = buildProviderRegistry(logger, { isDev: true }); - - expect(registry.mock).toMatchObject({ - id: "mock", - label: "Mock Load Test", - defaultModeId: "load-test", - }); - }); - - test("built-in override applies command", () => { - buildProviderRegistry(logger, { - providerOverrides: { - claude: { - command: ["/opt/custom-claude", "--verbose"], - }, +test("built-in override applies command", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["/opt/custom-claude", "--verbose"], }, - }); - - expect(mockState.constructorArgs.claude[0]).toEqual({ - runtimeSettings: { - command: { - mode: "replace", - argv: ["/opt/custom-claude", "--verbose"], - }, - env: undefined, - }, - }); + }, }); - test("built-in override applies env", () => { - buildProviderRegistry(logger, { - providerOverrides: { - claude: { - env: { - CLAUDE_CONFIG_DIR: "/tmp/claude", - }, - }, + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: { + mode: "replace", + argv: ["/opt/custom-claude", "--verbose"], }, - }); + env: undefined, + }, + }); +}); - expect(mockState.constructorArgs.claude[0]).toEqual({ - runtimeSettings: { - command: undefined, +test("built-in override applies env", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { env: { CLAUDE_CONFIG_DIR: "/tmp/claude", }, }, - }); + }, }); - test("new provider extending claude appears in registry", () => { - const registry = buildProviderRegistry(logger, { - providerOverrides: { - zai: { - extends: "claude", - label: "ZAI", - description: "Claude with ZAI defaults", + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: undefined, + env: { + CLAUDE_CONFIG_DIR: "/tmp/claude", + }, + }, + }); +}); + +test("new provider extending claude appears in registry", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + zai: { + extends: "claude", + label: "ZAI", + description: "Claude with ZAI defaults", + }, + }, + }); + + expect(registry.zai).toBeDefined(); + expect(registry.zai.label).toBe("ZAI"); + expect(registry.zai.description).toBe("Claude with ZAI defaults"); + expect(registry.zai.createClient(logger).provider).toBe("zai"); +}); + +test("new provider extending acp uses GenericACPAgentClient", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "my-agent": { + extends: "acp", + label: "My Agent", + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", }, }, - }); - - expect(registry.zai).toBeDefined(); - expect(registry.zai.label).toBe("ZAI"); - expect(registry.zai.description).toBe("Claude with ZAI defaults"); - expect(registry.zai.createClient(logger).provider).toBe("zai"); + }, }); - test("new provider extending acp uses GenericACPAgentClient", () => { - const registry = buildProviderRegistry(logger, { + expect(registry["my-agent"].createClient(logger).provider).toBe("my-agent"); + expect(mockState.constructorArgs.genericAcp).toEqual([ + { + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", + }, + }, + { + command: ["my-agent", "--acp"], + env: { + ACP_TOKEN: "secret", + }, + }, + ]); +}); + +test('extends: "acp" without command throws', () => { + expect(() => + buildProviderRegistry(logger, { providerOverrides: { "my-agent": { extends: "acp", label: "My Agent", - command: ["my-agent", "--acp"], - env: { - ACP_TOKEN: "secret", - }, }, }, - }); + }), + ).toThrowError("ACP provider 'my-agent' requires a command"); +}); - expect(registry["my-agent"].createClient(logger).provider).toBe("my-agent"); - expect(mockState.constructorArgs.genericAcp).toEqual([ - { - command: ["my-agent", "--acp"], - env: { - ACP_TOKEN: "secret", - }, - }, - { - command: ["my-agent", "--acp"], - env: { - ACP_TOKEN: "secret", - }, - }, - ]); - }); - - test('extends: "acp" without command throws', () => { - expect(() => - buildProviderRegistry(logger, { - providerOverrides: { - "my-agent": { - extends: "acp", - label: "My Agent", - }, - }, - }), - ).toThrowError("ACP provider 'my-agent' requires a command"); - }); - - test("custom provider without label throws", () => { - expect(() => - buildProviderRegistry(logger, { - providerOverrides: { - zai: { - extends: "claude", - }, - }, - }), - ).toThrowError("Custom provider 'zai' requires a label"); - }); - - test("enabled: false excludes provider from registry", () => { - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - enabled: false, - }, - }, - }); - - expect(registry.claude).toBeUndefined(); - }); - - test("provider override command can be PATH-resolved and still report available", async () => { - mockState.isCommandAvailable.mockResolvedValue(true); - - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - command: ["claude", "--flag"], - }, - }, - }); - - await expect(registry.claude.createClient(logger).isAvailable()).resolves.toBe(true); - expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); - }); - - test("disallowedTools flows through to runtime settings", () => { +test("custom provider without label throws", () => { + expect(() => buildProviderRegistry(logger, { providerOverrides: { - claude: { - disallowedTools: ["WebSearch", "WebFetch"], + zai: { + extends: "claude", }, }, - }); + }), + ).toThrowError("Custom provider 'zai' requires a label"); +}); - expect(mockState.constructorArgs.claude[0]).toEqual({ - runtimeSettings: { - command: undefined, - env: undefined, +test("enabled: false excludes provider from registry", () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + enabled: false, + }, + }, + }); + + expect(registry.claude).toBeUndefined(); +}); + +test("provider override command can be PATH-resolved and still report available", async () => { + mockState.isCommandAvailable.mockResolvedValue(true); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["claude", "--flag"], + }, + }, + }); + + await expect(registry.claude.createClient(logger).isAvailable()).resolves.toBe(true); + expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); +}); + +test("disallowedTools flows through to runtime settings", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { disallowedTools: ["WebSearch", "WebFetch"], }, - }); + }, }); - test("derived provider inherits and merges disallowedTools from base", () => { - buildProviderRegistry(logger, { + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: undefined, + env: undefined, + disallowedTools: ["WebSearch", "WebFetch"], + }, + }); +}); + +test("derived provider inherits and merges disallowedTools from base", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + disallowedTools: ["WebSearch"], + }, + zai: { + extends: "claude", + label: "ZAI", + disallowedTools: ["ComputerUse"], + }, + }, + }); + + const zaiArgs = mockState.constructorArgs.claude.find( + (entry) => + Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) && + (entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes( + "ComputerUse", + ), + ); + expect(zaiArgs).toBeDefined(); + expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([ + "WebSearch", + "ComputerUse", + ]); +}); + +test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + command: ["/opt/custom-claude"], + }, + zai: { + extends: "claude", + label: "ZAI", + }, + }, + }); + + expect(mockState.constructorArgs.claude).toHaveLength(2); + expect( + mockState.constructorArgs.claude.every( + (entry) => + (entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] === + "/opt/custom-claude", + ), + ).toBe(true); +}); + +describe("model merging", () => { + test("profile models replace runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); + + const registry = buildProviderRegistry(logger, { providerOverrides: { claude: { - disallowedTools: ["WebSearch"], - }, - zai: { - extends: "claude", - label: "ZAI", - disallowedTools: ["ComputerUse"], + models: [ + { + id: "profile-fast", + label: "Profile Fast", + }, + ], }, }, }); - const zaiArgs = mockState.constructorArgs.claude.find( - (entry) => - Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) && - (entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes( - "ComputerUse", - ), - ); - expect(zaiArgs).toBeDefined(); - expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([ - "WebSearch", - "ComputerUse", + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.map((model) => model.id)).toEqual(["profile-fast"]); + }); + + test("profile models exclude runtime models entirely", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "shared-model", + label: "Runtime Label", + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "shared-model", + label: "Profile Label", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "shared-model", + label: "Profile Label", + }, ]); }); - test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { - buildProviderRegistry(logger, { + test("profile isDefault preserved without runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); + + const registry = buildProviderRegistry(logger, { providerOverrides: { claude: { - command: ["/opt/custom-claude"], - }, - zai: { - extends: "claude", - label: "ZAI", + models: [ + { + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ], }, }, }); - expect(mockState.constructorArgs.claude).toHaveLength(2); - expect( - mockState.constructorArgs.claude.every( - (entry) => - (entry.runtimeSettings as { command?: { argv?: string[] } }).command?.argv?.[0] === - "/opt/custom-claude", - ), - ).toBe(true); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ]); }); - describe("model merging", () => { - test("profile models replace runtime models", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-pro", - label: "Runtime Pro", + test("additional models append to runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "profile-fast", + label: "Profile Fast", + }, + ], }, - ]); - - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - models: [ - { - id: "profile-fast", - label: "Profile Fast", - }, - ], - }, - }, - }); - - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models.map((model) => model.id)).toEqual(["profile-fast"]); + }, }); - test("profile models exclude runtime models entirely", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "shared-model", - label: "Runtime Label", - }, - { - provider: "claude", - id: "runtime-only", - label: "Runtime Only", - }, - ]); - - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - models: [ - { - id: "shared-model", - label: "Profile Label", - }, - ], - }, - }, - }); - - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "shared-model", - label: "Profile Label", - }, - ]); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, }); - test("profile isDefault preserved without runtime models", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-default", - label: "Runtime Default", - isDefault: true, - }, - ]); + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + { + provider: "claude", + id: "profile-fast", + label: "Profile Fast", + }, + ]); + }); - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - models: [ - { - id: "profile-default", - label: "Profile Default", - isDefault: true, - }, - ], - }, - }, - }); + test("additional models merge onto profile replacement models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "profile-default", - label: "Profile Default", - isDefault: true, + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "profile-curated", + label: "Profile Curated", + }, + ], + additionalModels: [ + { + id: "profile-extra", + label: "Profile Extra", + }, + ], }, - ]); + }, }); - test("additional models append to runtime models", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-pro", - label: "Runtime Pro", - }, - ]); - - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - additionalModels: [ - { - id: "profile-fast", - label: "Profile Fast", - }, - ], - }, - }, - }); - - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "runtime-pro", - label: "Runtime Pro", - }, - { - provider: "claude", - id: "profile-fast", - label: "Profile Fast", - }, - ]); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, }); - test("additional models merge onto profile replacement models", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-pro", - label: "Runtime Pro", + expect(models.map((model) => model.id)).toEqual(["profile-curated", "profile-extra"]); + }); + + test("additional models override matching runtime models in place", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "shared-model", + label: "Runtime Label", + description: "Runtime description", + metadata: { + source: "runtime", }, - ]); + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - models: [ - { - id: "profile-curated", - label: "Profile Curated", - }, - ], - additionalModels: [ - { - id: "profile-extra", - label: "Profile Extra", - }, - ], - }, + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "shared-model", + label: "Profile Label", + }, + ], }, - }); - - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models.map((model) => model.id)).toEqual(["profile-curated", "profile-extra"]); + }, }); - test("additional models override matching runtime models in place", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "shared-model", - label: "Runtime Label", - description: "Runtime description", - metadata: { - source: "runtime", - }, - }, - { - provider: "claude", - id: "runtime-only", - label: "Runtime Only", - }, - ]); - - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - additionalModels: [ - { - id: "shared-model", - label: "Profile Label", - }, - ], - }, - }, - }); - - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "shared-model", - label: "Profile Label", - description: "Runtime description", - metadata: { - source: "runtime", - }, - }, - { - provider: "claude", - id: "runtime-only", - label: "Runtime Only", - }, - ]); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, }); - test("additional model default overrides runtime default", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-default", - label: "Runtime Default", - isDefault: true, + expect(models).toEqual([ + { + provider: "claude", + id: "shared-model", + label: "Profile Label", + description: "Runtime description", + metadata: { + source: "runtime", }, - { - provider: "claude", - id: "runtime-other", - label: "Runtime Other", - }, - ]); + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); + }); - const registry = buildProviderRegistry(logger, { - providerOverrides: { - claude: { - additionalModels: [ - { - id: "profile-default", - label: "Profile Default", - isDefault: true, - }, - ], - }, - }, - }); + test("additional model default overrides runtime default", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + { + provider: "claude", + id: "runtime-other", + label: "Runtime Other", + }, + ]); - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "runtime-default", - label: "Runtime Default", - isDefault: false, + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ], }, - { - provider: "claude", - id: "runtime-other", - label: "Runtime Other", - isDefault: false, - }, - { - provider: "claude", - id: "profile-default", - label: "Profile Default", - isDefault: true, - }, - ]); + }, }); - test("no profile models — runtime models returned as-is", async () => { - mockState.runtimeModels.set("claude", [ - { - provider: "claude", - id: "runtime-default", - label: "Runtime Default", - isDefault: true, - }, - ]); - - const registry = buildProviderRegistry(logger); - const models = await registry.claude.fetchModels({ - cwd: "/tmp/registry-models", - force: false, - }); - - expect(models).toEqual([ - { - provider: "claude", - id: "runtime-default", - label: "Runtime Default", - isDefault: true, - }, - ]); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, }); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: false, + }, + { + provider: "claude", + id: "runtime-other", + label: "Runtime Other", + isDefault: false, + }, + { + provider: "claude", + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ]); + }); + + test("no profile models — runtime models returned as-is", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); + + const registry = buildProviderRegistry(logger); + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + ]); }); }); diff --git a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts index 2820cdcf74..acc20dfe41 100644 --- a/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts +++ b/packages/server/src/server/agent/providers/__tests__/claude-agent.event-stream.integration.test.ts @@ -192,309 +192,300 @@ function assertInvariants(events: AgentStreamEvent[], foregroundTurnIds: string[ // Tests // --------------------------------------------------------------------------- -describe("Agent event stream redesign — integration", () => { - let canRun = false; +let canRun = false; - beforeAll(async () => { - canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; - }); - - beforeEach((context) => { - if (!canRun) { - context.skip(); - } - }); - - test("Test 1: Basic foreground turn", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents( - handle.session, - "respond with just the word hello", - ); - - const turnStarted = events.find( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ); - expect(turnStarted).toBeDefined(); - - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("Test 2: No duplicate user_messages — THE BUG", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); - - expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); - - // No turn_started after terminal for the same turnId - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const staleTurnStarted = events - .slice(terminalIdx + 1) - .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); - expect(staleTurnStarted.length).toBe(0); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("Test 3: Lifecycle doesn't get stuck in running", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { - extraMs: 3_000, - }); - - const terminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - const afterTerminal = events.slice(terminalIdx + 1); - - // No subsequent turn_started for same turnId - expect( - afterTerminal.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, - ).length, - ).toBe(0); - - // Any turn_started after terminal must have a different turnId - for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { - expect((ts as EventWithTurnId).turnId).not.toBe(turnId); - } - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("Test 4: Autonomous run", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); - const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; - - try { - const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( - handle.session, - [ - "Use the Task tool to start a background sub-agent.", - "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", - "Do not wait for task completion.", - "Reply immediately with exactly: SPAWNED", - `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, - ].join(" "), - { - extraMs: 10_000, - timeoutMs: 60_000, - }, - ); - - const fgTerminalIdx = events.findIndex( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, - ); - const afterForeground = events.slice(fgTerminalIdx + 1); - - // Autonomous turn_started with a different turnId - const autoStarts = afterForeground.filter( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, - ) as EventWithTurnId[]; - if (autoStarts.length === 0) { - assertInvariants(events, [fgTurnId]); - return; - } - - const autoTurnId = autoStarts[0]!.turnId; - expect(fgTurnId).not.toBe(autoTurnId); - - // Autonomous turn reaches terminal - expect( - afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId), - ).toBeDefined(); - - assertInvariants(events, [fgTurnId]); - } finally { - await cleanupSession(handle); - } - }, 90_000); - - test("Test 5: Interruption", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); - - try { - let turnId: string | null = null; - const events = await new Promise((resolve, reject) => { - const collected: AgentStreamEvent[] = []; - let interrupted = false; - - const timeout = setTimeout(() => { - unsubscribe(); - reject(new Error("Timed out after 45000ms waiting for terminal event")); - }, 45_000); - - const unsubscribe = handle.session.subscribe((event) => { - collected.push(event); - if (!turnId && event.type === "turn_started" && hasTurnId(event)) { - turnId = event.turnId; - } - - // Once we see turn_started, fire the interrupt - if ( - !interrupted && - turnId && - event.type === "turn_started" && - hasTurnId(event) && - event.turnId === turnId - ) { - interrupted = true; - handle.session.interrupt().catch(() => undefined); - } - - // Resolve when we get a terminal event for this turn - if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { - clearTimeout(timeout); - unsubscribe(); - resolve(collected); - } - }); - - void handle.session - .startTurn("write a very long essay about the history of computing") - .then((result) => { - if (turnId && turnId !== result.turnId) { - clearTimeout(timeout); - unsubscribe(); - reject( - new Error( - `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, - ), - ); - return; - } - turnId = result.turnId; - }) - .catch((error) => { - clearTimeout(timeout); - unsubscribe(); - reject(error); - }); - }); - - expect(turnId).toBeDefined(); - - // turn_canceled or turn_failed arrives for that turnId - const terminal = events.find( - (e) => - (e.type === "turn_canceled" || e.type === "turn_failed") && - hasTurnId(e) && - e.turnId === turnId, - ); - expect(terminal).toBeDefined(); - - // No further events for that turnId after terminal - const terminalIdx = events.indexOf(terminal!); - expect( - events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, - ).toBe(0); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("Test 6: Sequential foreground turns", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); - - try { - const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( - handle.session, - "say first", - ); - - const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( - handle.session, - "say second", - ); - - const allEvents = [...events1, ...events2]; - - expect(turnId1).not.toBe(turnId2); - - // No events from turn 1 after turn 2 starts - const turn2StartIdx = allEvents.findIndex( - (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, - ); - expect( - allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1) - .length, - ).toBe(0); - - assertInvariants(allEvents, [turnId1, turnId2]); - } finally { - await cleanupSession(handle); - } - }, 90_000); - - test("Test 7: Fast-fail", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { - extraMs: 3_000, - }); - - // At most one turn_started - expect( - events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) - .length, - ).toBeLessThanOrEqual(1); - - // Terminal present - const terminal = events.find( - (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, - ); - expect(terminal).toBeDefined(); - - // No stale turn_started after terminal - const terminalIdx = events.indexOf(terminal!); - expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); - - test("Test 8: User message dedup by text", async () => { - const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); - - try { - const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { - extraMs: 3_000, - }); - - expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); - - assertInvariants(events, [turnId]); - } finally { - await cleanupSession(handle); - } - }, 60_000); +beforeAll(async () => { + canRun = (await isCommandAvailable("claude")) && hasClaudeCredentials; }); + +beforeEach((context) => { + if (!canRun) { + context.skip(); + } +}); + +test("Test 1: Basic foreground turn", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-basic-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents( + handle.session, + "respond with just the word hello", + ); + + const turnStarted = events.find( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId, + ); + expect(turnStarted).toBeDefined(); + + const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId); + expect(terminal).toBeDefined(); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); + +test("Test 2: No duplicate user_messages — THE BUG", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "say hi").length).toBeLessThanOrEqual(1); + + // No turn_started after terminal for the same turnId + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const staleTurnStarted = events + .slice(terminalIdx + 1) + .filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId); + expect(staleTurnStarted.length).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); + +test("Test 3: Lifecycle doesn't get stuck in running", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-lifecycle-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "say hi", { + extraMs: 3_000, + }); + + const terminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId, + ); + const afterTerminal = events.slice(terminalIdx + 1); + + // No subsequent turn_started for same turnId + expect( + afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId) + .length, + ).toBe(0); + + // Any turn_started after terminal must have a different turnId + for (const ts of afterTerminal.filter((e) => e.type === "turn_started" && hasTurnId(e))) { + expect((ts as EventWithTurnId).turnId).not.toBe(turnId); + } + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); + +test("Test 4: Autonomous run", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-autonomous-" }); + const autonomousWakeToken = `AUTONOMOUS_WAKE_${Date.now().toString(36)}`; + + try { + const { turnId: fgTurnId, events } = await startTurnAndCollectEvents( + handle.session, + [ + "Use the Task tool to start a background sub-agent.", + "In that task, run the Bash command exactly: sleep 3 && echo BACKGROUND_DONE", + "Do not wait for task completion.", + "Reply immediately with exactly: SPAWNED", + `When the background task completes later, reply with exactly: ${autonomousWakeToken}`, + ].join(" "), + { + extraMs: 10_000, + timeoutMs: 60_000, + }, + ); + + const fgTerminalIdx = events.findIndex( + (e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === fgTurnId, + ); + const afterForeground = events.slice(fgTerminalIdx + 1); + + // Autonomous turn_started with a different turnId + const autoStarts = afterForeground.filter( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId !== fgTurnId, + ) as EventWithTurnId[]; + if (autoStarts.length === 0) { + assertInvariants(events, [fgTurnId]); + return; + } + + const autoTurnId = autoStarts[0]!.turnId; + expect(fgTurnId).not.toBe(autoTurnId); + + // Autonomous turn reaches terminal + expect( + afterForeground.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === autoTurnId), + ).toBeDefined(); + + assertInvariants(events, [fgTurnId]); + } finally { + await cleanupSession(handle); + } +}, 90_000); + +test("Test 5: Interruption", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-interrupt-" }); + + try { + let turnId: string | null = null; + const events = await new Promise((resolve, reject) => { + const collected: AgentStreamEvent[] = []; + let interrupted = false; + + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out after 45000ms waiting for terminal event")); + }, 45_000); + + const unsubscribe = handle.session.subscribe((event) => { + collected.push(event); + if (!turnId && event.type === "turn_started" && hasTurnId(event)) { + turnId = event.turnId; + } + + // Once we see turn_started, fire the interrupt + if ( + !interrupted && + turnId && + event.type === "turn_started" && + hasTurnId(event) && + event.turnId === turnId + ) { + interrupted = true; + handle.session.interrupt().catch(() => undefined); + } + + // Resolve when we get a terminal event for this turn + if (turnId && isTerminalEvent(event) && hasTurnId(event) && event.turnId === turnId) { + clearTimeout(timeout); + unsubscribe(); + resolve(collected); + } + }); + + void handle.session + .startTurn("write a very long essay about the history of computing") + .then((result) => { + if (turnId && turnId !== result.turnId) { + clearTimeout(timeout); + unsubscribe(); + reject( + new Error( + `Observed turn_started for ${turnId} but startTurn returned ${result.turnId}`, + ), + ); + return; + } + turnId = result.turnId; + }) + .catch((error) => { + clearTimeout(timeout); + unsubscribe(); + reject(error); + }); + }); + + expect(turnId).toBeDefined(); + + // turn_canceled or turn_failed arrives for that turnId + const terminal = events.find( + (e) => + (e.type === "turn_canceled" || e.type === "turn_failed") && + hasTurnId(e) && + e.turnId === turnId, + ); + expect(terminal).toBeDefined(); + + // No further events for that turnId after terminal + const terminalIdx = events.indexOf(terminal!); + expect( + events.slice(terminalIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId).length, + ).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); + +test("Test 6: Sequential foreground turns", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-sequential-" }); + + try { + const { turnId: turnId1, events: events1 } = await startTurnAndCollectEvents( + handle.session, + "say first", + ); + + const { turnId: turnId2, events: events2 } = await startTurnAndCollectEvents( + handle.session, + "say second", + ); + + const allEvents = [...events1, ...events2]; + + expect(turnId1).not.toBe(turnId2); + + // No events from turn 1 after turn 2 starts + const turn2StartIdx = allEvents.findIndex( + (e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId2, + ); + expect( + allEvents.slice(turn2StartIdx + 1).filter((e) => hasTurnId(e) && e.turnId === turnId1).length, + ).toBe(0); + + assertInvariants(allEvents, [turnId1, turnId2]); + } finally { + await cleanupSession(handle); + } +}, 90_000); + +test("Test 7: Fast-fail", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-fast-fail-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "", { + extraMs: 3_000, + }); + + // At most one turn_started + expect( + events.filter((e) => e.type === "turn_started" && hasTurnId(e) && e.turnId === turnId).length, + ).toBeLessThanOrEqual(1); + + // Terminal present + const terminal = events.find((e) => isTerminalEvent(e) && hasTurnId(e) && e.turnId === turnId); + expect(terminal).toBeDefined(); + + // No stale turn_started after terminal + const terminalIdx = events.indexOf(terminal!); + expect(events.slice(terminalIdx + 1).filter((e) => e.type === "turn_started").length).toBe(0); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); + +test("Test 8: User message dedup by text", async () => { + const handle = await createSession({ cwdPrefix: "event-stream-user-dedup-" }); + + try { + const { turnId, events } = await startTurnAndCollectEvents(handle.session, "hello world", { + extraMs: 3_000, + }); + + expect(userMessagesWithText(events, "hello world").length).toBeLessThanOrEqual(1); + + assertInvariants(events, [turnId]); + } finally { + await cleanupSession(handle); + } +}, 60_000); diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index e28eb1e1ae..4c45618a8a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -248,403 +248,395 @@ afterEach(() => { sdkMocks.query.mockReset(); }); -describe("ClaudeAgentSession interrupt regression", () => { - test("interrupt only calls query.interrupt and leaves the query open", async () => { - const logger = createTestLogger(); - const queries: ScriptedQuery[] = []; +test("interrupt only calls query.interrupt and leaves the query open", async () => { + const logger = createTestLogger(); + const queries: ScriptedQuery[] = []; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const scriptedQuery = createScriptedQuery({ - prompt, - sessionId: "interrupt-keep-query-session", - }); - queries.push(scriptedQuery); - return scriptedQuery; + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const scriptedQuery = createScriptedQuery({ + prompt, + sessionId: "interrupt-keep-query-session", }); - - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - const firstTurn = streamSession(session, "first prompt"); - await firstTurn.next(); - await waitFor(() => queries[0]?.prompts.length === 1); - - await session.interrupt(); - await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); - - expect(sdkMocks.query).toHaveBeenCalledTimes(1); - expect(queries[0]?.return).not.toHaveBeenCalled(); - - const firstTurnEvents = await collectUntilTerminal(firstTurn); - expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({ - type: "turn_canceled", - provider: "claude", - reason: "Interrupted", - }); - - await session.close(); + queries.push(scriptedQuery); + return scriptedQuery; }); - test("reuses the existing query after interrupt before starting the next prompt", async () => { - const logger = createTestLogger(); - const queries: ScriptedQuery[] = []; - - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const scriptedQuery = createScriptedQuery({ - prompt, - sessionId: "interrupt-reuse-query-session", - async handlePrompt({ promptRecord, query }) { - if (promptRecord.text !== "second prompt") { - return; - } - query.emit({ - type: "assistant", - message: { content: "SECOND_PROMPT_RESPONSE" }, - session_id: "interrupt-reuse-query-session", - }); - query.emit(buildSuccessResult("interrupt-reuse-query-session")); - }, - }); - queries.push(scriptedQuery); - return scriptedQuery; - }); - - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - const firstTurn = streamSession(session, "first prompt"); - await firstTurn.next(); - await waitFor(() => queries[0]?.prompts.length === 1); - - await session.interrupt(); - await collectUntilTerminal(firstTurn); - - const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); - - expect(sdkMocks.query).toHaveBeenCalledTimes(1); - expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([ - "first prompt", - "second prompt", - ]); - expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1); - expect(queries[0]?.return).not.toHaveBeenCalled(); - expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); - - await session.close(); + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), }); - test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => { - const logger = createTestLogger(); - const output = createAsyncQueue>(); - const prompts: PromptRecord[] = []; - let throwAbortOnNext = false; + const firstTurn = streamSession(session, "first prompt"); + await firstTurn.next(); + await waitFor(() => queries[0]?.prompts.length === 1); - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const scriptedQuery = { - next: vi.fn(async () => { - if (throwAbortOnNext) { - throwAbortOnNext = false; - throw new Error("Request was aborted."); - } - return output.next(); - }), - interrupt: vi.fn(async () => { - throwAbortOnNext = true; - }), - return: vi.fn(async () => { - output.end(); - }), - setPermissionMode: vi.fn(async () => undefined), - setModel: vi.fn(async () => undefined), - supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), - supportedCommands: vi.fn(async () => []), - rewindFiles: vi.fn(async () => ({ canRewind: true })), - emit: (message: Record) => { - output.push(message); - }, - end: () => { - output.end(); - }, - prompts, - [Symbol.asyncIterator]() { - return this; - }, - } satisfies ScriptedQuery; + await session.interrupt(); + await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: "interrupt-abort-recovery-session", - permissionMode: "default", - model: "opus", - }); + expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queries[0]?.return).not.toHaveBeenCalled(); - void (async () => { - for await (const promptMessage of prompt) { - const record = promptMessage as Record; - const promptRecord = { - text: extractPromptText(record), - uuid: typeof record.uuid === "string" ? record.uuid : null, - }; - prompts.push(promptRecord); - - if (promptRecord.text !== "second prompt") { - continue; - } - - output.push({ - type: "assistant", - message: { content: "SECOND_PROMPT_RESPONSE" }, - session_id: "interrupt-abort-recovery-session", - }); - output.push(buildSuccessResult("interrupt-abort-recovery-session")); - } - })(); - - return scriptedQuery; - }); - - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - const firstTurn = streamSession(session, "first prompt"); - await firstTurn.next(); - await session.interrupt(); - await collectUntilTerminal(firstTurn); - - const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); - - expect(sdkMocks.query).toHaveBeenCalledTimes(1); - expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]); - expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); - expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); - - await session.close(); + const firstTurnEvents = await collectUntilTerminal(firstTurn); + expect(firstTurnEvents.find((event) => event.type === "turn_canceled")).toMatchObject({ + type: "turn_canceled", + provider: "claude", + reason: "Interrupted", }); - test("stale abort result after replacement start does not poison the new foreground turn", async () => { - const logger = createTestLogger(); - let queryRef: ScriptedQuery | null = null; - - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - queryRef = createScriptedQuery({ - prompt, - sessionId: "interrupt-stale-result-session", - }); - return queryRef; - }); - - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - const firstTurn = streamSession(session, "first prompt"); - const firstStarted = await firstTurn.next(); - await waitFor(() => queryRef?.prompts.length === 1); - - await session.interrupt(); - const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; - expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true); - - const observedSecondTurnEvents: AgentStreamEvent[] = []; - const unsubscribe = session.subscribe((event) => { - observedSecondTurnEvents.push(event); - }); - - const secondTurn = streamSession(session, "second prompt"); - const secondStarted = await secondTurn.next(); - await waitFor(() => queryRef?.prompts.length === 2); - - queryRef?.emit({ - type: "result", - subtype: "error_during_execution", - errors: ["Request was aborted."], - session_id: "interrupt-stale-result-session", - }); - queryRef?.emit({ - type: "assistant", - message: { content: "SECOND_PROMPT_RESPONSE" }, - session_id: "interrupt-stale-result-session", - }); - queryRef?.emit(buildSuccessResult("interrupt-stale-result-session")); - - const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))]; - unsubscribe(); - - expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false); - expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false); - expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); - expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); - expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe( - 1, - ); - expect( - observedSecondTurnEvents.some( - (event) => event.type === "turn_failed" || event.type === "turn_canceled", - ), - ).toBe(false); - - await session.close(); - }); + await session.close(); }); -describe("ClaudeAgentSession autonomous turns", () => { - test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { - const logger = createTestLogger(); - let queryRef: ScriptedQuery | null = null; +test("reuses the existing query after interrupt before starting the next prompt", async () => { + const logger = createTestLogger(); + const queries: ScriptedQuery[] = []; - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - queryRef = createScriptedQuery({ - prompt, - sessionId: "autonomous-live-session", - async handlePrompt({ promptRecord, query }) { - if (promptRecord.text !== "seed prompt") { - return; - } + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const scriptedQuery = createScriptedQuery({ + prompt, + sessionId: "interrupt-reuse-query-session", + async handlePrompt({ promptRecord, query }) { + if (promptRecord.text !== "second prompt") { + return; + } + query.emit({ + type: "assistant", + message: { content: "SECOND_PROMPT_RESPONSE" }, + session_id: "interrupt-reuse-query-session", + }); + query.emit(buildSuccessResult("interrupt-reuse-query-session")); + }, + }); + queries.push(scriptedQuery); + return scriptedQuery; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const firstTurn = streamSession(session, "first prompt"); + await firstTurn.next(); + await waitFor(() => queries[0]?.prompts.length === 1); + + await session.interrupt(); + await collectUntilTerminal(firstTurn); + + const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); + + expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queries[0]?.prompts.map((prompt) => prompt.text)).toEqual([ + "first prompt", + "second prompt", + ]); + expect(queries[0]?.interrupt).toHaveBeenCalledTimes(1); + expect(queries[0]?.return).not.toHaveBeenCalled(); + expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); + + await session.close(); +}); + +test("recovers when the query pump sees a single interrupt abort before the next prompt", async () => { + const logger = createTestLogger(); + const output = createAsyncQueue>(); + const prompts: PromptRecord[] = []; + let throwAbortOnNext = false; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const scriptedQuery = { + next: vi.fn(async () => { + if (throwAbortOnNext) { + throwAbortOnNext = false; + throw new Error("Request was aborted."); + } + return output.next(); + }), + interrupt: vi.fn(async () => { + throwAbortOnNext = true; + }), + return: vi.fn(async () => { + output.end(); + }), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + emit: (message: Record) => { + output.push(message); + }, + end: () => { + output.end(); + }, + prompts, + [Symbol.asyncIterator]() { + return this; + }, + } satisfies ScriptedQuery; + + scriptedQuery.emit({ + type: "system", + subtype: "init", + session_id: "interrupt-abort-recovery-session", + permissionMode: "default", + model: "opus", + }); + + void (async () => { + for await (const promptMessage of prompt) { + const record = promptMessage as Record; + const promptRecord = { + text: extractPromptText(record), + uuid: typeof record.uuid === "string" ? record.uuid : null, + }; + prompts.push(promptRecord); + + if (promptRecord.text !== "second prompt") { + continue; + } + + output.push({ + type: "assistant", + message: { content: "SECOND_PROMPT_RESPONSE" }, + session_id: "interrupt-abort-recovery-session", + }); + output.push(buildSuccessResult("interrupt-abort-recovery-session")); + } + })(); + + return scriptedQuery; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const firstTurn = streamSession(session, "first prompt"); + await firstTurn.next(); + await session.interrupt(); + await collectUntilTerminal(firstTurn); + + const secondTurnEvents = await collectUntilTerminal(streamSession(session, "second prompt")); + + expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(prompts.map((prompt) => prompt.text)).toEqual(["first prompt", "second prompt"]); + expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); + expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); + + await session.close(); +}); + +test("stale abort result after replacement start does not poison the new foreground turn", async () => { + const logger = createTestLogger(); + let queryRef: ScriptedQuery | null = null; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryRef = createScriptedQuery({ + prompt, + sessionId: "interrupt-stale-result-session", + }); + return queryRef; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const firstTurn = streamSession(session, "first prompt"); + const firstStarted = await firstTurn.next(); + await waitFor(() => queryRef?.prompts.length === 1); + + await session.interrupt(); + const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; + expect(firstTurnEvents.some((event) => event.type === "turn_canceled")).toBe(true); + + const observedSecondTurnEvents: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => { + observedSecondTurnEvents.push(event); + }); + + const secondTurn = streamSession(session, "second prompt"); + const secondStarted = await secondTurn.next(); + await waitFor(() => queryRef?.prompts.length === 2); + + queryRef?.emit({ + type: "result", + subtype: "error_during_execution", + errors: ["Request was aborted."], + session_id: "interrupt-stale-result-session", + }); + queryRef?.emit({ + type: "assistant", + message: { content: "SECOND_PROMPT_RESPONSE" }, + session_id: "interrupt-stale-result-session", + }); + queryRef?.emit(buildSuccessResult("interrupt-stale-result-session")); + + const secondTurnEvents = [secondStarted.value!, ...(await collectUntilTerminal(secondTurn))]; + unsubscribe(); + + expect(secondTurnEvents.some((event) => event.type === "turn_failed")).toBe(false); + expect(secondTurnEvents.some((event) => event.type === "turn_canceled")).toBe(false); + expect(secondTurnEvents.some((event) => event.type === "turn_completed")).toBe(true); + expect(collectAssistantText(secondTurnEvents)).toContain("SECOND_PROMPT_RESPONSE"); + expect(observedSecondTurnEvents.filter((event) => event.type === "turn_started").length).toBe(1); + expect( + observedSecondTurnEvents.some( + (event) => event.type === "turn_failed" || event.type === "turn_canceled", + ), + ).toBe(false); + + await session.close(); +}); + +test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { + const logger = createTestLogger(); + let queryRef: ScriptedQuery | null = null; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryRef = createScriptedQuery({ + prompt, + sessionId: "autonomous-live-session", + async handlePrompt({ promptRecord, query }) { + if (promptRecord.text !== "seed prompt") { + return; + } + query.emit({ + type: "assistant", + message: { content: "SEED_RESPONSE" }, + session_id: "autonomous-live-session", + }); + query.emit(buildSuccessResult("autonomous-live-session")); + }, + }); + return queryRef; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + await collectUntilTerminal(streamSession(session, "seed prompt")); + + const subscribedEvents = subscribeToEvents(session); + queryRef?.emit({ + type: "assistant", + message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, + session_id: "autonomous-live-session", + }); + queryRef?.emit(buildSuccessResult("autonomous-live-session")); + + const started = await subscribedEvents.next(); + const timeline = await subscribedEvents.next(); + const completed = await subscribedEvents.next(); + + expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" }); + expect(timeline.value).toMatchObject({ + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "AUTONOMOUS_WAKE_RESPONSE", + }, + }); + expect(completed.value).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + + subscribedEvents.close(); + await session.close(); +}); + +test("auto-completes an open autonomous turn when a foreground prompt starts", async () => { + const logger = createTestLogger(); + let queryRef: ScriptedQuery | null = null; + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + queryRef = createScriptedQuery({ + prompt, + sessionId: "autonomous-handoff-session", + async handlePrompt({ promptRecord, query }) { + if (promptRecord.text === "seed prompt") { query.emit({ type: "assistant", message: { content: "SEED_RESPONSE" }, - session_id: "autonomous-live-session", + session_id: "autonomous-handoff-session", }); - query.emit(buildSuccessResult("autonomous-live-session")); - }, - }); - return queryRef; - }); + query.emit(buildSuccessResult("autonomous-handoff-session")); + return; + } - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - await collectUntilTerminal(streamSession(session, "seed prompt")); - - const subscribedEvents = subscribeToEvents(session); - queryRef?.emit({ - type: "assistant", - message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, - session_id: "autonomous-live-session", - }); - queryRef?.emit(buildSuccessResult("autonomous-live-session")); - - const started = await subscribedEvents.next(); - const timeline = await subscribedEvents.next(); - const completed = await subscribedEvents.next(); - - expect(started.value).toMatchObject({ type: "turn_started", provider: "claude" }); - expect(timeline.value).toMatchObject({ - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "AUTONOMOUS_WAKE_RESPONSE", + if (promptRecord.text === "foreground prompt") { + query.emit({ + type: "assistant", + message: { content: "FOREGROUND_RESPONSE" }, + session_id: "autonomous-handoff-session", + }); + query.emit(buildSuccessResult("autonomous-handoff-session")); + } }, }); - expect(completed.value).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - - subscribedEvents.close(); - await session.close(); + return queryRef; }); - test("auto-completes an open autonomous turn when a foreground prompt starts", async () => { - const logger = createTestLogger(); - let queryRef: ScriptedQuery | null = null; - - sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - queryRef = createScriptedQuery({ - prompt, - sessionId: "autonomous-handoff-session", - async handlePrompt({ promptRecord, query }) { - if (promptRecord.text === "seed prompt") { - query.emit({ - type: "assistant", - message: { content: "SEED_RESPONSE" }, - session_id: "autonomous-handoff-session", - }); - query.emit(buildSuccessResult("autonomous-handoff-session")); - return; - } - - if (promptRecord.text === "foreground prompt") { - query.emit({ - type: "assistant", - message: { content: "FOREGROUND_RESPONSE" }, - session_id: "autonomous-handoff-session", - }); - query.emit(buildSuccessResult("autonomous-handoff-session")); - } - }, - }); - return queryRef; - }); - - const client = new ClaudeAgentClient({ logger }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - }); - - await collectUntilTerminal(streamSession(session, "seed prompt")); - - const subscribedEvents = subscribeToEvents(session); - queryRef?.emit({ - type: "assistant", - message: { content: "BACKGROUND_ONLY_RESPONSE" }, - session_id: "autonomous-handoff-session", - }); - - const autonomousStart = await subscribedEvents.next(); - const autonomousTimeline = await subscribedEvents.next(); - const foregroundEvents = await collectUntilTerminal( - streamSession(session, "foreground prompt"), - ); - const autonomousComplete = await subscribedEvents.next(); - - expect(autonomousStart.value).toMatchObject({ - type: "turn_started", - provider: "claude", - }); - expect(autonomousTimeline.value).toMatchObject({ - type: "timeline", - provider: "claude", - item: { - type: "assistant_message", - text: "BACKGROUND_ONLY_RESPONSE", - }, - }); - expect(autonomousComplete.value).toMatchObject({ - type: "turn_completed", - provider: "claude", - }); - expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); - expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE"); - expect( - [autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some( - (event) => event?.type === "turn_canceled", - ), - ).toBe(false); - expect(sdkMocks.query).toHaveBeenCalledTimes(1); - expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([ - "seed prompt", - "foreground prompt", - ]); - - subscribedEvents.close(); - await session.close(); + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), }); + + await collectUntilTerminal(streamSession(session, "seed prompt")); + + const subscribedEvents = subscribeToEvents(session); + queryRef?.emit({ + type: "assistant", + message: { content: "BACKGROUND_ONLY_RESPONSE" }, + session_id: "autonomous-handoff-session", + }); + + const autonomousStart = await subscribedEvents.next(); + const autonomousTimeline = await subscribedEvents.next(); + const foregroundEvents = await collectUntilTerminal(streamSession(session, "foreground prompt")); + const autonomousComplete = await subscribedEvents.next(); + + expect(autonomousStart.value).toMatchObject({ + type: "turn_started", + provider: "claude", + }); + expect(autonomousTimeline.value).toMatchObject({ + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "BACKGROUND_ONLY_RESPONSE", + }, + }); + expect(autonomousComplete.value).toMatchObject({ + type: "turn_completed", + provider: "claude", + }); + expect(foregroundEvents.some((event) => event.type === "turn_completed")).toBe(true); + expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_RESPONSE"); + expect( + [autonomousStart.value, autonomousTimeline.value, autonomousComplete.value].some( + (event) => event?.type === "turn_canceled", + ), + ).toBe(false); + expect(sdkMocks.query).toHaveBeenCalledTimes(1); + expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual([ + "seed prompt", + "foreground prompt", + ]); + + subscribedEvents.close(); + await session.close(); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 2db189fa2c..4bc564a062 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -165,1153 +165,1150 @@ async function waitForCondition(check: () => boolean, timeoutMs: number): Promis throw new Error("Timed out waiting for condition"); } -describe("ClaudeAgentSession redesign invariants", () => { - beforeEach(() => { - sdkQueryFactory.mockReset(); +beforeEach(() => { + sdkQueryFactory.mockReset(); +}); + +afterEach(() => { + sdkQueryFactory.mockReset(); +}); + +test("logs redacted query summary and never leaks sentinel secrets", async () => { + const envSecret = "PASEO_ENV_SENTINEL_SECRET"; + const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; + const systemSecret = "PASEO_SYSTEM_PROMPT_SENTINEL_SECRET"; + const previousEnv = process.env.PASEO_TEST_SENTINEL_SECRET; + process.env.PASEO_TEST_SENTINEL_SECRET = envSecret; + + sdkQueryFactory.mockImplementation(() => { + let step = 0; + return createBaseQueryMock( + vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "redacted-log-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { content: "done" }, + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + ); }); - afterEach(() => { - sdkQueryFactory.mockReset(); + const spy = createSpyLogger(); + const client = new ClaudeAgentClient({ + logger: spy.logger, + queryFactory: sdkQueryFactory, + runtimeSettings: { + env: { + PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret, + }, + }, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + systemPrompt: `Never log ${systemSecret}`, }); - test("logs redacted query summary and never leaks sentinel secrets", async () => { - const envSecret = "PASEO_ENV_SENTINEL_SECRET"; - const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; - const systemSecret = "PASEO_SYSTEM_PROMPT_SENTINEL_SECRET"; - const previousEnv = process.env.PASEO_TEST_SENTINEL_SECRET; - process.env.PASEO_TEST_SENTINEL_SECRET = envSecret; + try { + await session.run("redaction check"); + + const queryLogCall = spy.debug.mock.calls.find((args) => args[1] === "claude query"); + expect(queryLogCall).toBeDefined(); + const payload = queryLogCall?.[0] as { options?: Record } | undefined; + expect(payload?.options).toBeDefined(); + expect(payload?.options).not.toHaveProperty("env"); + expect(payload?.options).not.toHaveProperty("systemPrompt"); + expect(payload?.options).not.toHaveProperty("canUseTool"); + expect(payload?.options).toHaveProperty("hasEnv"); + expect(payload?.options).toHaveProperty("envKeyCount"); + + const serialized = JSON.stringify(spy.calls, (_key, value) => + typeof value === "function" ? "[function]" : value, + ); + expect(serialized).not.toContain(envSecret); + expect(serialized).not.toContain(runtimeSecret); + expect(serialized).not.toContain(systemSecret); + } finally { + await session.close(); + if (previousEnv === undefined) { + delete process.env.PASEO_TEST_SENTINEL_SECRET; + } else { + process.env.PASEO_TEST_SENTINEL_SECRET = previousEnv; + } + } +}); + +test("interruptActiveTurn only interrupts the active query without info logs", async () => { + const spy = createSpyLogger(); + const session = await createSessionWithLogger(spy.logger); + const internal = session as unknown as { + query: { + interrupt: () => Promise; + return?: () => Promise; + close?: () => void; + } | null; + input: { end: () => void } | null; + queryRestartNeeded: boolean; + interruptActiveTurn: () => Promise; + }; + const interrupt = vi.fn(async () => undefined); + const queryReturn = vi.fn(async () => undefined); + const end = vi.fn(() => undefined); + internal.query = { + interrupt, + return: queryReturn, + close: vi.fn(() => undefined), + }; + internal.input = { end }; + internal.queryRestartNeeded = false; + + try { + await internal.interruptActiveTurn(); + + const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) => + message.includes("interruptActiveTurn"), + ); + const interruptDebugMessages = extractStringLogArgs(spy.debug.mock.calls).filter((message) => + message.includes("interruptActiveTurn"), + ); - sdkQueryFactory.mockImplementation(() => { - let step = 0; - return createBaseQueryMock( - vi.fn(async () => { - if (step === 0) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "init", - session_id: "redacted-log-session", - permissionMode: "default", - model: "opus", - }, - }; - } - if (step === 1) { - step += 1; - return { - done: false, - value: { - type: "assistant", - message: { content: "done" }, - }, - }; - } - if (step === 2) { - step += 1; - return { - done: false, - value: { - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }, - }; - } - return { done: true, value: undefined }; - }), - ); - }); - - const spy = createSpyLogger(); - const client = new ClaudeAgentClient({ - logger: spy.logger, - queryFactory: sdkQueryFactory, - runtimeSettings: { - env: { - PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret, + expect(interruptInfoMessages).toEqual([]); + expect(interruptDebugMessages).toEqual([]); + expect(interrupt).toHaveBeenCalledTimes(1); + expect(queryReturn).not.toHaveBeenCalled(); + expect(end).not.toHaveBeenCalled(); + expect(internal.query).not.toBeNull(); + expect(internal.input).not.toBeNull(); + expect(internal.queryRestartNeeded).toBe(false); + } finally { + await session.close(); + } +}); + +test("extracts identifiers from fixture-driven protocol shape variants", () => { + const fixtures = [ + { + name: "root identifiers take priority over nested variants", + message: { + type: "stream_event", + task_id: "task-root", + parent_message_id: "parent-root", + message_id: "msg-root", + event: { + type: "message_delta", + task_id: "task-event", + parent_message_id: "parent-event", + message_id: "msg-event", + message: { id: "msg-event-inner" }, + }, + }, + expected: { + taskId: "task-root", + parentMessageId: "parent-root", + messageId: "msg-root", + }, + }, + { + name: "stream_event identifiers are used when root identifiers are absent", + message: { + type: "stream_event", + event: { + type: "message_delta", + task_id: "task-event-only", + parent_message_id: "parent-event-only", + message_id: "msg-event-only", + }, + }, + expected: { + taskId: "task-event-only", + parentMessageId: "parent-event-only", + messageId: "msg-event-only", + }, + }, + { + name: "assistant message container identifiers are used as a fallback", + message: { + type: "assistant", + message: { + id: "msg-container", + task_id: "task-container", + parent_message_id: "parent-container", + content: "assistant message", + }, + }, + expected: { + taskId: "task-container", + parentMessageId: "parent-container", + messageId: "msg-container", + }, + }, + { + name: "user uuid is used as a message_id fallback", + message: { + type: "user", + uuid: "uuid-fallback", + message: { + role: "user", + content: "prompt text", }, + }, + expected: { + taskId: null, + parentMessageId: null, + messageId: "uuid-fallback", }, - }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - systemPrompt: `Never log ${systemSecret}`, - }); + }, + ] as const; + for (const fixture of fixtures) { + expect( + readEventIdentifiers( + fixture.message as unknown as Parameters[0], + ), + ).toEqual(fixture.expected); + } +}); + +test("captures session IDs from fixture-driven init message variants", async () => { + const fixtures = [ + { + name: "session_id field", + payload: { session_id: " session-id-1 " }, + expected: "session-id-1", + }, + { + name: "sessionId field", + payload: { sessionId: " session-id-2 " }, + expected: "session-id-2", + }, + { + name: "nested session.id field", + payload: { session: { id: " session-id-3 " } }, + expected: "session-id-3", + }, + ] as const; + + for (const fixture of fixtures) { + const session = await createSession(); + const internal = session as unknown as { + handleSystemMessage: (message: Record) => string | null; + }; try { - await session.run("redaction check"); - - const queryLogCall = spy.debug.mock.calls.find((args) => args[1] === "claude query"); - expect(queryLogCall).toBeDefined(); - const payload = queryLogCall?.[0] as { options?: Record } | undefined; - expect(payload?.options).toBeDefined(); - expect(payload?.options).not.toHaveProperty("env"); - expect(payload?.options).not.toHaveProperty("systemPrompt"); - expect(payload?.options).not.toHaveProperty("canUseTool"); - expect(payload?.options).toHaveProperty("hasEnv"); - expect(payload?.options).toHaveProperty("envKeyCount"); - - const serialized = JSON.stringify(spy.calls, (_key, value) => - typeof value === "function" ? "[function]" : value, - ); - expect(serialized).not.toContain(envSecret); - expect(serialized).not.toContain(runtimeSecret); - expect(serialized).not.toContain(systemSecret); + const started = internal.handleSystemMessage({ + type: "system", + subtype: "init", + permissionMode: "default", + model: "opus", + ...fixture.payload, + }); + expect(started).toBe(fixture.expected); + expect(session.describePersistence()?.sessionId).toBe(fixture.expected); } finally { await session.close(); - if (previousEnv === undefined) { - delete process.env.PASEO_TEST_SENTINEL_SECRET; - } else { - process.env.PASEO_TEST_SENTINEL_SECRET = previousEnv; - } } - }); + } +}); + +test("waits for complete JSON values before updating tool input from input_json_delta", async () => { + const session = await createSession(); + const internal = session as unknown as { + mapPartialEvent: (event: Record) => AgentTimelineItem[]; + toolUseCache: Map }>; + toolUseIndexToId: Map; + toolUseInputBuffers: Map; + }; + + const toolUseId = "tool-input-delta"; + const index = 7; + try { + internal.mapPartialEvent({ + type: "content_block_start", + index, + content_block: { + type: "tool_use", + id: toolUseId, + name: "Bash", + input: { command: "echo seed" }, + }, + }); - test("interruptActiveTurn only interrupts the active query without info logs", async () => { - const spy = createSpyLogger(); - const session = await createSessionWithLogger(spy.logger); - const internal = session as unknown as { - query: { - interrupt: () => Promise; - return?: () => Promise; - close?: () => void; - } | null; - input: { end: () => void } | null; - queryRestartNeeded: boolean; - interruptActiveTurn: () => Promise; - }; - const interrupt = vi.fn(async () => undefined); - const queryReturn = vi.fn(async () => undefined); - const end = vi.fn(() => undefined); - internal.query = { - interrupt, - return: queryReturn, - close: vi.fn(() => undefined), + const readCommand = () => { + const command = internal.toolUseCache.get(toolUseId)?.input?.command; + return typeof command === "string" ? command : null; }; - internal.input = { end }; - internal.queryRestartNeeded = false; - - try { - await internal.interruptActiveTurn(); - - const interruptInfoMessages = extractStringLogArgs(spy.info.mock.calls).filter((message) => - message.includes("interruptActiveTurn"), - ); - const interruptDebugMessages = extractStringLogArgs(spy.debug.mock.calls).filter((message) => - message.includes("interruptActiveTurn"), - ); - - expect(interruptInfoMessages).toEqual([]); - expect(interruptDebugMessages).toEqual([]); - expect(interrupt).toHaveBeenCalledTimes(1); - expect(queryReturn).not.toHaveBeenCalled(); - expect(end).not.toHaveBeenCalled(); - expect(internal.query).not.toBeNull(); - expect(internal.input).not.toBeNull(); - expect(internal.queryRestartNeeded).toBe(false); - } finally { - await session.close(); - } - }); - test("extracts identifiers from fixture-driven protocol shape variants", () => { - const fixtures = [ - { - name: "root identifiers take priority over nested variants", - message: { - type: "stream_event", - task_id: "task-root", - parent_message_id: "parent-root", - message_id: "msg-root", - event: { - type: "message_delta", - task_id: "task-event", - parent_message_id: "parent-event", - message_id: "msg-event", - message: { id: "msg-event-inner" }, - }, - }, - expected: { - taskId: "task-root", - parentMessageId: "parent-root", - messageId: "msg-root", - }, - }, + const deltaFixtures = [ { - name: "stream_event identifiers are used when root identifiers are absent", - message: { - type: "stream_event", - event: { - type: "message_delta", - task_id: "task-event-only", - parent_message_id: "parent-event-only", - message_id: "msg-event-only", + event: { + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: '{"command":"echo ', }, - }, - expected: { - taskId: "task-event-only", - parentMessageId: "parent-event-only", - messageId: "msg-event-only", }, + expectedCommand: "echo seed", }, { - name: "assistant message container identifiers are used as a fallback", - message: { - type: "assistant", - message: { - id: "msg-container", - task_id: "task-container", - parent_message_id: "parent-container", - content: "assistant message", + event: { + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: 'delta"}', }, - }, - expected: { - taskId: "task-container", - parentMessageId: "parent-container", - messageId: "msg-container", }, + expectedCommand: "echo delta", }, { - name: "user uuid is used as a message_id fallback", - message: { - type: "user", - uuid: "uuid-fallback", - message: { - role: "user", - content: "prompt text", + event: { + type: "content_block_delta", + delta: { + type: "input_json_delta", + partial_json: '{"command":"ignored"}', }, - }, - expected: { - taskId: null, - parentMessageId: null, - messageId: "uuid-fallback", }, + expectedCommand: "echo delta", }, ] as const; - for (const fixture of fixtures) { - expect( - readEventIdentifiers( - fixture.message as unknown as Parameters[0], - ), - ).toEqual(fixture.expected); + for (const fixture of deltaFixtures) { + internal.mapPartialEvent(fixture.event as unknown as Record); + expect(readCommand()).toBe(fixture.expectedCommand); } - }); - test("captures session IDs from fixture-driven init message variants", async () => { - const fixtures = [ - { - name: "session_id field", - payload: { session_id: " session-id-1 " }, - expected: "session-id-1", + internal.mapPartialEvent({ + type: "content_block_stop", + index, + }); + expect(internal.toolUseIndexToId.has(index)).toBe(false); + expect(internal.toolUseInputBuffers.has(toolUseId)).toBe(false); + } finally { + await session.close(); + } +}); + +test("does not surface incomplete string values from input_json_delta", async () => { + const session = await createSession(); + const internal = session as unknown as { + mapPartialEvent: (event: Record) => AgentTimelineItem[]; + toolUseCache: Map }>; + }; + + const toolUseId = "tool-input-preview"; + const index = 8; + try { + internal.mapPartialEvent({ + type: "content_block_start", + index, + content_block: { + type: "tool_use", + id: toolUseId, + name: "Edit", }, - { - name: "sessionId field", - payload: { sessionId: " session-id-2 " }, - expected: "session-id-2", + }); + + internal.mapPartialEvent({ + type: "content_block_delta", + index, + delta: { + type: "input_json_delta", + partial_json: '{"file_path":"src/message.tsx","old_string":"before', }, - { - name: "nested session.id field", - payload: { session: { id: " session-id-3 " } }, - expected: "session-id-3", + }); + + expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({ + file_path: "src/message.tsx", + }); + } finally { + await session.close(); + } +}); + +test("maps tool_result content shapes into deterministic string output", async () => { + const session = await createSession(); + const internal = session as unknown as { + buildToolOutput: ( + block: Record, + entry: Record | undefined, + ) => Record | undefined; + }; + + const toolEntry = { + id: "tool-1", + name: "Bash", + server: "Bash", + classification: "command", + started: true, + input: { + command: "echo hello", + }, + }; + + const fixtures = [ + { + name: "string content", + content: "plain output", + expectedOutput: "plain output", + }, + { + name: "text block array content", + content: [ + { type: "text", text: "first line\n" }, + { type: "text", text: "second line" }, + ], + expectedOutput: "first line\nsecond line", + }, + { + name: "structured fallback content", + content: { + z: 3, + nested: { + b: 2, + a: 1, + }, + a: 0, }, - ] as const; + expectedOutput: '{"a":0,"nested":{"a":1,"b":2},"z":3}', + }, + ] as const; + try { for (const fixture of fixtures) { - const session = await createSession(); - const internal = session as unknown as { - handleSystemMessage: (message: Record) => string | null; - }; - try { - const started = internal.handleSystemMessage({ - type: "system", - subtype: "init", - permissionMode: "default", - model: "opus", - ...fixture.payload, - }); - expect(started).toBe(fixture.expected); - expect(session.describePersistence()?.sessionId).toBe(fixture.expected); - } finally { - await session.close(); - } + const output = internal.buildToolOutput( + { + type: "tool_result", + tool_use_id: "tool-1", + tool_name: "Bash", + content: fixture.content, + is_error: false, + }, + toolEntry, + ); + expect(output).toEqual( + expect.objectContaining({ + type: "command", + command: "echo hello", + output: fixture.expectedOutput, + }), + ); } - }); - - test("waits for complete JSON values before updating tool input from input_json_delta", async () => { - const session = await createSession(); - const internal = session as unknown as { - mapPartialEvent: (event: Record) => AgentTimelineItem[]; - toolUseCache: Map }>; - toolUseIndexToId: Map; - toolUseInputBuffers: Map; - }; - - const toolUseId = "tool-input-delta"; - const index = 7; - try { - internal.mapPartialEvent({ - type: "content_block_start", - index, - content_block: { - type: "tool_use", - id: toolUseId, - name: "Bash", - input: { command: "echo seed" }, - }, - }); - - const readCommand = () => { - const command = internal.toolUseCache.get(toolUseId)?.input?.command; - return typeof command === "string" ? command : null; - }; - - const deltaFixtures = [ - { - event: { - type: "content_block_delta", - index, - delta: { - type: "input_json_delta", - partial_json: '{"command":"echo ', + } finally { + await session.close(); + } +}); + +test("completes a foreground run when only system metadata arrives before the first assistant message", async () => { + let step = 0; + sdkQueryFactory.mockImplementation(() => + createBaseQueryMock( + vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "redesign-metadata-only-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "hook_response", + session_id: "redesign-metadata-only-session", + hook_name: "SessionStart:Callback", + hook_event: "SessionStart", + stdout: "", + stderr: "", }, - }, - expectedCommand: "echo seed", - }, - { - event: { - type: "content_block_delta", - index, - delta: { - type: "input_json_delta", - partial_json: 'delta"}', + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { content: "assistant output" }, }, - }, - expectedCommand: "echo delta", - }, - { - event: { - type: "content_block_delta", - delta: { - type: "input_json_delta", - partial_json: '{"command":"ignored"}', + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, }, - }, - expectedCommand: "echo delta", - }, - ] as const; + }; + } + return { done: true, value: undefined }; + }), + ), + ); + + const session = await createSession(); + try { + const events = await Promise.race([ + collectUntilTerminal(streamSession(session, "metadata helper prompt")), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Timed out waiting for foreground terminal event")), + 1_000, + ); + }), + ]); - for (const fixture of deltaFixtures) { - internal.mapPartialEvent(fixture.event as unknown as Record); - expect(readCommand()).toBe(fixture.expectedCommand); - } + expect(events.some((event) => event.type === "turn_completed")).toBe(true); - internal.mapPartialEvent({ - type: "content_block_stop", - index, - }); - expect(internal.toolUseIndexToId.has(index)).toBe(false); - expect(internal.toolUseInputBuffers.has(toolUseId)).toBe(false); - } finally { - await session.close(); - } - }); - - test("does not surface incomplete string values from input_json_delta", async () => { - const session = await createSession(); - const internal = session as unknown as { - mapPartialEvent: (event: Record) => AgentTimelineItem[]; - toolUseCache: Map }>; - }; - - const toolUseId = "tool-input-preview"; - const index = 8; - try { - internal.mapPartialEvent({ - type: "content_block_start", - index, - content_block: { - type: "tool_use", - id: toolUseId, - name: "Edit", - }, - }); - - internal.mapPartialEvent({ - type: "content_block_delta", - index, - delta: { - type: "input_json_delta", - partial_json: '{"file_path":"src/message.tsx","old_string":"before', - }, - }); - - expect(internal.toolUseCache.get(toolUseId)?.input).toEqual({ - file_path: "src/message.tsx", - }); - } finally { - await session.close(); - } - }); - - test("maps tool_result content shapes into deterministic string output", async () => { - const session = await createSession(); - const internal = session as unknown as { - buildToolOutput: ( - block: Record, - entry: Record | undefined, - ) => Record | undefined; - }; - - const toolEntry = { - id: "tool-1", - name: "Bash", - server: "Bash", - classification: "command", - started: true, - input: { - command: "echo hello", - }, - }; - - const fixtures = [ - { - name: "string content", - content: "plain output", - expectedOutput: "plain output", - }, - { - name: "text block array content", - content: [ - { type: "text", text: "first line\n" }, - { type: "text", text: "second line" }, - ], - expectedOutput: "first line\nsecond line", - }, - { - name: "structured fallback content", - content: { - z: 3, - nested: { - b: 2, - a: 1, - }, - a: 0, - }, - expectedOutput: '{"a":0,"nested":{"a":1,"b":2},"z":3}', - }, - ] as const; - - try { - for (const fixture of fixtures) { - const output = internal.buildToolOutput( - { - type: "tool_result", - tool_use_id: "tool-1", - tool_name: "Bash", - content: fixture.content, - is_error: false, - }, - toolEntry, - ); - expect(output).toEqual( - expect.objectContaining({ - type: "command", - command: "echo hello", - output: fixture.expectedOutput, - }), - ); + const assistantText = events + .filter( + (event): event is Extract => + event.type === "timeline" && event.item.type === "assistant_message", + ) + .map((event) => event.item.text) + .join(""); + expect(assistantText).toContain("assistant output"); + } finally { + await session.close(); + } +}); + +test("captures Claude stderr in the turn failure diagnostic when stderr arrives after process exit", async () => { + const stderrMessage = + 'Error: Effort level "max" is not available for Claude.ai subscribers. Please use "low", "medium", or "high".'; + let capturedOptions: + | { + stderr?: (data: string) => void; + effort?: string; + permissionMode?: string; } - } finally { - await session.close(); - } - }); + | undefined; - test("completes a foreground run when only system metadata arrives before the first assistant message", async () => { - let step = 0; - sdkQueryFactory.mockImplementation(() => - createBaseQueryMock( + sdkQueryFactory.mockImplementation( + ({ options }: { options: { stderr?: (data: string) => void; effort?: string } }) => { + capturedOptions = options; + let failed = false; + const emitStderr = () => options.stderr?.(`${stderrMessage}\n`); + return createBaseQueryMock( vi.fn(async () => { - if (step === 0) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "init", - session_id: "redesign-metadata-only-session", - permissionMode: "default", - model: "opus", - }, - }; - } - if (step === 1) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "hook_response", - session_id: "redesign-metadata-only-session", - hook_name: "SessionStart:Callback", - hook_event: "SessionStart", - stdout: "", - stderr: "", - }, - }; + if (!failed) { + failed = true; + setTimeout(emitStderr, 0); + throw new Error("Claude Code process exited with code 1"); } - if (step === 2) { - step += 1; - return { - done: false, - value: { - type: "assistant", - message: { content: "assistant output" }, - }, - }; - } - if (step === 3) { - step += 1; - return { - done: false, - value: { - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }, - }; - } return { done: true, value: undefined }; }), - ), - ); + ); + }, + ); - const session = await createSession(); - try { - const events = await Promise.race([ - collectUntilTerminal(streamSession(session, "metadata helper prompt")), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error("Timed out waiting for foreground terminal event")), - 1_000, - ); - }), - ]); - - expect(events.some((event) => event.type === "turn_completed")).toBe(true); - - const assistantText = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "assistant_message", - ) - .map((event) => event.item.text) - .join(""); - expect(assistantText).toContain("assistant output"); - } finally { - await session.close(); - } + const loggerSpy = createSpyLogger(); + const client = new ClaudeAgentClient({ + logger: loggerSpy.logger, + queryFactory: sdkQueryFactory, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "bypassPermissions", + thinkingOptionId: "max", }); - test("captures Claude stderr in the turn failure diagnostic when stderr arrives after process exit", async () => { - const stderrMessage = - 'Error: Effort level "max" is not available for Claude.ai subscribers. Please use "low", "medium", or "high".'; - let capturedOptions: - | { - stderr?: (data: string) => void; - effort?: string; - permissionMode?: string; - } - | undefined; - - sdkQueryFactory.mockImplementation( - ({ options }: { options: { stderr?: (data: string) => void; effort?: string } }) => { - capturedOptions = options; - let failed = false; - return createBaseQueryMock( - vi.fn(async () => { - if (!failed) { - failed = true; - setTimeout(() => { - options.stderr?.(`${stderrMessage}\n`); - }, 0); - throw new Error("Claude Code process exited with code 1"); - } - return { done: true, value: undefined }; - }), - ); - }, + try { + const events = await collectUntilTerminal(streamSession(session, "trigger max failure")); + const failure = events.find( + (event): event is Extract => + event.type === "turn_failed", ); - const loggerSpy = createSpyLogger(); - const client = new ClaudeAgentClient({ - logger: loggerSpy.logger, - queryFactory: sdkQueryFactory, + expect(capturedOptions?.permissionMode).toBe("bypassPermissions"); + expect(capturedOptions?.effort).toBe("max"); + expect(failure).toMatchObject({ + type: "turn_failed", + error: "Claude Code process exited with code 1", + code: "1", + diagnostic: stderrMessage, }); - const session = await client.createSession({ - provider: "claude", - cwd: process.cwd(), - modeId: "bypassPermissions", - thinkingOptionId: "max", - }); - - try { - const events = await collectUntilTerminal(streamSession(session, "trigger max failure")); - const failure = events.find( - (event): event is Extract => - event.type === "turn_failed", - ); - - expect(capturedOptions?.permissionMode).toBe("bypassPermissions"); - expect(capturedOptions?.effort).toBe("max"); - expect(failure).toMatchObject({ - type: "turn_failed", - error: "Claude Code process exited with code 1", - code: "1", - diagnostic: stderrMessage, + expect(loggerSpy.error).toHaveBeenCalledWith( + expect.objectContaining({ stderr: stderrMessage }), + "Claude Agent SDK stderr", + ); + } finally { + await session.close(); + } +}); + +test("preserves bypass capability across query restarts triggered by thinking changes", async () => { + const capturedOptions: Array<{ + permissionMode?: string; + allowDangerouslySkipPermissions?: boolean; + effort?: string; + }> = []; + + sdkQueryFactory.mockImplementation( + ({ + options, + }: { + options: { + permissionMode?: string; + allowDangerouslySkipPermissions?: boolean; + effort?: string; + }; + }) => { + capturedOptions.push({ + permissionMode: options.permissionMode, + allowDangerouslySkipPermissions: options.allowDangerouslySkipPermissions, + effort: options.effort, }); - expect(loggerSpy.error).toHaveBeenCalledWith( - expect.objectContaining({ stderr: stderrMessage }), - "Claude Agent SDK stderr", - ); - } finally { - await session.close(); - } - }); - test("preserves bypass capability across query restarts triggered by thinking changes", async () => { - const capturedOptions: Array<{ - permissionMode?: string; - allowDangerouslySkipPermissions?: boolean; - effort?: string; - }> = []; - - sdkQueryFactory.mockImplementation( - ({ - options, - }: { - options: { - permissionMode?: string; - allowDangerouslySkipPermissions?: boolean; - effort?: string; - }; - }) => { - capturedOptions.push({ - permissionMode: options.permissionMode, - allowDangerouslySkipPermissions: options.allowDangerouslySkipPermissions, - effort: options.effort, - }); - - return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); - }, - ); + return createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + }, + ); - const session = await createSession(); + const session = await createSession(); - try { - await session.setMode("bypassPermissions"); - await session.setMode("acceptEdits"); - await session.setThinkingOption("high"); - await session.setMode("bypassPermissions"); + try { + await session.setMode("bypassPermissions"); + await session.setMode("acceptEdits"); + await session.setThinkingOption("high"); + await session.setMode("bypassPermissions"); - expect(capturedOptions).toHaveLength(2); - expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", - allowDangerouslySkipPermissions: true, - }); - expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", - allowDangerouslySkipPermissions: true, - effort: "high", - }); - } finally { - await session.close(); - } - }); + expect(capturedOptions).toHaveLength(2); + expect(capturedOptions[0]).toMatchObject({ + permissionMode: "default", + allowDangerouslySkipPermissions: true, + }); + expect(capturedOptions[1]).toMatchObject({ + permissionMode: "acceptEdits", + allowDangerouslySkipPermissions: true, + effort: "high", + }); + } finally { + await session.close(); + } +}); - test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => { - const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); - sdkQueryFactory.mockImplementation(() => queryMock); +test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => { + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); - const session = await createSession(); - const events: AgentStreamEvent[] = []; - session.subscribe((event) => events.push(event)); + const session = await createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); - try { - await session.setMode("bypassPermissions"); - await session.setMode("plan"); - - const internal = session as unknown as { - handlePermissionRequest: ( - toolName: string, - input: Record, - options: Record, - ) => Promise; - }; + try { + await session.setMode("bypassPermissions"); + await session.setMode("plan"); - const pendingResolution = internal.handlePermissionRequest( - "ExitPlanMode", - { plan: "- Implement the approved plan" }, - {}, - ); + const internal = session as unknown as { + handlePermissionRequest: ( + toolName: string, + input: Record, + options: Record, + ) => Promise; + }; - const requestEvent = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", - ); + const pendingResolution = internal.handlePermissionRequest( + "ExitPlanMode", + { plan: "- Implement the approved plan" }, + {}, + ); - expect(requestEvent).toBeDefined(); - expect(requestEvent?.request.actions).toEqual([ - { - id: "reject", - label: "Reject", - behavior: "deny", - variant: "danger", - intent: "dismiss", - }, - { - id: "implement", - label: "Implement", - behavior: "allow", - variant: "primary", - intent: "implement", - }, - { - id: "implement_resume", - label: "Implement with Bypass", - behavior: "allow", - variant: "secondary", - intent: "implement_resume", - }, - ]); + const requestEvent = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); - if (!requestEvent) { - throw new Error("Expected plan permission request"); - } - - await session.respondToPermission(requestEvent.request.id, { + expect(requestEvent).toBeDefined(); + expect(requestEvent?.request.actions).toEqual([ + { + id: "reject", + label: "Reject", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "implement", + label: "Implement", + behavior: "allow", + variant: "primary", + intent: "implement", + }, + { + id: "implement_resume", + label: "Implement with Bypass", behavior: "allow", - selectedActionId: "implement_resume", - }); + variant: "secondary", + intent: "implement_resume", + }, + ]); - await expect(pendingResolution).resolves.toMatchObject({ - behavior: "allow", - updatedInput: { plan: "- Implement the approved plan" }, - }); - expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions"); - expect(await session.getCurrentMode()).toBe("bypassPermissions"); - } finally { - await session.close(); + if (!requestEvent) { + throw new Error("Expected plan permission request"); } - }); - test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { - const session = await createSession(); - const internal = session as unknown as { - turnState: "idle" | "foreground" | "autonomous"; - nextTurnOrdinal: number; - routeSdkMessageFromPump: (message: Record) => void; - autonomousTurn: { id: string } | null; - }; + await session.respondToPermission(requestEvent.request.id, { + behavior: "allow", + selectedActionId: "implement_resume", + }); - internal.turnState = "idle"; - internal.routeSdkMessageFromPump({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "AUTO " }, - }, + await expect(pendingResolution).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { plan: "- Implement the approved plan" }, }); + expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions"); + expect(await session.getCurrentMode()).toBe("bypassPermissions"); + } finally { + await session.close(); + } +}); + +test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { + const session = await createSession(); + const internal = session as unknown as { + turnState: "idle" | "foreground" | "autonomous"; + nextTurnOrdinal: number; + routeSdkMessageFromPump: (message: Record) => void; + autonomousTurn: { id: string } | null; + }; + + internal.turnState = "idle"; + internal.routeSdkMessageFromPump({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "AUTO " }, + }, + }); - const firstRunId = internal.autonomousTurn?.id ?? null; - expect(firstRunId).toBe("autonomous-turn-1"); - expect(internal.nextTurnOrdinal).toBe(2); + const firstRunId = internal.autonomousTurn?.id ?? null; + expect(firstRunId).toBe("autonomous-turn-1"); + expect(internal.nextTurnOrdinal).toBe(2); - internal.routeSdkMessageFromPump({ - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "WAKE" }, - }, - }); - expect(internal.autonomousTurn?.id).toBe(firstRunId); - expect(internal.nextTurnOrdinal).toBe(2); - - internal.routeSdkMessageFromPump({ - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }); - expect(internal.autonomousTurn).toBeNull(); - - await session.close(); + internal.routeSdkMessageFromPump({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "WAKE" }, + }, + }); + expect(internal.autonomousTurn?.id).toBe(firstRunId); + expect(internal.nextTurnOrdinal).toBe(2); + + internal.routeSdkMessageFromPump({ + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, }); + expect(internal.autonomousTurn).toBeNull(); - test("tracks run lifecycle transitions for success, error, and interrupt", async () => { - const session = await createSession(); - let streamCase: "success" | "error" | "interrupt" = "success"; + await session.close(); +}); - sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const readPromptUuid = createPromptUuidReader(prompt); - let step = 0; - let interruptRequested = false; +test("tracks run lifecycle transitions for success, error, and interrupt", async () => { + const session = await createSession(); + let streamCase: "success" | "error" | "interrupt" = "success"; - const mock = createBaseQueryMock( - vi.fn(async () => { - if (step === 0) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "init", - session_id: "redesign-lifecycle-session", - permissionMode: "default", - model: "opus", - }, - }; - } - if (step === 1) { - step += 1; - return { - done: false, - value: { - type: "user", - message: { role: "user", content: "prompt replay" }, - parent_tool_use_id: null, - uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", - session_id: "redesign-lifecycle-session", - }, - }; - } - if (step === 2) { - step += 1; + sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + let interruptRequested = false; + + const mock = createBaseQueryMock( + vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "redesign-lifecycle-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "prompt replay" }, + parent_tool_use_id: null, + uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", + session_id: "redesign-lifecycle-session", + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { content: "assistant output" }, + }, + }; + } + if (streamCase === "interrupt") { + if (!interruptRequested) { + await new Promise((resolve) => setTimeout(resolve, 50)); return { done: false, value: { type: "assistant", - message: { content: "assistant output" }, + message: { content: "waiting for interrupt" }, }, }; } - if (streamCase === "interrupt") { - if (!interruptRequested) { - await new Promise((resolve) => setTimeout(resolve, 50)); - return { - done: false, - value: { - type: "assistant", - message: { content: "waiting for interrupt" }, - }, - }; - } - return { done: true, value: undefined }; - } - if (step === 3) { - step += 1; - if (streamCase === "success") { - return { - done: false, - value: { - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }, - }; - } + return { done: true, value: undefined }; + } + if (step === 3) { + step += 1; + if (streamCase === "success") { return { done: false, value: { type: "result", - subtype: "error", + subtype: "success", usage: buildUsage(), - errors: ["simulated failure"], total_cost_usd: 0, }, }; } - return { done: true, value: undefined }; - }), - ); + return { + done: false, + value: { + type: "result", + subtype: "error", + usage: buildUsage(), + errors: ["simulated failure"], + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + ); - mock.interrupt.mockImplementation(async () => { - interruptRequested = true; - }); - return mock; + mock.interrupt.mockImplementation(async () => { + interruptRequested = true; }); + return mock; + }); - streamCase = "success"; - const successEvents = await collectUntilTerminal(streamSession(session, "success prompt")); - expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true); - expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false); - expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false); - - streamCase = "error"; - const errorEvents = await collectUntilTerminal(streamSession(session, "error prompt")); - expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true); - expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false); - - streamCase = "interrupt"; - const interruptStream = streamSession(session, "interrupt prompt"); - const interruptEvents: AgentStreamEvent[] = []; - for await (const event of interruptStream) { - interruptEvents.push(event); - if (event.type === "timeline" && event.item.type === "assistant_message") { - await session.interrupt(); - } - if (event.type === "turn_canceled") { - break; - } + streamCase = "success"; + const successEvents = await collectUntilTerminal(streamSession(session, "success prompt")); + expect(successEvents.some((event) => event.type === "turn_completed")).toBe(true); + expect(successEvents.some((event) => event.type === "turn_failed")).toBe(false); + expect(successEvents.some((event) => event.type === "turn_canceled")).toBe(false); + + streamCase = "error"; + const errorEvents = await collectUntilTerminal(streamSession(session, "error prompt")); + expect(errorEvents.some((event) => event.type === "turn_failed")).toBe(true); + expect(errorEvents.some((event) => event.type === "turn_completed")).toBe(false); + + streamCase = "interrupt"; + const interruptStream = streamSession(session, "interrupt prompt"); + const interruptEvents: AgentStreamEvent[] = []; + for await (const event of interruptStream) { + interruptEvents.push(event); + if (event.type === "timeline" && event.item.type === "assistant_message") { + await session.interrupt(); + } + if (event.type === "turn_canceled") { + break; } - expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(true); + } + expect(interruptEvents.some((event) => event.type === "turn_canceled")).toBe(true); - await session.close(); - }); + await session.close(); +}); - test("assembles assistant timeline when message_delta arrives before message_start", async () => { - sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const readPromptUuid = createPromptUuidReader(prompt); - let step = 0; - return createBaseQueryMock( - vi.fn(async () => { - if (step === 0) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "init", - session_id: "redesign-timeline-session", - permissionMode: "default", - model: "opus", +test("assembles assistant timeline when message_delta arrives before message_start", async () => { + sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + return createBaseQueryMock( + vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "redesign-timeline-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "timeline prompt" }, + parent_tool_use_id: null, + uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", + session_id: "redesign-timeline-session", + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "HELLO " }, }, - }; - } - if (step === 1) { - step += 1; - return { - done: false, - value: { - type: "user", - message: { role: "user", content: "timeline prompt" }, - parent_tool_use_id: null, - uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", - session_id: "redesign-timeline-session", + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "message_start", + message: { id: "message-1", role: "assistant", model: "opus" }, }, - }; - } - if (step === 2) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "HELLO " }, - }, + }, + }; + } + if (step === 4) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "content_block_delta", + message_id: "message-1", + delta: { type: "text_delta", text: "WORLD" }, }, - }; - } - if (step === 3) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - event: { - type: "message_start", - message: { id: "message-1", role: "assistant", model: "opus" }, - }, + }, + }; + } + if (step === 5) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "message_stop", + message_id: "message-1", }, - }; - } - if (step === 4) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - event: { - type: "content_block_delta", - message_id: "message-1", - delta: { type: "text_delta", text: "WORLD" }, - }, - }, - }; - } - if (step === 5) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - event: { - type: "message_stop", - message_id: "message-1", - }, - }, - }; - } - if (step === 6) { - step += 1; - return { - done: false, - value: { - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }, - }; - } - return { done: true, value: undefined }; - }), - ); - }); + }, + }; + } + if (step === 6) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + ); + }); - const session = await createSession(); - const events = await collectUntilTerminal(streamSession(session, "timeline prompt")); - const assistantText = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "assistant_message", - ) - .map((event) => event.item.text) - .join(""); + const session = await createSession(); + const events = await collectUntilTerminal(streamSession(session, "timeline prompt")); + const assistantText = events + .filter( + (event): event is Extract => + event.type === "timeline" && event.item.type === "assistant_message", + ) + .map((event) => event.item.text) + .join(""); - expect(assistantText).toContain("HELLO WORLD"); + expect(assistantText).toContain("HELLO WORLD"); - await session.close(); - }); + await session.close(); +}); - test("does not use stream_event uuid as assistant message identity when message_id is missing", async () => { - sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { - const readPromptUuid = createPromptUuidReader(prompt); - let step = 0; - return createBaseQueryMock( - vi.fn(async () => { - if (step === 0) { - step += 1; - return { - done: false, - value: { - type: "system", - subtype: "init", - session_id: "redesign-stream-event-uuid-session", - permissionMode: "default", - model: "opus", +test("does not use stream_event uuid as assistant message identity when message_id is missing", async () => { + sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + return createBaseQueryMock( + vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "redesign-stream-event-uuid-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "uuid fallback prompt" }, + parent_tool_use_id: null, + uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", + session_id: "redesign-stream-event-uuid-session", + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + uuid: "stream-event-uuid-1", + event: { + type: "message_start", + message: { role: "assistant", model: "opus" }, }, - }; - } - if (step === 1) { - step += 1; - return { - done: false, - value: { - type: "user", - message: { role: "user", content: "uuid fallback prompt" }, - parent_tool_use_id: null, - uuid: (await readPromptUuid()) ?? "missing-prompt-uuid", - session_id: "redesign-stream-event-uuid-session", + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + uuid: "stream-event-uuid-2", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "HELLO " }, }, - }; - } - if (step === 2) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - uuid: "stream-event-uuid-1", - event: { - type: "message_start", - message: { role: "assistant", model: "opus" }, - }, + }, + }; + } + if (step === 4) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + uuid: "stream-event-uuid-3", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "WORLD" }, }, - }; - } - if (step === 3) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - uuid: "stream-event-uuid-2", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "HELLO " }, - }, + }, + }; + } + if (step === 5) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + uuid: "stream-event-uuid-4", + event: { + type: "message_stop", }, - }; - } - if (step === 4) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - uuid: "stream-event-uuid-3", - event: { - type: "content_block_delta", - delta: { type: "text_delta", text: "WORLD" }, - }, - }, - }; - } - if (step === 5) { - step += 1; - return { - done: false, - value: { - type: "stream_event", - uuid: "stream-event-uuid-4", - event: { - type: "message_stop", - }, - }, - }; - } - if (step === 6) { - step += 1; - return { - done: false, - value: { - type: "result", - subtype: "success", - usage: buildUsage(), - total_cost_usd: 0, - }, - }; - } - return { done: true, value: undefined }; - }), - ); - }); + }, + }; + } + if (step === 6) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + return { done: true, value: undefined }; + }), + ); + }); - const session = await createSession(); - const events = await collectUntilTerminal(streamSession(session, "uuid fallback prompt")); - const assistantText = events - .filter( - (event): event is Extract => - event.type === "timeline" && event.item.type === "assistant_message", - ) - .map((event) => event.item.text) - .join(""); + const session = await createSession(); + const events = await collectUntilTerminal(streamSession(session, "uuid fallback prompt")); + const assistantText = events + .filter( + (event): event is Extract => + event.type === "timeline" && event.item.type === "assistant_message", + ) + .map((event) => event.item.text) + .join(""); - expect(assistantText).toContain("HELLO WORLD"); + expect(assistantText).toContain("HELLO WORLD"); - const assembler = session as unknown as { - timelineAssembler: { messages: Map }; - }; - expect(assembler.timelineAssembler.messages.size).toBe(0); + const assembler = session as unknown as { + timelineAssembler: { messages: Map }; + }; + expect(assembler.timelineAssembler.messages.size).toBe(0); - await session.close(); - }); + await session.close(); }); diff --git a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts index e65857906d..0c245427fd 100644 --- a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts +++ b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts @@ -4,548 +4,544 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseRolloutFile } from "./codex-rollout-timeline.js"; -describe("codex rollout parsing", () => { - let tmpDir: string; +let tmpDir: string; - beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "rollout-test-")); - }); +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "rollout-test-")); +}); - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); - describe("exec_command parsing", () => { - test("parses exec_command as Bash tool call with command", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.348Z", - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - arguments: '{"cmd":"task show cc4ea7d1"}', - call_id: "call_MhTWDF2mpM4dhbNmHNt6ikDF", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "Bash", - callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF", - detail: { type: "shell", command: "task show cc4ea7d1" }, - }); - }); - - test("includes function_call_output as tool call output", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.348Z", - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - arguments: '{"cmd":"echo hello"}', - call_id: "call_abc123", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.785Z", - type: "response_item", - payload: { - type: "function_call_output", - call_id: "call_abc123", - output: - "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nhello", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "Bash", - callId: "call_abc123", - detail: { - type: "shell", - command: "echo hello", - output: "hello", +describe("exec_command parsing", () => { + test("parses exec_command as Bash tool call with command", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"task show cc4ea7d1"}', + call_id: "call_MhTWDF2mpM4dhbNmHNt6ikDF", }, - }); - }); + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); - test("maps write_stdin polling calls into terminal interaction tool calls", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.348Z", - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - arguments: '{"cmd":"npm test"}', - call_id: "call_real", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.785Z", - type: "response_item", - payload: { - type: "function_call_output", - call_id: "call_real", - output: - "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:28:16.497Z", - type: "response_item", - payload: { - type: "function_call", - name: "write_stdin", - arguments: - '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', - call_id: "call_polling", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); + const timeline = await parseRolloutFile(rolloutPath); - const timeline = await parseRolloutFile(rolloutPath); - - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(2); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "Bash", - }); - expect(toolCalls[1]).toMatchObject({ - type: "tool_call", - name: "terminal", - callId: "terminal-session-7144", - detail: { - type: "plain_text", - icon: "square_terminal", - }, - metadata: { - processId: "7144", - }, - }); - }); - - test("falls back to generic terminal interaction label when command cannot be resolved", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:28:16.497Z", - type: "response_item", - payload: { - type: "function_call", - name: "write_stdin", - arguments: - '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', - call_id: "call_polling", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "terminal", - callId: "terminal-session-7144", - detail: { - type: "plain_text", - icon: "square_terminal", - }, - }); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF", + detail: { type: "shell", command: "task show cc4ea7d1" }, }); }); - describe("older shell format", () => { - test("parses shell command as Bash tool call", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2025-11-03T14:37:50.400Z", - type: "response_item", - payload: { - type: "function_call", - name: "shell", - arguments: '{"command":["bash","-lc","ls -la"],"workdir":"/Users/test/project"}', - call_id: "call_shell123", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "Bash", - callId: "call_shell123", - detail: { type: "shell", command: "ls -la" }, - }); - }); - }); - - describe("real rollout file structure", () => { - test("parses user message correctly", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "response_item", - payload: { - type: "message", - role: "user", - content: [{ type: "input_text", text: "Fix the bug in auth.ts" }], - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - expect(timeline).toContainEqual({ - type: "user_message", - text: "Fix the bug in auth.ts", - }); - }); - - test("parses assistant message correctly", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "I'll fix that for you." }], - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - expect(timeline).toContainEqual({ - type: "assistant_message", - text: "I'll fix that for you.", - }); - }); - - test("parses reasoning correctly", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "response_item", - payload: { - type: "reasoning", - content: [{ type: "reasoning_text", text: "Let me think about this." }], - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - expect(timeline).toContainEqual({ - type: "reasoning", - text: "Let me think about this.", - }); - }); - - test("parses legacy response_item shape using item", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "response_item", - item: { - type: "function_call", - name: "exec_command", - arguments: '{"cmd":"echo hello"}', - call_id: "call_legacy_1", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.785Z", - type: "response_item", - item: { - type: "function_call_output", - call_id: "call_legacy_1", - output: "hello", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - expect(toolCalls[0]).toMatchObject({ - type: "tool_call", - name: "Bash", - callId: "call_legacy_1", - detail: { type: "shell", command: "echo hello", output: "hello" }, - }); - }); - - test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const patch = [ - "*** Begin Patch", - "*** Add File: src/new-file.ts", - "+export const value = 1;", - "*** End Patch", - ].join("\n"); - const lines = [ - JSON.stringify({ - timestamp: "2026-02-09T10:00:00.000Z", - type: "response_item", - payload: { - type: "custom_tool_call", - name: "apply_patch", - call_id: "call_patch_custom_1", - input: patch, - }, - }), - JSON.stringify({ - timestamp: "2026-02-09T10:00:01.000Z", - type: "response_item", - payload: { - type: "custom_tool_call_output", - call_id: "call_patch_custom_1", - output: - '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}', - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - const toolCalls = timeline.filter((i) => i.type === "tool_call"); - expect(toolCalls.length).toBe(1); - - const patchCall = toolCalls[0]; - expect(patchCall).toMatchObject({ - type: "tool_call", - name: "apply_patch", - callId: "call_patch_custom_1", - status: "completed", - }); - expect(patchCall.detail.type).toBe("edit"); - if (patchCall.detail.type === "edit") { - expect(patchCall.detail.filePath).toBe("src/new-file.ts"); - expect(patchCall.detail.unifiedDiff).toContain("diff --git"); - expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;"); - expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch"); - } - }); - - test("parses legacy event_msg shape using msg", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "event_msg", - msg: { - type: "agent_reasoning", - text: "thinking", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "event_msg", - msg: { - type: "agent_message", - message: { role: "assistant", message: "done" }, - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "event_msg", - msg: { - type: "user_message", - message: { role: "user", message: "question" }, - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" }); - expect(timeline).toContainEqual({ type: "assistant_message", text: "done" }); - expect(timeline).toContainEqual({ type: "user_message", text: "question" }); - }); - - test("deduplicates mirrored response_item and event_msg text records", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.000Z", - type: "response_item", - payload: { - type: "message", - role: "user", - content: [{ type: "input_text", text: "question" }], - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.001Z", - type: "event_msg", - payload: { - type: "user_message", - message: "question", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.010Z", - type: "response_item", - payload: { - type: "reasoning", - content: [{ type: "reasoning_text", text: "thinking" }], - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.011Z", - type: "event_msg", - payload: { - type: "agent_reasoning", - text: "thinking", - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.020Z", - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "answer" }], - }, - }), - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.021Z", - type: "event_msg", - payload: { - type: "agent_message", - message: "answer", - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - expect(timeline).toEqual([ - { type: "user_message", text: "question" }, - { type: "reasoning", text: "thinking" }, - { type: "assistant_message", text: "answer" }, - ]); - }); - }); - - describe("complex conversation", () => { - test("parses a full conversation with commands and outputs", async () => { - const rolloutPath = join(tmpDir, "rollout.jsonl"); - const lines = [ - // User message - JSON.stringify({ - timestamp: "2026-01-22T07:08:54.378Z", - type: "response_item", - payload: { - type: "message", - role: "user", - content: [{ type: "input_text", text: "Run npm test" }], - }, - }), - // Reasoning - JSON.stringify({ - timestamp: "2026-01-22T07:08:55.000Z", - type: "response_item", - payload: { - type: "reasoning", - content: [{ type: "reasoning_text", text: "I need to run the tests." }], - }, - }), - // Command - JSON.stringify({ - timestamp: "2026-01-22T07:09:01.348Z", - type: "response_item", - payload: { - type: "function_call", - name: "exec_command", - arguments: '{"cmd":"npm test"}', - call_id: "call_test", - }, - }), - // Command output - JSON.stringify({ - timestamp: "2026-01-22T07:09:05.000Z", - type: "response_item", - payload: { - type: "function_call_output", - call_id: "call_test", - output: - "Chunk ID: abc\nWall time: 3.5 seconds\nProcess exited with code 0\nOutput:\nAll tests passed!", - }, - }), - // Assistant response - JSON.stringify({ - timestamp: "2026-01-22T07:09:06.000Z", - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "All tests passed!" }], - }, - }), - ]; - writeFileSync(rolloutPath, lines.join("\n") + "\n"); - - const timeline = await parseRolloutFile(rolloutPath); - - // Should have: user_message, reasoning, tool_call (with output), assistant_message - expect(timeline.length).toBe(4); - - expect(timeline[0]).toMatchObject({ type: "user_message", text: "Run npm test" }); - expect(timeline[1]).toMatchObject({ - type: "reasoning", - text: "I need to run the tests.", - }); - expect(timeline[2]).toMatchObject({ - type: "tool_call", - name: "Bash", - detail: { - type: "shell", - command: "npm test", - output: "All tests passed!", + test("includes function_call_output as tool call output", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"echo hello"}', + call_id: "call_abc123", }, - }); - expect(timeline[3]).toMatchObject({ - type: "assistant_message", - text: "All tests passed!", - }); + }), + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.785Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_abc123", + output: + "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nhello", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_abc123", + detail: { + type: "shell", + command: "echo hello", + output: "hello", + }, + }); + }); + + test("maps write_stdin polling calls into terminal interaction tool calls", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"npm test"}', + call_id: "call_real", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.785Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_real", + output: + "Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:28:16.497Z", + type: "response_item", + payload: { + type: "function_call", + name: "write_stdin", + arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', + call_id: "call_polling", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(2); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + }); + expect(toolCalls[1]).toMatchObject({ + type: "tool_call", + name: "terminal", + callId: "terminal-session-7144", + detail: { + type: "plain_text", + icon: "square_terminal", + }, + metadata: { + processId: "7144", + }, + }); + }); + + test("falls back to generic terminal interaction label when command cannot be resolved", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:28:16.497Z", + type: "response_item", + payload: { + type: "function_call", + name: "write_stdin", + arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}', + call_id: "call_polling", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "terminal", + callId: "terminal-session-7144", + detail: { + type: "plain_text", + icon: "square_terminal", + }, + }); + }); +}); + +describe("older shell format", () => { + test("parses shell command as Bash tool call", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2025-11-03T14:37:50.400Z", + type: "response_item", + payload: { + type: "function_call", + name: "shell", + arguments: '{"command":["bash","-lc","ls -la"],"workdir":"/Users/test/project"}', + call_id: "call_shell123", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_shell123", + detail: { type: "shell", command: "ls -la" }, + }); + }); +}); + +describe("real rollout file structure", () => { + test("parses user message correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Fix the bug in auth.ts" }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "user_message", + text: "Fix the bug in auth.ts", + }); + }); + + test("parses assistant message correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "I'll fix that for you." }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "assistant_message", + text: "I'll fix that for you.", + }); + }); + + test("parses reasoning correctly", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "Let me think about this." }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + expect(timeline).toContainEqual({ + type: "reasoning", + text: "Let me think about this.", + }); + }); + + test("parses legacy response_item shape using item", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + item: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"echo hello"}', + call_id: "call_legacy_1", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.785Z", + type: "response_item", + item: { + type: "function_call_output", + call_id: "call_legacy_1", + output: "hello", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + expect(toolCalls[0]).toMatchObject({ + type: "tool_call", + name: "Bash", + callId: "call_legacy_1", + detail: { type: "shell", command: "echo hello", output: "hello" }, + }); + }); + + test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const patch = [ + "*** Begin Patch", + "*** Add File: src/new-file.ts", + "+export const value = 1;", + "*** End Patch", + ].join("\n"); + const lines = [ + JSON.stringify({ + timestamp: "2026-02-09T10:00:00.000Z", + type: "response_item", + payload: { + type: "custom_tool_call", + name: "apply_patch", + call_id: "call_patch_custom_1", + input: patch, + }, + }), + JSON.stringify({ + timestamp: "2026-02-09T10:00:01.000Z", + type: "response_item", + payload: { + type: "custom_tool_call_output", + call_id: "call_patch_custom_1", + output: + '{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}', + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + const toolCalls = timeline.filter((i) => i.type === "tool_call"); + expect(toolCalls.length).toBe(1); + + const patchCall = toolCalls[0]; + expect(patchCall).toMatchObject({ + type: "tool_call", + name: "apply_patch", + callId: "call_patch_custom_1", + status: "completed", + }); + expect(patchCall.detail.type).toBe("edit"); + if (patchCall.detail.type === "edit") { + expect(patchCall.detail.filePath).toBe("src/new-file.ts"); + expect(patchCall.detail.unifiedDiff).toContain("diff --git"); + expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;"); + expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch"); + } + }); + + test("parses legacy event_msg shape using msg", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "event_msg", + msg: { + type: "agent_reasoning", + text: "thinking", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "event_msg", + msg: { + type: "agent_message", + message: { role: "assistant", message: "done" }, + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "event_msg", + msg: { + type: "user_message", + message: { role: "user", message: "question" }, + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" }); + expect(timeline).toContainEqual({ type: "assistant_message", text: "done" }); + expect(timeline).toContainEqual({ type: "user_message", text: "question" }); + }); + + test("deduplicates mirrored response_item and event_msg text records", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.000Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "question" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.001Z", + type: "event_msg", + payload: { + type: "user_message", + message: "question", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.010Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "thinking" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.011Z", + type: "event_msg", + payload: { + type: "agent_reasoning", + text: "thinking", + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.020Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "answer" }], + }, + }), + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.021Z", + type: "event_msg", + payload: { + type: "agent_message", + message: "answer", + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + expect(timeline).toEqual([ + { type: "user_message", text: "question" }, + { type: "reasoning", text: "thinking" }, + { type: "assistant_message", text: "answer" }, + ]); + }); +}); + +describe("complex conversation", () => { + test("parses a full conversation with commands and outputs", async () => { + const rolloutPath = join(tmpDir, "rollout.jsonl"); + const lines = [ + // User message + JSON.stringify({ + timestamp: "2026-01-22T07:08:54.378Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Run npm test" }], + }, + }), + // Reasoning + JSON.stringify({ + timestamp: "2026-01-22T07:08:55.000Z", + type: "response_item", + payload: { + type: "reasoning", + content: [{ type: "reasoning_text", text: "I need to run the tests." }], + }, + }), + // Command + JSON.stringify({ + timestamp: "2026-01-22T07:09:01.348Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + arguments: '{"cmd":"npm test"}', + call_id: "call_test", + }, + }), + // Command output + JSON.stringify({ + timestamp: "2026-01-22T07:09:05.000Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call_test", + output: + "Chunk ID: abc\nWall time: 3.5 seconds\nProcess exited with code 0\nOutput:\nAll tests passed!", + }, + }), + // Assistant response + JSON.stringify({ + timestamp: "2026-01-22T07:09:06.000Z", + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "All tests passed!" }], + }, + }), + ]; + writeFileSync(rolloutPath, lines.join("\n") + "\n"); + + const timeline = await parseRolloutFile(rolloutPath); + + // Should have: user_message, reasoning, tool_call (with output), assistant_message + expect(timeline.length).toBe(4); + + expect(timeline[0]).toMatchObject({ type: "user_message", text: "Run npm test" }); + expect(timeline[1]).toMatchObject({ + type: "reasoning", + text: "I need to run the tests.", + }); + expect(timeline[2]).toMatchObject({ + type: "tool_call", + name: "Bash", + detail: { + type: "shell", + command: "npm test", + output: "All tests passed!", + }, + }); + expect(timeline[3]).toMatchObject({ + type: "assistant_message", + text: "All tests passed!", }); }); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index 769f21a058..6ad08f0065 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -9,88 +9,86 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; -describe("OpenCodeAgentClient.listModels timeout", () => { - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); - test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { - vi.useFakeTimers(); +test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { + vi.useFakeTimers(); - const providerList = vi.fn( - () => - new Promise((resolve) => { - setTimeout(() => { - resolve({ - data: { - connected: ["zai"], - all: [ - { - id: "zai", - name: "Z.AI", - models: { - "glm-5.1": { - name: "GLM 5.1", - limit: { context: 128_000 }, - }, + const providerList = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: { + connected: ["zai"], + all: [ + { + id: "zai", + name: "Z.AI", + models: { + "glm-5.1": { + name: "GLM 5.1", + limit: { context: 128_000 }, }, }, - ], - }, - }); - }, 15_000); - }), - ); - - vi.mocked(createOpencodeClient).mockReturnValue({ - provider: { - list: providerList, - }, - } as never); - - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ - acquire: vi.fn().mockResolvedValue({ - server: { port: 1234, url: "http://127.0.0.1:1234" }, - release: vi.fn(), + }, + ], + }, + }); + }, 15_000); }), - } as never); + ); - const client = new OpenCodeAgentClient(createTestLogger()); - const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false }); + vi.mocked(createOpencodeClient).mockReturnValue({ + provider: { + list: providerList, + }, + } as never); - await vi.advanceTimersByTimeAsync(15_000); - - await expect(modelsPromise).resolves.toMatchObject([ - { - provider: "opencode", - id: "zai/glm-5.1", - label: "GLM 5.1", - }, - ]); - }); - - test("passes explicit refresh force through server acquisition", async () => { - vi.mocked(createOpencodeClient).mockReturnValue({ - provider: { - list: vi.fn().mockResolvedValue({ - data: { - connected: ["openai"], - all: [{ id: "openai", name: "OpenAI", models: {} }], - }, - }), - }, - } as never); - const acquire = vi.fn().mockResolvedValue({ + vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ + acquire: vi.fn().mockResolvedValue({ server: { port: 1234, url: "http://127.0.0.1:1234" }, release: vi.fn(), - }); - vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never); + }), + } as never); - const client = new OpenCodeAgentClient(createTestLogger()); + const client = new OpenCodeAgentClient(createTestLogger()); + const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false }); - await client.listModels({ cwd: "/tmp/opencode-models", force: true }); + await vi.advanceTimersByTimeAsync(15_000); - expect(acquire).toHaveBeenCalledWith({ force: true }); + await expect(modelsPromise).resolves.toMatchObject([ + { + provider: "opencode", + id: "zai/glm-5.1", + label: "GLM 5.1", + }, + ]); +}); + +test("passes explicit refresh force through server acquisition", async () => { + vi.mocked(createOpencodeClient).mockReturnValue({ + provider: { + list: vi.fn().mockResolvedValue({ + data: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + }, + }), + }, + } as never); + const acquire = vi.fn().mockResolvedValue({ + server: { port: 1234, url: "http://127.0.0.1:1234" }, + release: vi.fn(), }); + vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never); + + const client = new OpenCodeAgentClient(createTestLogger()); + + await client.listModels({ cwd: "/tmp/opencode-models", force: true }); + + expect(acquire).toHaveBeenCalledWith({ force: true }); }); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 97eded10d5..f76a52c1a2 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -244,30 +244,56 @@ function resolveSpeechConfig() { return undefined; } -describe("daemon client E2E", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeAll(async () => { - const speechConfig = resolveSpeechConfig(); +beforeAll(async () => { + const speechConfig = resolveSpeechConfig(); - ctx = await createDaemonTestContext({ - dictationFinalTimeoutMs: 5000, - ...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}), - ...(speechConfig ? { speech: speechConfig } : {}), - }); - }, 60000); + ctx = await createDaemonTestContext({ + dictationFinalTimeoutMs: 5000, + ...(openaiApiKey ? { openai: { apiKey: openaiApiKey } } : {}), + ...(speechConfig ? { speech: speechConfig } : {}), + }); +}, 60000); - afterAll(async () => { - await ctx.cleanup(); - }, 60000); +afterAll(async () => { + await ctx.cleanup(); +}, 60000); - test("handles session actions", async () => { - expect(ctx.client.isConnected).toBe(true); +test("handles session actions", async () => { + expect(ctx.client.isConnected).toBe(true); - const agents = await ctx.client.fetchAgents(); - expect(Array.isArray(agents.entries)).toBe(true); + const agents = await ctx.client.fetchAgents(); + expect(Array.isArray(agents.entries)).toBe(true); - const cwd = tmpCwd(); + const cwd = tmpCwd(); + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await expect(ctx.client.setVoiceMode(true, created.id)).resolves.toMatchObject({ + enabled: true, + agentId: created.id, + accepted: true, + error: null, + }); + await expect(ctx.client.setVoiceMode(false)).resolves.toMatchObject({ + enabled: false, + agentId: null, + accepted: true, + error: null, + }); + + await ctx.client.deleteAgent(randomUUID()); + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("archives agents and excludes them from default listings", async () => { + const cwd = tmpCwd(); + try { const created = await ctx.client.createAgent({ config: { ...getFullAccessConfig("codex"), @@ -275,1058 +301,1021 @@ describe("daemon client E2E", () => { }, }); - await expect(ctx.client.setVoiceMode(true, created.id)).resolves.toMatchObject({ - enabled: true, - agentId: created.id, - accepted: true, - error: null, - }); - await expect(ctx.client.setVoiceMode(false)).resolves.toMatchObject({ - enabled: false, - agentId: null, - accepted: true, - error: null, - }); + await ctx.client.archiveAgent(created.id); - await ctx.client.deleteAgent(randomUUID()); + const active = await ctx.client.fetchAgents(); + expect(active.entries.some((entry) => entry.agent.id === created.id)).toBe(false); + + const withArchived = await ctx.client.fetchAgents({ + filter: { includeArchived: true }, + }); + expect(withArchived.entries.some((entry) => entry.agent.id === created.id)).toBe(true); + } finally { rmSync(cwd, { recursive: true, force: true }); - }, 30000); + } +}, 30000); - test("archives agents and excludes them from default listings", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); +test("returns rpc error when archiving an unknown agent id", async () => { + await expect(ctx.client.archiveAgent(randomUUID())).rejects.toThrow(); +}, 10000); - await ctx.client.archiveAgent(created.id); - - const active = await ctx.client.fetchAgents(); - expect(active.entries.some((entry) => entry.agent.id === created.id)).toBe(false); - - const withArchived = await ctx.client.fetchAgents({ - filter: { includeArchived: true }, - }); - expect(withArchived.entries.some((entry) => entry.agent.id === created.id)).toBe(true); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); - - test("returns rpc error when archiving an unknown agent id", async () => { - await expect(ctx.client.archiveAgent(randomUUID())).rejects.toThrow(); - }, 10000); - - test("interrupts a running agent before archiving", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); - - await ctx.client.sendMessage( - created.id, - "Use your shell tool to run `sleep 30` and then confirm when done.", - ); - await ctx.client.waitForAgentUpsert( - created.id, - (snapshot) => snapshot.status === "running", - 15000, - ); - - const result = await ctx.client.archiveAgent(created.id); - expect(result.archivedAt).toBeTruthy(); - - const archivedResult = await ctx.client.fetchAgent(created.id); - expect(archivedResult).not.toBeNull(); - expect(archivedResult?.agent.archivedAt).toBeTruthy(); - expect(archivedResult?.agent.status).not.toBe("running"); - expect(archivedResult?.agent.requiresAttention).toBe(false); - expect(archivedResult?.agent.attentionReason).toBeNull(); - expect(archivedResult?.project).not.toBeNull(); - expect(archivedResult?.project?.checkout.cwd).toBe(cwd); - - const runningAgents = await ctx.client.fetchAgents({ - filter: { includeArchived: true, statuses: ["running"] }, - }); - expect(runningAgents.entries.some((entry) => entry.agent.id === created.id)).toBe(false); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 60000); - - test("send_agent_message auto-unarchives archived agents", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); - - await ctx.client.archiveAgent(created.id); - await ctx.client.sendMessage(created.id, "Say hello and nothing else"); - const finalState = await ctx.client.waitForFinish(created.id, 120000); - expect(finalState.status).toBe("idle"); - - const refreshed = await ctx.client.fetchAgent(created.id); - expect(refreshed).not.toBeNull(); - expect(refreshed?.agent.archivedAt).toBeNull(); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 180000); - - test("refresh_agent auto-unarchives archived agents", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); - await ctx.client.archiveAgent(created.id); - await ctx.client.refreshAgent(created.id); - - const refreshed = await ctx.client.fetchAgent(created.id); - expect(refreshed).not.toBeNull(); - expect(refreshed?.agent.archivedAt).toBeNull(); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 120000); - - test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => { - const cwd = tmpCwd(); - const client = new NonPersistentReloadClient(); - const localCtx = await createDaemonTestContext({ - agentClients: { - claude: client, +test("interrupts a running agent before archiving", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, }, }); - try { - const created = await localCtx.client.createAgent({ - config: { - provider: "claude", - cwd, - }, - }); + await ctx.client.sendMessage( + created.id, + "Use your shell tool to run `sleep 30` and then confirm when done.", + ); + await ctx.client.waitForAgentUpsert( + created.id, + (snapshot) => snapshot.status === "running", + 15000, + ); - expect(client.createSessionCalls).toBe(1); - expect(client.resumeSessionCalls).toBe(0); - expect(client.closeCalls).toBe(0); + const result = await ctx.client.archiveAgent(created.id); + expect(result.archivedAt).toBeTruthy(); - await localCtx.client.refreshAgent(created.id); + const archivedResult = await ctx.client.fetchAgent(created.id); + expect(archivedResult).not.toBeNull(); + expect(archivedResult?.agent.archivedAt).toBeTruthy(); + expect(archivedResult?.agent.status).not.toBe("running"); + expect(archivedResult?.agent.requiresAttention).toBe(false); + expect(archivedResult?.agent.attentionReason).toBeNull(); + expect(archivedResult?.project).not.toBeNull(); + expect(archivedResult?.project?.checkout.cwd).toBe(cwd); - expect(client.createSessionCalls).toBe(2); - expect(client.resumeSessionCalls).toBe(0); - expect(client.closeCalls).toBe(1); - } finally { - await localCtx.cleanup(); - rmSync(cwd, { recursive: true, force: true }); + const runningAgents = await ctx.client.fetchAgents({ + filter: { includeArchived: true, statuses: ["running"] }, + }); + expect(runningAgents.entries.some((entry) => entry.agent.id === created.id)).toBe(false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 60000); + +test("send_agent_message auto-unarchives archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.archiveAgent(created.id); + await ctx.client.sendMessage(created.id, "Say hello and nothing else"); + const finalState = await ctx.client.waitForFinish(created.id, 120000); + expect(finalState.status).toBe("idle"); + + const refreshed = await ctx.client.fetchAgent(created.id); + expect(refreshed).not.toBeNull(); + expect(refreshed?.agent.archivedAt).toBeNull(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); + +test("refresh_agent auto-unarchives archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + await ctx.client.archiveAgent(created.id); + await ctx.client.refreshAgent(created.id); + + const refreshed = await ctx.client.fetchAgent(created.id); + expect(refreshed).not.toBeNull(); + expect(refreshed?.agent.archivedAt).toBeNull(); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 120000); + +test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => { + const cwd = tmpCwd(); + const client = new NonPersistentReloadClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { + claude: client, + }, + }); + + try { + const created = await localCtx.client.createAgent({ + config: { + provider: "claude", + cwd, + }, + }); + + expect(client.createSessionCalls).toBe(1); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(0); + + await localCtx.client.refreshAgent(created.id); + + expect(client.createSessionCalls).toBe(2); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(1); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("resume_agent auto-unarchives archived agents", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const agentBeforeArchive = await ctx.client.fetchAgent(created.id); + expect(agentBeforeArchive?.agent.persistence).toBeTruthy(); + await ctx.client.archiveAgent(created.id); + + const handle = agentBeforeArchive?.agent.persistence; + if (!handle) { + throw new Error("Expected persistence handle for resume test"); + } + const resumed = await ctx.client.resumeAgent(handle); + const resumedDetails = await ctx.client.fetchAgent(resumed.id); + expect(resumedDetails).not.toBeNull(); + expect(resumedDetails?.agent.archivedAt).toBeNull(); + + if (resumed.id !== created.id) { + await ctx.client.deleteAgent(resumed.id); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); + +test("update_agent persists unloaded title and labels across auto-unarchive", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + + await ctx.client.archiveAgent(created.id); + await ctx.client.updateAgent(created.id, { + name: "Pinned Title", + labels: { lane: "phase-1a" }, + }); + + const archived = await ctx.client.fetchAgent(created.id); + expect(archived).not.toBeNull(); + expect(archived?.agent.archivedAt).toBeTruthy(); + expect(archived?.agent.title).toBe("Pinned Title"); + expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" }); + + await ctx.client.sendMessage(created.id, "Say hello and nothing else"); + const finalState = await ctx.client.waitForFinish(created.id, 120000); + expect(finalState.status).toBe("idle"); + + const unarchived = await ctx.client.fetchAgent(created.id); + expect(unarchived).not.toBeNull(); + expect(unarchived?.agent.archivedAt).toBeNull(); + expect(unarchived?.agent.title).toBe("Pinned Title"); + expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); + +test("returns home-scoped directory suggestions", async () => { + const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); + const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); + + try { + const insideQuery = path.basename(insideHomeDir); + const insideResult = await ctx.client.getDirectorySuggestions({ + query: insideQuery, + limit: 25, + }); + expect(insideResult.error).toBeNull(); + expect(insideResult.directories).toContain(insideHomeDir); + + const outsideQuery = path.basename(outsideHomeDir); + const outsideResult = await ctx.client.getDirectorySuggestions({ + query: outsideQuery, + limit: 25, + }); + expect(outsideResult.error).toBeNull(); + expect(outsideResult.directories).not.toContain(outsideHomeDir); + } finally { + rmSync(insideHomeDir, { recursive: true, force: true }); + rmSync(outsideHomeDir, { recursive: true, force: true }); + } +}, 30000); + +test("receives server_info on websocket connect", async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + clientId: `cid-e2e-${randomUUID()}`, + clientType: "cli", + }); + await client.connect(); + const serverInfo = client.getLastServerInfoMessage(); + expect(serverInfo).not.toBeNull(); + expect(serverInfo?.serverId.length).toBeGreaterThan(0); + + await client.close(); +}, 15000); + +test("emits disabled voice capability reasons on fresh daemon startup", async () => { + const isolatedCtx = await createDaemonTestContext({ + speech: { + providers: { + dictationStt: { provider: "local", explicit: true, enabled: false }, + voiceTurnDetection: { provider: "local", explicit: true, enabled: false }, + voiceStt: { provider: "local", explicit: true, enabled: false }, + voiceTts: { provider: "local", explicit: true, enabled: false }, + }, + }, + }); + + const client = new DaemonClient({ + url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`, + clientId: `cid-e2e-${randomUUID()}`, + clientType: "cli", + }); + + try { + await client.connect(); + const serverInfo = client.getLastServerInfoMessage(); + const voice = serverInfo?.capabilities?.voice; + expect(voice).toBeTruthy(); + + expect(voice?.dictation.enabled).toBe(false); + expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); + expect(voice?.voice.enabled).toBe(false); + expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); + } finally { + await client.close().catch(() => undefined); + await isolatedCtx.cleanup(); + } +}, 30000); + +test("handles concurrent filtered agent fetch requests", async () => { + const firstRequestId = `fetch-${Date.now()}-a`; + const secondRequestId = `fetch-${Date.now()}-b`; + + const [first, second] = await Promise.all([ + ctx.client.fetchAgents({ + requestId: firstRequestId, + filter: { labels: { surface: "voice" } }, + }), + ctx.client.fetchAgents({ + requestId: secondRequestId, + filter: { labels: { surface: "voice" } }, + }), + ]); + + expect(first.requestId).toBe(firstRequestId); + expect(second.requestId).toBe(secondRequestId); + expect(Array.isArray(first.entries)).toBe(true); + expect(Array.isArray(second.entries)).toBe(true); +}, 15000); + +test("creates agent and exercises lifecycle", async () => { + const cwd = tmpCwd(); + + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "daemon-client-lifecycle" }, + }); + + const agentUpdatePromise = waitForSignal(15000, (resolve) => { + const unsubscribe = ctx.client.on("agent_update", (message) => { + if (message.type !== "agent_update") { + return; + } + if (message.payload.kind !== "upsert") { + return; + } + resolve(message); + }); + return unsubscribe; + }); + + const createRequestId = `create-${Date.now()}`; + const createdStatusPromise = waitForSignal(15000, (resolve) => { + const unsubscribe = ctx.client.on("status", (message) => { + if (message.type !== "status") { + return; + } + const payload = message.payload as { + status?: string; + agentId?: string; + requestId?: string; + }; + if (payload.status !== "agent_created") { + return; + } + if (payload.requestId !== createRequestId) { + return; + } + resolve(message); + }); + return unsubscribe; + }); + + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + title: "Daemon Client V2", + requestId: createRequestId, + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + const fetchedResult = await ctx.client.fetchAgent(agent.id); + expect(fetchedResult?.agent.id).toBe(agent.id); + + const agentUpdate = await agentUpdatePromise; + expect(agentUpdate.payload.agent.id).toBe(agent.id); + const createdStatus = await createdStatusPromise; + expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id); + + const failRequestId = `fail-${Date.now()}`; + const failedStatusPromise = waitForSignal(15000, (resolve) => { + const unsubscribe = ctx.client.on("status", (message) => { + if (message.type !== "status") { + return; + } + const payload = message.payload as { + status?: string; + requestId?: string; + }; + if (payload.status !== "agent_create_failed") { + return; + } + if (payload.requestId !== failRequestId) { + return; + } + resolve(message); + }); + return unsubscribe; + }); + + await expect( + ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd: "/this/path/does/not/exist/12345", + title: "Should Fail", + requestId: failRequestId, + }), + ).rejects.toThrow("Working directory does not exist"); + await failedStatusPromise; + + let sawRefresh = false; + const unsubscribe = ctx.client.subscribe((event) => { + if (event.type === "status" && event.payload.status === "agent_refreshed") { + sawRefresh = true; } }); - test("resume_agent auto-unarchives archived agents", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); - const agentBeforeArchive = await ctx.client.fetchAgent(created.id); - expect(agentBeforeArchive?.agent.persistence).toBeTruthy(); - await ctx.client.archiveAgent(created.id); - - const handle = agentBeforeArchive?.agent.persistence; - if (!handle) { - throw new Error("Expected persistence handle for resume test"); - } - const resumed = await ctx.client.resumeAgent(handle); - const resumedDetails = await ctx.client.fetchAgent(resumed.id); - expect(resumedDetails).not.toBeNull(); - expect(resumedDetails?.agent.archivedAt).toBeNull(); - - if (resumed.id !== created.id) { - await ctx.client.deleteAgent(resumed.id); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 180000); - - test("update_agent persists unloaded title and labels across auto-unarchive", async () => { - const cwd = tmpCwd(); - try { - const created = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd, - }, - }); - - await ctx.client.archiveAgent(created.id); - await ctx.client.updateAgent(created.id, { - name: "Pinned Title", - labels: { lane: "phase-1a" }, - }); - - const archived = await ctx.client.fetchAgent(created.id); - expect(archived).not.toBeNull(); - expect(archived?.agent.archivedAt).toBeTruthy(); - expect(archived?.agent.title).toBe("Pinned Title"); - expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" }); - - await ctx.client.sendMessage(created.id, "Say hello and nothing else"); - const finalState = await ctx.client.waitForFinish(created.id, 120000); - expect(finalState.status).toBe("idle"); - - const unarchived = await ctx.client.fetchAgent(created.id); - expect(unarchived).not.toBeNull(); - expect(unarchived?.agent.archivedAt).toBeNull(); - expect(unarchived?.agent.title).toBe("Pinned Title"); - expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 180000); - - test("returns home-scoped directory suggestions", async () => { - const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); - const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); - - try { - const insideQuery = path.basename(insideHomeDir); - const insideResult = await ctx.client.getDirectorySuggestions({ - query: insideQuery, - limit: 25, - }); - expect(insideResult.error).toBeNull(); - expect(insideResult.directories).toContain(insideHomeDir); - - const outsideQuery = path.basename(outsideHomeDir); - const outsideResult = await ctx.client.getDirectorySuggestions({ - query: outsideQuery, - limit: 25, - }); - expect(outsideResult.error).toBeNull(); - expect(outsideResult.directories).not.toContain(outsideHomeDir); - } finally { - rmSync(insideHomeDir, { recursive: true, force: true }); - rmSync(outsideHomeDir, { recursive: true, force: true }); - } - }, 30000); - - test("receives server_info on websocket connect", async () => { - const client = new DaemonClient({ - url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, - clientId: `cid-e2e-${randomUUID()}`, - clientType: "cli", - }); - await client.connect(); - const serverInfo = client.getLastServerInfoMessage(); - expect(serverInfo).not.toBeNull(); - expect(serverInfo?.serverId.length).toBeGreaterThan(0); - - await client.close(); - }, 15000); - - test("emits disabled voice capability reasons on fresh daemon startup", async () => { - const isolatedCtx = await createDaemonTestContext({ - speech: { - providers: { - dictationStt: { provider: "local", explicit: true, enabled: false }, - voiceTurnDetection: { provider: "local", explicit: true, enabled: false }, - voiceStt: { provider: "local", explicit: true, enabled: false }, - voiceTts: { provider: "local", explicit: true, enabled: false }, - }, - }, - }); - - const client = new DaemonClient({ - url: `ws://127.0.0.1:${isolatedCtx.daemon.port}/ws`, - clientId: `cid-e2e-${randomUUID()}`, - clientType: "cli", - }); - - try { - await client.connect(); - const serverInfo = client.getLastServerInfoMessage(); - const voice = serverInfo?.capabilities?.voice; - expect(voice).toBeTruthy(); - - expect(voice?.dictation.enabled).toBe(false); - expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config."); - expect(voice?.voice.enabled).toBe(false); - expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config."); - } finally { - await client.close().catch(() => undefined); - await isolatedCtx.cleanup(); - } - }, 30000); - - test("handles concurrent filtered agent fetch requests", async () => { - const firstRequestId = `fetch-${Date.now()}-a`; - const secondRequestId = `fetch-${Date.now()}-b`; - - const [first, second] = await Promise.all([ - ctx.client.fetchAgents({ - requestId: firstRequestId, - filter: { labels: { surface: "voice" } }, - }), - ctx.client.fetchAgents({ - requestId: secondRequestId, - filter: { labels: { surface: "voice" } }, - }), - ]); - - expect(first.requestId).toBe(firstRequestId); - expect(second.requestId).toBe(secondRequestId); - expect(Array.isArray(first.entries)).toBe(true); - expect(Array.isArray(second.entries)).toBe(true); - }, 15000); - - test("creates agent and exercises lifecycle", async () => { - const cwd = tmpCwd(); - - await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "daemon-client-lifecycle" }, - }); - - const agentUpdatePromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("agent_update", (message) => { - if (message.type !== "agent_update") { - return; - } - if (message.payload.kind !== "upsert") { - return; - } - resolve(message); - }); - return unsubscribe; - }); - - const createRequestId = `create-${Date.now()}`; - const createdStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - agentId?: string; - requestId?: string; - }; - if (payload.status !== "agent_created") { - return; - } - if (payload.requestId !== createRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - - const agent = await ctx.client.createAgent({ - ...getFullAccessConfig("codex"), - cwd, - title: "Daemon Client V2", - requestId: createRequestId, - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - const fetchedResult = await ctx.client.fetchAgent(agent.id); - expect(fetchedResult?.agent.id).toBe(agent.id); - - const agentUpdate = await agentUpdatePromise; - expect(agentUpdate.payload.agent.id).toBe(agent.id); - const createdStatus = await createdStatusPromise; - expect((createdStatus.payload as { agentId?: string }).agentId).toBe(agent.id); - - const failRequestId = `fail-${Date.now()}`; - const failedStatusPromise = waitForSignal(15000, (resolve) => { - const unsubscribe = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - const payload = message.payload as { - status?: string; - requestId?: string; - }; - if (payload.status !== "agent_create_failed") { - return; - } - if (payload.requestId !== failRequestId) { - return; - } - resolve(message); - }); - return unsubscribe; - }); - - await expect( - ctx.client.createAgent({ - ...getFullAccessConfig("codex"), - cwd: "/this/path/does/not/exist/12345", - title: "Should Fail", - requestId: failRequestId, - }), - ).rejects.toThrow("Working directory does not exist"); - await failedStatusPromise; - - let sawRefresh = false; - const unsubscribe = ctx.client.subscribe((event) => { - if (event.type === "status" && event.payload.status === "agent_refreshed") { - sawRefresh = true; - } - }); - - const statusPromise = waitForSignal(15000, (resolve) => { - const unsubscribeStatus = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - if (message.payload.status !== "agent_refreshed") { - return; - } - if ((message.payload as { agentId?: string }).agentId !== agent.id) { - return; - } - resolve(message); - }); - return unsubscribeStatus; - }); - - const refreshResult = await ctx.client.refreshAgent(agent.id); - unsubscribe(); - - expect(refreshResult.status).toBe("agent_refreshed"); - expect(refreshResult.agentId).toBe(agent.id); - expect(sawRefresh).toBe(true); - const statusMessage = await statusPromise; - expect((statusMessage.payload as { agentId?: string }).agentId).toBe(agent.id); - - const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 1, - }); - expect(timelineResult.agentId).toBe(agent.id); - - const nextMode = agent.availableModes.find((mode) => mode.id !== agent.currentModeId)?.id; - - if (nextMode) { - await ctx.client.setAgentMode(agent.id, nextMode); - const modeState = await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.currentModeId === nextMode, - 15000, - ); - expect(modeState.currentModeId).toBe(nextMode); - } else { - await ctx.client.setAgentMode(agent.id, agent.currentModeId ?? "auto"); - } - - let sawAssistantMessage = false; - let sawRawAssistantMessage = false; - const unsubscribeStream = ctx.client.subscribe((event) => { - if (event.type !== "agent_stream" || event.agentId !== agent.id) { + const statusPromise = waitForSignal(15000, (resolve) => { + const unsubscribeStatus = ctx.client.on("status", (message) => { + if (message.type !== "status") { return; } - if (event.event.type === "timeline" && event.event.item.type === "assistant_message") { - sawAssistantMessage = true; + if (message.payload.status !== "agent_refreshed") { + return; } + if ((message.payload as { agentId?: string }).agentId !== agent.id) { + return; + } + resolve(message); }); - const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => { - if (message.type !== "agent_stream") { + return unsubscribeStatus; + }); + + const refreshResult = await ctx.client.refreshAgent(agent.id); + unsubscribe(); + + expect(refreshResult.status).toBe("agent_refreshed"); + expect(refreshResult.agentId).toBe(agent.id); + expect(sawRefresh).toBe(true); + const statusMessage = await statusPromise; + expect((statusMessage.payload as { agentId?: string }).agentId).toBe(agent.id); + + const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 1, + }); + expect(timelineResult.agentId).toBe(agent.id); + + const nextMode = agent.availableModes.find((mode) => mode.id !== agent.currentModeId)?.id; + + if (nextMode) { + await ctx.client.setAgentMode(agent.id, nextMode); + const modeState = await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.currentModeId === nextMode, + 15000, + ); + expect(modeState.currentModeId).toBe(nextMode); + } else { + await ctx.client.setAgentMode(agent.id, agent.currentModeId ?? "auto"); + } + + let sawAssistantMessage = false; + let sawRawAssistantMessage = false; + const unsubscribeStream = ctx.client.subscribe((event) => { + if (event.type !== "agent_stream" || event.agentId !== agent.id) { + return; + } + if (event.event.type === "timeline" && event.event.item.type === "assistant_message") { + sawAssistantMessage = true; + } + }); + const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => { + if (message.type !== "agent_stream") { + return; + } + if (message.payload.agentId !== agent.id) { + return; + } + if ( + message.payload.event.type === "timeline" && + message.payload.event.item.type === "assistant_message" + ) { + sawRawAssistantMessage = true; + } + }); + await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + unsubscribeStream(); + unsubscribeRawStream(); + expect(finalState.status).toBe("idle"); + expect(sawAssistantMessage).toBe(true); + expect(sawRawAssistantMessage).toBe(true); + + await ctx.client.setVoiceMode(false); + + await ctx.client.abortRequest(); + await ctx.client.audioPlayed("audio-1"); + ctx.client.clearAgentAttention(agent.id); + await ctx.client.cancelAgent(agent.id); + + const modelsRequestId = `models-${Date.now()}`; + const modelsPromise = waitForSignal(30000, (resolve) => { + const unsubscribeModels = ctx.client.on("list_provider_models_response", (message) => { + if (message.type !== "list_provider_models_response") { + return; + } + if (message.payload.provider !== "codex") { + return; + } + if (message.payload.requestId !== modelsRequestId) { + return; + } + resolve(message); + }); + return unsubscribeModels; + }); + + const models = await ctx.client.listProviderModels("codex", { + cwd, + requestId: modelsRequestId, + }); + const modelsMessage = await modelsPromise; + expect(models.provider).toBe("codex"); + expect(models.fetchedAt).toBeTruthy(); + expect(models.requestId).toBe(modelsRequestId); + expect(modelsMessage.payload.provider).toBe("codex"); + expect(modelsMessage.payload.requestId).toBe(modelsRequestId); + + const commandsRequestId = `commands-${Date.now()}`; + const commandsResponsePromise = waitForSignal(15000, (resolve) => { + const unsubscribeCommands = ctx.client.on("list_commands_response", (message) => { + if (message.type !== "list_commands_response") { return; } if (message.payload.agentId !== agent.id) { return; } - if ( - message.payload.event.type === "timeline" && - message.payload.event.item.type === "assistant_message" - ) { - sawRawAssistantMessage = true; + if (message.payload.requestId !== commandsRequestId) { + return; } + resolve(message); }); - await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - unsubscribeStream(); - unsubscribeRawStream(); - expect(finalState.status).toBe("idle"); - expect(sawAssistantMessage).toBe(true); - expect(sawRawAssistantMessage).toBe(true); + return unsubscribeCommands; + }); + const commands = await ctx.client.listCommands(agent.id, commandsRequestId); + const commandsMessage = await commandsResponsePromise; + expect(commands.agentId).toBe(agent.id); + expect(Array.isArray(commands.commands)).toBe(true); + expect(commands.requestId).toBe(commandsRequestId); + expect(commandsMessage.payload.agentId).toBe(agent.id); + expect(commandsMessage.payload.requestId).toBe(commandsRequestId); + + const persistence = finalState.final?.persistence; + + const agentDeletedPromise = waitForSignal(15000, (resolve) => { + const unsubscribeDeleted = ctx.client.on("agent_deleted", (message) => { + if (message.type !== "agent_deleted") { + return; + } + if (message.payload.agentId !== agent.id) { + return; + } + resolve(message); + }); + return unsubscribeDeleted; + }); + + await ctx.client.deleteAgent(agent.id); + const agentDeleted = await agentDeletedPromise; + expect(agentDeleted.payload.agentId).toBe(agent.id); + + if (persistence) { + const resumed = await ctx.client.resumeAgent(persistence); + expect(resumed.id).toBeTruthy(); + expect(resumed.status).toBe("idle"); + await ctx.client.deleteAgent(resumed.id); + } + + rmSync(cwd, { recursive: true, force: true }); +}, 300000); + +test("handles permission flow", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "permission.txt"); + + const agent = await ctx.client.createAgent({ + ...getAskModeConfig("codex"), + cwd, + title: "Permission Test", + }); + + const permissionRequestPromise = waitForSignal(60000, (resolve) => { + const unsubscribe = ctx.client.on("agent_permission_request", (message) => { + if (message.type !== "agent_permission_request") { + return; + } + if (message.payload.agentId !== agent.id) { + return; + } + resolve(message); + }); + return unsubscribe; + }); + + const permissionResolvedPromise = waitForSignal(60000, (resolve) => { + const unsubscribe = ctx.client.on("agent_permission_resolved", (message) => { + if (message.type !== "agent_permission_resolved") { + return; + } + if (message.payload.agentId !== agent.id) { + return; + } + resolve(message); + }); + return unsubscribe; + }); + + try { + await ctx.client.sendMessage( + agent.id, + [ + 'Use your shell tool to run: `printf "ok" > permission.txt`.', + "This will require approval. Request permission and wait for approval before continuing.", + ].join("\n"), + ); + + const permissionState = await ctx.client.waitForFinish(agent.id, 60000); + expect(permissionState.status).toBe("permission"); + expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); + const permission = permissionState.final!.pendingPermissions[0]; + expect(permission).toBeTruthy(); + expect(permission.id).toBeTruthy(); + + const permissionRequest = await permissionRequestPromise; + expect(permissionRequest.payload.agentId).toBe(agent.id); + + await ctx.client.respondToPermission(agent.id, permission.id, { + behavior: "allow", + }); + + const permissionResolved = await permissionResolvedPromise; + expect(permissionResolved.payload.requestId).toBe(permission.id); + + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + expect(finalState.status).toBe("idle"); + expect(existsSync(filePath)).toBe(true); + } finally { + // Prevent unhandled rejections if the test fails before promises resolve. + await permissionRequestPromise.catch(() => {}); + await permissionResolvedPromise.catch(() => {}); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + } +}, 180000); + +test("exposes raw session events for reachable screens", async () => { + const cwd = tmpCwd(); + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + title: "Raw Events Test", + }); + + await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); + await ctx.client.waitForFinish(agent.id, 120000); + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + }); + expect(timeline.entries.length).toBeGreaterThan(0); + + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 120000); + +speechTest( + "does not process non-voice audio through the voice agent path", + async () => { await ctx.client.setVoiceMode(false); - await ctx.client.abortRequest(); - await ctx.client.audioPlayed("audio-1"); - ctx.client.clearAgentAttention(agent.id); - await ctx.client.cancelAgent(agent.id); + let sawTranscriptLog = false; + let sawAssistantChunk = false; + let sawAssistantLog = false; - const modelsRequestId = `models-${Date.now()}`; - const modelsPromise = waitForSignal(30000, (resolve) => { - const unsubscribeModels = ctx.client.on("list_provider_models_response", (message) => { - if (message.type !== "list_provider_models_response") { + const transcriptSeen = waitForSignal(60000, (resolve) => { + const unsubscribeChunk = ctx.client.on("assistant_chunk", (message) => { + if (message.type !== "assistant_chunk") { return; } - if (message.payload.provider !== "codex") { - return; + if (message.payload.chunk.length > 0) { + sawAssistantChunk = true; } - if (message.payload.requestId !== modelsRequestId) { - return; - } - resolve(message); }); - return unsubscribeModels; - }); - const models = await ctx.client.listProviderModels("codex", { - cwd, - requestId: modelsRequestId, - }); - const modelsMessage = await modelsPromise; - expect(models.provider).toBe("codex"); - expect(models.fetchedAt).toBeTruthy(); - expect(models.requestId).toBe(modelsRequestId); - expect(modelsMessage.payload.provider).toBe("codex"); - expect(modelsMessage.payload.requestId).toBe(modelsRequestId); - - const commandsRequestId = `commands-${Date.now()}`; - const commandsResponsePromise = waitForSignal(15000, (resolve) => { - const unsubscribeCommands = ctx.client.on("list_commands_response", (message) => { - if (message.type !== "list_commands_response") { + const unsubscribeActivity = ctx.client.on("activity_log", (message) => { + if (message.type !== "activity_log") { return; } - if (message.payload.agentId !== agent.id) { - return; + if (message.payload.type === "transcript") { + sawTranscriptLog = true; + resolve(); } - if (message.payload.requestId !== commandsRequestId) { - return; + if (message.payload.type === "assistant") { + sawAssistantLog = true; } - resolve(message); }); - return unsubscribeCommands; + + return () => { + unsubscribeChunk(); + unsubscribeActivity(); + }; }); - const commands = await ctx.client.listCommands(agent.id, commandsRequestId); - const commandsMessage = await commandsResponsePromise; - expect(commands.agentId).toBe(agent.id); - expect(Array.isArray(commands.commands)).toBe(true); - expect(commands.requestId).toBe(commandsRequestId); - expect(commandsMessage.payload.agentId).toBe(agent.id); - expect(commandsMessage.payload.requestId).toBe(commandsRequestId); + const wav = await readFixture("recording.wav"); + await ctx.client.sendVoiceAudioChunk(wav.toString("base64"), "audio/wav", true); + await transcriptSeen; + await new Promise((resolve) => setTimeout(resolve, 1500)); - const persistence = finalState.final?.persistence; + expect(sawTranscriptLog).toBe(true); + expect(sawAssistantChunk).toBe(false); + expect(sawAssistantLog).toBe(false); + }, + 90000, +); - const agentDeletedPromise = waitForSignal(15000, (resolve) => { - const unsubscribeDeleted = ctx.client.on("agent_deleted", (message) => { - if (message.type !== "agent_deleted") { - return; - } - if (message.payload.agentId !== agent.id) { - return; - } - resolve(message); - }); - return unsubscribeDeleted; +speechTest( + "voice mode buffers audio until isLast and emits transcription_result", + async () => { + const voiceCwd = tmpCwd(); + const voiceAgent = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: voiceCwd, + }, }); + await ctx.client.setVoiceMode(true, voiceAgent.id); - await ctx.client.deleteAgent(agent.id); - const agentDeleted = await agentDeletedPromise; - expect(agentDeleted.payload.agentId).toBe(agent.id); - - if (persistence) { - const resumed = await ctx.client.resumeAgent(persistence); - expect(resumed.id).toBeTruthy(); - expect(resumed.status).toBe("idle"); - await ctx.client.deleteAgent(resumed.id); - } - - rmSync(cwd, { recursive: true, force: true }); - }, 300000); - - test("handles permission flow", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "permission.txt"); - - const agent = await ctx.client.createAgent({ - ...getAskModeConfig("codex"), - cwd, - title: "Permission Test", - }); - - const permissionRequestPromise = waitForSignal(60000, (resolve) => { - const unsubscribe = ctx.client.on("agent_permission_request", (message) => { - if (message.type !== "agent_permission_request") { + const transcription = waitForSignal(30_000, (resolve) => { + const unsubscribe = ctx.client.on("transcription_result", (message) => { + if (message.type !== "transcription_result") { return; } - if (message.payload.agentId !== agent.id) { - return; - } - resolve(message); + resolve(message.payload); }); return unsubscribe; }); - const permissionResolvedPromise = waitForSignal(60000, (resolve) => { - const unsubscribe = ctx.client.on("agent_permission_resolved", (message) => { - if (message.type !== "agent_permission_resolved") { + const errorSignal = waitForSignal(30_000, (resolve) => { + const unsubscribeStatus = ctx.client.on("status", (message) => { + if (message.type !== "status") { return; } - if (message.payload.agentId !== agent.id) { + if (message.payload.status !== "error") { return; } - resolve(message); + resolve(`status:error ${message.payload.message}`); }); - return unsubscribe; + + const unsubscribeLog = ctx.client.on("activity_log", (message) => { + if (message.type !== "activity_log") { + return; + } + if (message.payload.type !== "error") { + return; + } + resolve(`activity_log:error ${message.payload.content}`); + }); + + return () => { + unsubscribeStatus(); + unsubscribeLog(); + }; }); try { - await ctx.client.sendMessage( - agent.id, - [ - 'Use your shell tool to run: `printf "ok" > permission.txt`.', - "This will require approval. Request permission and wait for approval before continuing.", - ].join("\n"), - ); - - const permissionState = await ctx.client.waitForFinish(agent.id, 60000); - expect(permissionState.status).toBe("permission"); - expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); - const permission = permissionState.final!.pendingPermissions[0]; - expect(permission).toBeTruthy(); - expect(permission.id).toBeTruthy(); - - const permissionRequest = await permissionRequestPromise; - expect(permissionRequest.payload.agentId).toBe(agent.id); - - await ctx.client.respondToPermission(agent.id, permission.id, { - behavior: "allow", - }); - - const permissionResolved = await permissionResolvedPromise; - expect(permissionResolved.payload.requestId).toBe(permission.id); - - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - expect(finalState.status).toBe("idle"); - expect(existsSync(filePath)).toBe(true); - } finally { - // Prevent unhandled rejections if the test fails before promises resolve. - await permissionRequestPromise.catch(() => {}); - await permissionResolvedPromise.catch(() => {}); - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - } - }, 180000); - - test("exposes raw session events for reachable screens", async () => { - const cwd = tmpCwd(); - const agent = await ctx.client.createAgent({ - ...getFullAccessConfig("codex"), - cwd, - title: "Raw Events Test", - }); - - await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); - await ctx.client.waitForFinish(agent.id, 120000); - - const timeline = await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - }); - expect(timeline.entries.length).toBeGreaterThan(0); - - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 120000); - - speechTest( - "does not process non-voice audio through the voice agent path", - async () => { - await ctx.client.setVoiceMode(false); - - let sawTranscriptLog = false; - let sawAssistantChunk = false; - let sawAssistantLog = false; - - const transcriptSeen = waitForSignal(60000, (resolve) => { - const unsubscribeChunk = ctx.client.on("assistant_chunk", (message) => { - if (message.type !== "assistant_chunk") { - return; - } - if (message.payload.chunk.length > 0) { - sawAssistantChunk = true; - } - }); - - const unsubscribeActivity = ctx.client.on("activity_log", (message) => { - if (message.type !== "activity_log") { - return; - } - if (message.payload.type === "transcript") { - sawTranscriptLog = true; - resolve(); - } - if (message.payload.type === "assistant") { - sawAssistantLog = true; - } - }); - - return () => { - unsubscribeChunk(); - unsubscribeActivity(); - }; - }); - const wav = await readFixture("recording.wav"); - await ctx.client.sendVoiceAudioChunk(wav.toString("base64"), "audio/wav", true); - await transcriptSeen; - await new Promise((resolve) => setTimeout(resolve, 1500)); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + const format = "audio/pcm;rate=16000;bits=16"; - expect(sawTranscriptLog).toBe(true); - expect(sawAssistantChunk).toBe(false); - expect(sawAssistantLog).toBe(false); - }, - 90000, - ); - - speechTest( - "voice mode buffers audio until isLast and emits transcription_result", - async () => { - const voiceCwd = tmpCwd(); - const voiceAgent = await ctx.client.createAgent({ - config: { - ...getFullAccessConfig("codex"), - cwd: voiceCwd, - }, - }); - await ctx.client.setVoiceMode(true, voiceAgent.id); - - const transcription = waitForSignal(30_000, (resolve) => { + const earlyTranscription = waitForSignal(1000, (resolve) => { const unsubscribe = ctx.client.on("transcription_result", (message) => { if (message.type !== "transcription_result") { return; } - resolve(message.payload); + resolve(message.payload.text); }); return unsubscribe; }); - const errorSignal = waitForSignal(30_000, (resolve) => { - const unsubscribeStatus = ctx.client.on("status", (message) => { - if (message.type !== "status") { - return; - } - if (message.payload.status !== "error") { - return; - } - resolve(`status:error ${message.payload.message}`); - }); - - const unsubscribeLog = ctx.client.on("activity_log", (message) => { - if (message.type !== "activity_log") { - return; - } - if (message.payload.type !== "error") { - return; - } - resolve(`activity_log:error ${message.payload.content}`); - }); - - return () => { - unsubscribeStatus(); - unsubscribeLog(); - }; - }); - - try { - const wav = await readFixture("recording.wav"); - const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); - expect(sampleRate).toBe(16000); - const format = "audio/pcm;rate=16000;bits=16"; - - const earlyTranscription = waitForSignal(1000, (resolve) => { - const unsubscribe = ctx.client.on("transcription_result", (message) => { - if (message.type !== "transcription_result") { - return; - } - resolve(message.payload.text); - }); - return unsubscribe; - }); - - const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 - const firstChunk = pcm16.subarray(0, Math.min(chunkBytes, pcm16.length)); - await ctx.client.sendVoiceAudioChunk(firstChunk.toString("base64"), format, false); - await earlyTranscription - .then(() => { - throw new Error("Expected no transcription_result before isLast=true"); - }) - .catch(() => {}); - - for (let offset = chunkBytes; offset < pcm16.length; offset += chunkBytes) { - const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - const isLast = offset + chunkBytes >= pcm16.length; - await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast); - } - - const outcome = await Promise.race([ - transcription.then((payload) => ({ kind: "ok" as const, payload })), - errorSignal.then((error) => ({ kind: "error" as const, error })), - ]); - - if (outcome.kind === "error") { - throw new Error(outcome.error); - } - - expect(typeof outcome.payload.text).toBe("string"); - if (outcome.payload.text.trim().length > 0) { - expect(outcome.payload.text.toLowerCase()).toContain("voice note"); - } else { - expect(outcome.payload.isLowConfidence).toBe(true); - } - } finally { - await Promise.allSettled([transcription, errorSignal]); - await ctx.client.setVoiceMode(false); - rmSync(voiceCwd, { recursive: true, force: true }); - } - }, - 90_000, - ); - - speechTest( - "streams dictation PCM and returns final transcript", - async () => { - const wav = await readFixture("recording.wav"); - const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); - expect(sampleRate).toBe(16000); - const dictationId = `dict-${Date.now()}`; - const format = "audio/pcm;rate=16000;bits=16"; - - await ctx.client.startDictationStream(dictationId, format); - - const chunkBytes = 3200; // ~100ms @ 16kHz mono PCM16 (1600 samples * 2 bytes) - let seq = 0; - for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { - const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); - seq += 1; - } - - const finalSeq = seq - 1; - const result = await ctx.client.finishDictationStream(dictationId, finalSeq); - - expect(result.dictationId).toBe(dictationId); - expect(result.text.toLowerCase()).toContain("voice note"); - }, - 30_000, - ); - - speechTest( - "realtime dictation transcript is similar to baseline fixture", - async () => { - const wav = await readFixture("recording.wav"); - const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); - expect(sampleRate).toBe(16000); - const dictationId = `dict-baseline-${Date.now()}`; - const format = "audio/pcm;rate=16000;bits=16"; - - const baseline = (await readFile(fixturePath("recording.baseline.txt"), "utf-8")).trim(); - - await ctx.client.startDictationStream(dictationId, format); - const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 - let seq = 0; - for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const firstChunk = pcm16.subarray(0, Math.min(chunkBytes, pcm16.length)); + await ctx.client.sendVoiceAudioChunk(firstChunk.toString("base64"), format, false); + await earlyTranscription + .then(() => { + throw new Error("Expected no transcription_result before isLast=true"); + }) + .catch(() => {}); + + for (let offset = chunkBytes; offset < pcm16.length; offset += chunkBytes) { const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); - seq += 1; + const isLast = offset + chunkBytes >= pcm16.length; + await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast); } - const finalSeq = seq - 1; - const result = await ctx.client.finishDictationStream(dictationId, finalSeq); + const outcome = await Promise.race([ + transcription.then((payload) => ({ kind: "ok" as const, payload })), + errorSignal.then((error) => ({ kind: "error" as const, error })), + ]); - expect(result.dictationId).toBe(dictationId); - expect(wordSimilarity(result.text, baseline)).toBeGreaterThan(0.6); - }, - 30_000, + if (outcome.kind === "error") { + throw new Error(outcome.error); + } + + expect(typeof outcome.payload.text).toBe("string"); + if (outcome.payload.text.trim().length > 0) { + expect(outcome.payload.text.toLowerCase()).toContain("voice note"); + } else { + expect(outcome.payload.isLowConfidence).toBe(true); + } + } finally { + await Promise.allSettled([transcription, errorSignal]); + await ctx.client.setVoiceMode(false); + rmSync(voiceCwd, { recursive: true, force: true }); + } + }, + 90_000, +); + +speechTest( + "streams dictation PCM and returns final transcript", + async () => { + const wav = await readFixture("recording.wav"); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + const dictationId = `dict-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + await ctx.client.startDictationStream(dictationId, format); + + const chunkBytes = 3200; // ~100ms @ 16kHz mono PCM16 (1600 samples * 2 bytes) + let seq = 0; + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); + seq += 1; + } + + const finalSeq = seq - 1; + const result = await ctx.client.finishDictationStream(dictationId, finalSeq); + + expect(result.dictationId).toBe(dictationId); + expect(result.text.toLowerCase()).toContain("voice note"); + }, + 30_000, +); + +speechTest( + "realtime dictation transcript is similar to baseline fixture", + async () => { + const wav = await readFixture("recording.wav"); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + const dictationId = `dict-baseline-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + const baseline = (await readFile(fixturePath("recording.baseline.txt"), "utf-8")).trim(); + + await ctx.client.startDictationStream(dictationId, format); + + const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 + let seq = 0; + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); + seq += 1; + } + + const finalSeq = seq - 1; + const result = await ctx.client.finishDictationStream(dictationId, finalSeq); + + expect(result.dictationId).toBe(dictationId); + expect(wordSimilarity(result.text, baseline)).toBeGreaterThan(0.6); + }, + 30_000, +); + +speechTest( + "fails fast if dictation finishes without sending required chunks", + async () => { + const dictationId = `dict-missing-chunks-${Date.now()}`; + const format = "audio/pcm;rate=16000;bits=16"; + + await ctx.client.startDictationStream(dictationId, format); + + // Claim that we sent chunk 0, but actually send no chunks. + await expect(ctx.client.finishDictationStream(dictationId, 0)).rejects.toThrow( + /no audio chunks were received/i, + ); + }, + 15_000, +); + +test("supports git and file operations", async () => { + const cwd = tmpCwd(); + + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); + + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "original content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgSign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", + }); + + writeFileSync(testFile, "modified content\n"); + + const downloadFile = path.join(cwd, "download.txt"); + const downloadContents = "download payload"; + writeFileSync(downloadFile, downloadContents, "utf-8"); + + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + title: "Git/File Test", + }); + + // Test checkout status RPC + const checkoutStatus = await ctx.client.getCheckoutStatus(cwd); + expect(checkoutStatus.error).toBeNull(); + expect(checkoutStatus.isGit).toBe(true); + expect(checkoutStatus.repoRoot).toContain(cwd); + + const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + expect(diffResult.error).toBeNull(); + expect(Array.isArray(diffResult.files)).toBe(true); + expect(diffResult.files.length).toBeGreaterThan(0); + expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true); + + const listRequestId = `list-${Date.now()}`; + const listMessagePromise = waitForSignal(15000, (resolve) => { + const unsubscribeList = ctx.client.on("file_explorer_response", (message) => { + if (message.type !== "file_explorer_response") { + return; + } + if (message.payload.cwd !== cwd) { + return; + } + if (message.payload.mode !== "list") { + return; + } + if (message.payload.requestId !== listRequestId) { + return; + } + resolve(message); + }); + return unsubscribeList; + }); + + const listResult = await ctx.client.exploreFileSystem(cwd, ".", "list", listRequestId); + const listMessage = await listMessagePromise; + expect(listResult.error).toBeNull(); + expect(listResult.directory).toBeTruthy(); + expect(listResult.requestId).toBe(listRequestId); + expect(listMessage.payload.mode).toBe("list"); + expect(listMessage.payload.requestId).toBe(listRequestId); + + const fileRequestId = `file-${Date.now()}`; + const fileMessagePromise = waitForSignal(15000, (resolve) => { + const unsubscribeFile = ctx.client.on("file_explorer_response", (message) => { + if (message.type !== "file_explorer_response") { + return; + } + if (message.payload.cwd !== cwd) { + return; + } + if (message.payload.mode !== "file") { + return; + } + if (message.payload.requestId !== fileRequestId) { + return; + } + resolve(message); + }); + return unsubscribeFile; + }); + + const fileResult = await ctx.client.exploreFileSystem(cwd, "download.txt", "file", fileRequestId); + const fileMessage = await fileMessagePromise; + expect(fileResult.error).toBeNull(); + expect(fileResult.file?.content).toBe(downloadContents); + expect(fileResult.requestId).toBe(fileRequestId); + expect(fileMessage.payload.mode).toBe("file"); + expect(fileMessage.payload.requestId).toBe(fileRequestId); + + const tokenRequestId = `token-${Date.now()}`; + const tokenMessagePromise = waitForSignal(15000, (resolve) => { + const unsubscribeToken = ctx.client.on("file_download_token_response", (message) => { + if (message.type !== "file_download_token_response") { + return; + } + if (message.payload.cwd !== cwd) { + return; + } + if (!message.payload.path.endsWith("download.txt")) { + return; + } + if (message.payload.requestId !== tokenRequestId) { + return; + } + resolve(message); + }); + return unsubscribeToken; + }); + + const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt", tokenRequestId); + const tokenMessage = await tokenMessagePromise; + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toBeTruthy(); + expect(tokenResponse.requestId).toBe(tokenRequestId); + expect(tokenMessage.payload.cwd).toBe(cwd); + expect(tokenMessage.payload.requestId).toBe(tokenRequestId); + + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, ); - speechTest( - "fails fast if dictation finishes without sending required chunks", - async () => { - const dictationId = `dict-missing-chunks-${Date.now()}`; - const format = "audio/pcm;rate=16000;bits=16"; + expect(response.status).toBe(200); + const body = await response.text(); + expect(body).toBe(downloadContents); - await ctx.client.startDictationStream(dictationId, format); - - // Claim that we sent chunk 0, but actually send no chunks. - await expect(ctx.client.finishDictationStream(dictationId, 0)).rejects.toThrow( - /no audio chunks were received/i, - ); - }, - 15_000, - ); - - test("supports git and file operations", async () => { - const cwd = tmpCwd(); - - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "original content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgSign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); - - writeFileSync(testFile, "modified content\n"); - - const downloadFile = path.join(cwd, "download.txt"); - const downloadContents = "download payload"; - writeFileSync(downloadFile, downloadContents, "utf-8"); - - const agent = await ctx.client.createAgent({ - ...getFullAccessConfig("codex"), - cwd, - title: "Git/File Test", - }); - - // Test checkout status RPC - const checkoutStatus = await ctx.client.getCheckoutStatus(cwd); - expect(checkoutStatus.error).toBeNull(); - expect(checkoutStatus.isGit).toBe(true); - expect(checkoutStatus.repoRoot).toContain(cwd); - - const diffResult = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - expect(diffResult.error).toBeNull(); - expect(Array.isArray(diffResult.files)).toBe(true); - expect(diffResult.files.length).toBeGreaterThan(0); - expect(diffResult.files.some((file) => file.path === "test.txt")).toBe(true); - - const listRequestId = `list-${Date.now()}`; - const listMessagePromise = waitForSignal(15000, (resolve) => { - const unsubscribeList = ctx.client.on("file_explorer_response", (message) => { - if (message.type !== "file_explorer_response") { - return; - } - if (message.payload.cwd !== cwd) { - return; - } - if (message.payload.mode !== "list") { - return; - } - if (message.payload.requestId !== listRequestId) { - return; - } - resolve(message); - }); - return unsubscribeList; - }); - - const listResult = await ctx.client.exploreFileSystem(cwd, ".", "list", listRequestId); - const listMessage = await listMessagePromise; - expect(listResult.error).toBeNull(); - expect(listResult.directory).toBeTruthy(); - expect(listResult.requestId).toBe(listRequestId); - expect(listMessage.payload.mode).toBe("list"); - expect(listMessage.payload.requestId).toBe(listRequestId); - - const fileRequestId = `file-${Date.now()}`; - const fileMessagePromise = waitForSignal(15000, (resolve) => { - const unsubscribeFile = ctx.client.on("file_explorer_response", (message) => { - if (message.type !== "file_explorer_response") { - return; - } - if (message.payload.cwd !== cwd) { - return; - } - if (message.payload.mode !== "file") { - return; - } - if (message.payload.requestId !== fileRequestId) { - return; - } - resolve(message); - }); - return unsubscribeFile; - }); - - const fileResult = await ctx.client.exploreFileSystem( - cwd, - "download.txt", - "file", - fileRequestId, - ); - const fileMessage = await fileMessagePromise; - expect(fileResult.error).toBeNull(); - expect(fileResult.file?.content).toBe(downloadContents); - expect(fileResult.requestId).toBe(fileRequestId); - expect(fileMessage.payload.mode).toBe("file"); - expect(fileMessage.payload.requestId).toBe(fileRequestId); - - const tokenRequestId = `token-${Date.now()}`; - const tokenMessagePromise = waitForSignal(15000, (resolve) => { - const unsubscribeToken = ctx.client.on("file_download_token_response", (message) => { - if (message.type !== "file_download_token_response") { - return; - } - if (message.payload.cwd !== cwd) { - return; - } - if (!message.payload.path.endsWith("download.txt")) { - return; - } - if (message.payload.requestId !== tokenRequestId) { - return; - } - resolve(message); - }); - return unsubscribeToken; - }); - - const tokenResponse = await ctx.client.requestDownloadToken( - cwd, - "download.txt", - tokenRequestId, - ); - const tokenMessage = await tokenMessagePromise; - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.requestId).toBe(tokenRequestId); - expect(tokenMessage.payload.cwd).toBe(cwd); - expect(tokenMessage.payload.requestId).toBe(tokenRequestId); - - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); - - expect(response.status).toBe(200); - const body = await response.text(); - expect(body).toBe(downloadContents); - - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 120000); -}); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 120000); diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 7881086a73..fbe4c4ee55 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -13,381 +13,379 @@ function tmpCwd(): string { const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; -describe("daemon E2E", () => { - let ctx: DaemonTestContext; - let messages: SessionOutboundMessage[] = []; - let unsubscribe: (() => void) | null = null; - - beforeEach(async () => { - ctx = await createDaemonTestContext(); - messages = []; - unsubscribe = ctx.client.subscribeRawMessages((message) => { - messages.push(message); - }); - }); - - afterEach(async () => { - unsubscribe?.(); - await ctx.cleanup(); - }, 60000); - - describe("timestamp behavior", () => { - test("opening agent without interaction does not update timestamp", async () => { - const cwd = tmpCwd(); - - // Create a Codex agent - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Timestamp Test Agent", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Record the initial updatedAt timestamp - const initialUpdatedAt = agent.updatedAt; - expect(initialUpdatedAt).toBeTruthy(); - - // Wait a bit to ensure any timestamp update would be visible - await new Promise((resolve) => setTimeout(resolve, 1500)); - - // Clear captured messages before the "click" action - messages.length = 0; - - // Simulate opening the agent in the UI: - // fetch timeline window + fetch current agent snapshot. - await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 200, - }); - const refreshedResult = await ctx.client.fetchAgent(agent.id); - - // Verify agent is still idle - expect(refreshedResult?.agent.status).toBe("idle"); - - // CRITICAL: The timestamp should NOT have changed - // Just opening/clicking an agent should not update its updatedAt - expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt); - - // Also clear attention (what happens when opening an agent with notification) - await ctx.client.clearAgentAttention(agent.id); - - // Get the state again after clearing attention - await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 200, - }); - const clearResult = await ctx.client.fetchAgent(agent.id); - - // Timestamp should STILL not have changed - expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt); - - // Cleanup - rmSync(cwd, { recursive: true, force: true }); - }, 60000); - - test("sending message DOES update timestamp", async () => { - const cwd = tmpCwd(); - - // Create a Codex agent - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Timestamp Update Test Agent", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Record the initial updatedAt timestamp - const initialUpdatedAt = new Date(agent.updatedAt); - - // Wait a bit to ensure timestamp difference is visible - await new Promise((resolve) => setTimeout(resolve, 1500)); - - // Send a message (this SHOULD update the timestamp) - await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else"); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - expect(finalState.status).toBe("idle"); - - // The timestamp SHOULD have been updated (should be later than initial) - const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0); - expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); - - // Cleanup - rmSync(cwd, { recursive: true, force: true }); - }, 180000); - }); - - describe("cancelAgent", () => { - test("cancels a running agent mid-execution", async () => { - const cwd = tmpCwd(); - - await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-cancel" }, - }); - - // Create Codex agent - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Cancel Test Agent", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Clear message queue before sending prompt - messages.length = 0; - - // Send a prompt that triggers a long-running operation - await ctx.client.sendMessage(agent.id, "Run: sleep 30"); - - // Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race) - await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 30000, - ); - - // Record timestamp before cancel - const cancelStart = Date.now(); - - // Cancel the agent - await ctx.client.cancelAgent(agent.id); - - // Wait for agent to reach idle or error state via server wait RPC - const afterCancel = await ctx.client.waitForFinish(agent.id, 10000); - - // Calculate how long the cancel took - const cancelDuration = Date.now() - cancelStart; - - // Verify agent stopped within reasonable time (2 seconds) - expect(cancelDuration).toBeLessThan(3000); - - // Verify agent is now idle or error - expect(["idle", "error"]).toContain(afterCancel.status); - - // Verify no zombie sleep processes left (check for sleep 30) - const { execSync } = await import("child_process"); - try { - const result = execSync("pgrep -f 'sleep 30'", { - encoding: "utf8", - timeout: 2000, - }); - // If pgrep succeeds, there are zombie processes - if (result.trim()) { - // Kill them and fail the test - execSync("pkill -f 'sleep 30'"); - expect.fail("Found zombie sleep processes after cancel"); - } - } catch { - // pgrep returns non-zero when no processes found - this is expected - } - - // Cleanup - rmSync(cwd, { recursive: true, force: true }); - }, 60000); - }); - - describe("setAgentMode", () => { - test("switches agent mode and persists across messages", async () => { - const cwd = tmpCwd(); - - await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-mode" }, - }); - - // Create a Codex agent with default mode ("auto") - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Mode Switch Test Agent", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Verify initial mode is "auto" (the default) - expect(agent.currentModeId).toBe("auto"); - - // Clear message queue before mode switch - messages.length = 0; - const startPosition = messages.length; - - // Switch to "read-only" mode - await ctx.client.setAgentMode(agent.id, "read-only"); - - // Wait for agent_update upsert reflecting the new mode - const stateAfterModeSwitch = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timeout waiting for mode change in agent_update")); - }, 10000); - - const checkForModeChange = (): void => { - const queue = messages; - for (let i = startPosition; i < queue.length; i++) { - const msg = queue[i]; - if ( - msg.type === "agent_update" && - msg.payload.kind === "upsert" && - msg.payload.agent.id === agent.id && - msg.payload.agent.currentModeId === "read-only" - ) { - clearTimeout(timeout); - clearInterval(interval); - resolve(msg.payload.agent); - return; - } - } - }; - - const interval = setInterval(checkForModeChange, 50); - }); - - // Verify mode changed to "read-only" - expect(stateAfterModeSwitch.currentModeId).toBe("read-only"); - - // Now verify the mode persists: send a message and check the mode is still "read-only" - messages.length = 0; - await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); - - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - - // Mode should still be "read-only" after the message - expect(finalState.final?.currentModeId).toBe("read-only"); - - // Also verify runtimeInfo has the updated modeId - expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only"); - - // Switch to another mode: "full-access" - messages.length = 0; - const position2 = messages.length; - - await ctx.client.setAgentMode(agent.id, "full-access"); - - // Wait for agent_update upsert - const stateAfterFullAccess = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timeout waiting for full-access mode change")); - }, 10000); - - const checkForModeChange = (): void => { - const queue = messages; - for (let i = position2; i < queue.length; i++) { - const msg = queue[i]; - if ( - msg.type === "agent_update" && - msg.payload.kind === "upsert" && - msg.payload.agent.id === agent.id && - msg.payload.agent.currentModeId === "full-access" - ) { - clearTimeout(timeout); - clearInterval(interval); - resolve(msg.payload.agent); - return; - } - } - }; - - const interval = setInterval(checkForModeChange, 50); - }); - - expect(stateAfterFullAccess.currentModeId).toBe("full-access"); - - // Cleanup - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); - }); - - describe("listAgents", () => { - test("returns current agents and reflects create/delete operations", async () => { - const cwd1 = tmpCwd(); - const cwd2 = tmpCwd(); - - // Initially, there should be no agents (fresh session) - const initialAgents = await ctx.client.fetchAgents(); - expect(initialAgents.entries).toHaveLength(0); - - // Create first agent - const agent1 = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: cwd1, - title: "List Test Agent 1", - }); - - expect(agent1.id).toBeTruthy(); - expect(agent1.status).toBe("idle"); - - // fetchAgents should now return 1 agent - const afterFirst = await ctx.client.fetchAgents(); - expect(afterFirst.entries).toHaveLength(1); - expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id); - // Title may or may not be set depending on timing - expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1); - - // Create second agent - const agent2 = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: cwd2, - title: "List Test Agent 2", - }); - - expect(agent2.id).toBeTruthy(); - expect(agent2.status).toBe("idle"); - - // fetchAgents should now return 2 agents - const afterSecond = await ctx.client.fetchAgents(); - expect(afterSecond.entries).toHaveLength(2); - - // Verify both agents are present with correct IDs and states - const ids = afterSecond.entries.map((entry) => entry.agent.id); - expect(ids).toContain(agent1.id); - expect(ids).toContain(agent2.id); - - const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent; - const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent; - - // Title may or may not be set depending on timing - expect(agent1State?.cwd).toBe(cwd1); - expect(agent1State?.status).toBe("idle"); - - // Title may or may not be set depending on timing - expect(agent2State?.cwd).toBe(cwd2); - expect(agent2State?.status).toBe("idle"); - - // Delete first agent - await ctx.client.deleteAgent(agent1.id); - - // fetchAgents should now return only 1 agent - const afterDelete = await ctx.client.fetchAgents(); - expect(afterDelete.entries).toHaveLength(1); - expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id); - expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2); - - // Verify agent1 is no longer in the list - const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id); - expect(deletedAgent).toBeUndefined(); - - // Cleanup - await ctx.client.deleteAgent(agent2.id); - rmSync(cwd1, { recursive: true, force: true }); - rmSync(cwd2, { recursive: true, force: true }); - }, 60000); // 1 minute timeout +let ctx: DaemonTestContext; +let messages: SessionOutboundMessage[] = []; +let unsubscribe: (() => void) | null = null; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); + messages = []; + unsubscribe = ctx.client.subscribeRawMessages((message) => { + messages.push(message); }); }); + +afterEach(async () => { + unsubscribe?.(); + await ctx.cleanup(); +}, 60000); + +describe("timestamp behavior", () => { + test("opening agent without interaction does not update timestamp", async () => { + const cwd = tmpCwd(); + + // Create a Codex agent + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Timestamp Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Record the initial updatedAt timestamp + const initialUpdatedAt = agent.updatedAt; + expect(initialUpdatedAt).toBeTruthy(); + + // Wait a bit to ensure any timestamp update would be visible + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Clear captured messages before the "click" action + messages.length = 0; + + // Simulate opening the agent in the UI: + // fetch timeline window + fetch current agent snapshot. + await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 200, + }); + const refreshedResult = await ctx.client.fetchAgent(agent.id); + + // Verify agent is still idle + expect(refreshedResult?.agent.status).toBe("idle"); + + // CRITICAL: The timestamp should NOT have changed + // Just opening/clicking an agent should not update its updatedAt + expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt); + + // Also clear attention (what happens when opening an agent with notification) + await ctx.client.clearAgentAttention(agent.id); + + // Get the state again after clearing attention + await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 200, + }); + const clearResult = await ctx.client.fetchAgent(agent.id); + + // Timestamp should STILL not have changed + expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt); + + // Cleanup + rmSync(cwd, { recursive: true, force: true }); + }, 60000); + + test("sending message DOES update timestamp", async () => { + const cwd = tmpCwd(); + + // Create a Codex agent + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Timestamp Update Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Record the initial updatedAt timestamp + const initialUpdatedAt = new Date(agent.updatedAt); + + // Wait a bit to ensure timestamp difference is visible + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Send a message (this SHOULD update the timestamp) + await ctx.client.sendMessage(agent.id, "Say 'test' and nothing else"); + + // Wait for agent to complete + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + expect(finalState.status).toBe("idle"); + + // The timestamp SHOULD have been updated (should be later than initial) + const finalUpdatedAt = new Date(finalState.final?.updatedAt ?? 0); + expect(finalUpdatedAt.getTime()).toBeGreaterThan(initialUpdatedAt.getTime()); + + // Cleanup + rmSync(cwd, { recursive: true, force: true }); + }, 180000); +}); + +describe("cancelAgent", () => { + test("cancels a running agent mid-execution", async () => { + const cwd = tmpCwd(); + + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "agent-operations-cancel" }, + }); + + // Create Codex agent + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Cancel Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Clear message queue before sending prompt + messages.length = 0; + + // Send a prompt that triggers a long-running operation + await ctx.client.sendMessage(agent.id, "Run: sleep 30"); + + // Wait for the agent to begin running (fetch_agent RPC; no agent_update subscription race) + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 30000, + ); + + // Record timestamp before cancel + const cancelStart = Date.now(); + + // Cancel the agent + await ctx.client.cancelAgent(agent.id); + + // Wait for agent to reach idle or error state via server wait RPC + const afterCancel = await ctx.client.waitForFinish(agent.id, 10000); + + // Calculate how long the cancel took + const cancelDuration = Date.now() - cancelStart; + + // Verify agent stopped within reasonable time (2 seconds) + expect(cancelDuration).toBeLessThan(3000); + + // Verify agent is now idle or error + expect(["idle", "error"]).toContain(afterCancel.status); + + // Verify no zombie sleep processes left (check for sleep 30) + const { execSync } = await import("child_process"); + try { + const result = execSync("pgrep -f 'sleep 30'", { + encoding: "utf8", + timeout: 2000, + }); + // If pgrep succeeds, there are zombie processes + if (result.trim()) { + // Kill them and fail the test + execSync("pkill -f 'sleep 30'"); + expect.fail("Found zombie sleep processes after cancel"); + } + } catch { + // pgrep returns non-zero when no processes found - this is expected + } + + // Cleanup + rmSync(cwd, { recursive: true, force: true }); + }, 60000); +}); + +describe("setAgentMode", () => { + test("switches agent mode and persists across messages", async () => { + const cwd = tmpCwd(); + + await ctx.client.fetchAgents({ + subscribe: { subscriptionId: "agent-operations-mode" }, + }); + + // Create a Codex agent with default mode ("auto") + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Mode Switch Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Verify initial mode is "auto" (the default) + expect(agent.currentModeId).toBe("auto"); + + // Clear message queue before mode switch + messages.length = 0; + const startPosition = messages.length; + + // Switch to "read-only" mode + await ctx.client.setAgentMode(agent.id, "read-only"); + + // Wait for agent_update upsert reflecting the new mode + const stateAfterModeSwitch = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for mode change in agent_update")); + }, 10000); + + const checkForModeChange = (): void => { + const queue = messages; + for (let i = startPosition; i < queue.length; i++) { + const msg = queue[i]; + if ( + msg.type === "agent_update" && + msg.payload.kind === "upsert" && + msg.payload.agent.id === agent.id && + msg.payload.agent.currentModeId === "read-only" + ) { + clearTimeout(timeout); + clearInterval(interval); + resolve(msg.payload.agent); + return; + } + } + }; + + const interval = setInterval(checkForModeChange, 50); + }); + + // Verify mode changed to "read-only" + expect(stateAfterModeSwitch.currentModeId).toBe("read-only"); + + // Now verify the mode persists: send a message and check the mode is still "read-only" + messages.length = 0; + await ctx.client.sendMessage(agent.id, "Say 'hello' and nothing else"); + + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + + // Mode should still be "read-only" after the message + expect(finalState.final?.currentModeId).toBe("read-only"); + + // Also verify runtimeInfo has the updated modeId + expect(finalState.final?.runtimeInfo?.modeId).toBe("read-only"); + + // Switch to another mode: "full-access" + messages.length = 0; + const position2 = messages.length; + + await ctx.client.setAgentMode(agent.id, "full-access"); + + // Wait for agent_update upsert + const stateAfterFullAccess = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for full-access mode change")); + }, 10000); + + const checkForModeChange = (): void => { + const queue = messages; + for (let i = position2; i < queue.length; i++) { + const msg = queue[i]; + if ( + msg.type === "agent_update" && + msg.payload.kind === "upsert" && + msg.payload.agent.id === agent.id && + msg.payload.agent.currentModeId === "full-access" + ) { + clearTimeout(timeout); + clearInterval(interval); + resolve(msg.payload.agent); + return; + } + } + }; + + const interval = setInterval(checkForModeChange, 50); + }); + + expect(stateAfterFullAccess.currentModeId).toBe("full-access"); + + // Cleanup + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); +}); + +describe("listAgents", () => { + test("returns current agents and reflects create/delete operations", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + + // Initially, there should be no agents (fresh session) + const initialAgents = await ctx.client.fetchAgents(); + expect(initialAgents.entries).toHaveLength(0); + + // Create first agent + const agent1 = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: cwd1, + title: "List Test Agent 1", + }); + + expect(agent1.id).toBeTruthy(); + expect(agent1.status).toBe("idle"); + + // fetchAgents should now return 1 agent + const afterFirst = await ctx.client.fetchAgents(); + expect(afterFirst.entries).toHaveLength(1); + expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id); + // Title may or may not be set depending on timing + expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1); + + // Create second agent + const agent2 = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: cwd2, + title: "List Test Agent 2", + }); + + expect(agent2.id).toBeTruthy(); + expect(agent2.status).toBe("idle"); + + // fetchAgents should now return 2 agents + const afterSecond = await ctx.client.fetchAgents(); + expect(afterSecond.entries).toHaveLength(2); + + // Verify both agents are present with correct IDs and states + const ids = afterSecond.entries.map((entry) => entry.agent.id); + expect(ids).toContain(agent1.id); + expect(ids).toContain(agent2.id); + + const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent; + const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent; + + // Title may or may not be set depending on timing + expect(agent1State?.cwd).toBe(cwd1); + expect(agent1State?.status).toBe("idle"); + + // Title may or may not be set depending on timing + expect(agent2State?.cwd).toBe(cwd2); + expect(agent2State?.status).toBe("idle"); + + // Delete first agent + await ctx.client.deleteAgent(agent1.id); + + // fetchAgents should now return only 1 agent + const afterDelete = await ctx.client.fetchAgents(); + expect(afterDelete.entries).toHaveLength(1); + expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id); + expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2); + + // Verify agent1 is no longer in the list + const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id); + expect(deletedAgent).toBeUndefined(); + + // Cleanup + await ctx.client.deleteAgent(agent2.id); + rmSync(cwd1, { recursive: true, force: true }); + rmSync(cwd2, { recursive: true, force: true }); + }, 60000); // 1 minute timeout +}); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index f4cb8645a5..e11d9e7f8a 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -60,83 +60,81 @@ async function waitForCheckoutDiffUpdate( }); } -describe("daemon E2E checkout diff subscriptions", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - }); - - afterEach(async () => { - await ctx.cleanup(); - }, 60000); - - test("pushes file-level checkout diff updates with deterministic path order", async () => { - const cwd = tmpCwd(); - - try { - initGitRepo(cwd); - commitFile(cwd, "base.txt", "base\n"); - - const subscriptionId = "checkout-diff-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId }, - ); - - expect(initial.error).toBeNull(); - expect(initial.files).toEqual([]); - - writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n"); - writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n"); - - const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => { - const paths = new Set(payload.files.map((file) => file.path)); - return paths.has("alpha.txt") && paths.has("zeta.txt"); - }); - - expect(update.error).toBeNull(); - expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]); - - ctx.client.unsubscribeCheckoutDiff(subscriptionId); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 60000); - - test("pushes updates when subscribed from a subdirectory and files change outside it", async () => { - const cwd = tmpCwd(); - - try { - initGitRepo(cwd); - commitFile(cwd, "base.txt", "base\n"); - - const nestedDir = path.join(cwd, "nested", "dir"); - mkdirSync(nestedDir, { recursive: true }); - - const subscriptionId = "checkout-diff-subdir-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - nestedDir, - { mode: "uncommitted" }, - { subscriptionId }, - ); - - expect(initial.error).toBeNull(); - expect(initial.files).toEqual([]); - - writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n"); - - const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => - payload.files.some((file) => file.path === "outside-subdir.txt"), - ); - - expect(update.error).toBeNull(); - expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true); - - ctx.client.unsubscribeCheckoutDiff(subscriptionId); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 60000); +beforeEach(async () => { + ctx = await createDaemonTestContext(); }); + +afterEach(async () => { + await ctx.cleanup(); +}, 60000); + +test("pushes file-level checkout diff updates with deterministic path order", async () => { + const cwd = tmpCwd(); + + try { + initGitRepo(cwd); + commitFile(cwd, "base.txt", "base\n"); + + const subscriptionId = "checkout-diff-e2e-subscription"; + const initial = await ctx.client.subscribeCheckoutDiff( + cwd, + { mode: "uncommitted" }, + { subscriptionId }, + ); + + expect(initial.error).toBeNull(); + expect(initial.files).toEqual([]); + + writeFileSync(path.join(cwd, "zeta.txt"), "zeta\n"); + writeFileSync(path.join(cwd, "alpha.txt"), "alpha\n"); + + const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => { + const paths = new Set(payload.files.map((file) => file.path)); + return paths.has("alpha.txt") && paths.has("zeta.txt"); + }); + + expect(update.error).toBeNull(); + expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]); + + ctx.client.unsubscribeCheckoutDiff(subscriptionId); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 60000); + +test("pushes updates when subscribed from a subdirectory and files change outside it", async () => { + const cwd = tmpCwd(); + + try { + initGitRepo(cwd); + commitFile(cwd, "base.txt", "base\n"); + + const nestedDir = path.join(cwd, "nested", "dir"); + mkdirSync(nestedDir, { recursive: true }); + + const subscriptionId = "checkout-diff-subdir-e2e-subscription"; + const initial = await ctx.client.subscribeCheckoutDiff( + nestedDir, + { mode: "uncommitted" }, + { subscriptionId }, + ); + + expect(initial.error).toBeNull(); + expect(initial.files).toEqual([]); + + writeFileSync(path.join(cwd, "outside-subdir.txt"), "changed outside\n"); + + const update = await waitForCheckoutDiffUpdate(ctx, subscriptionId, (payload) => + payload.files.some((file) => file.path === "outside-subdir.txt"), + ); + + expect(update.error).toBeNull(); + expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true); + + ctx.client.unsubscribeCheckoutDiff(subscriptionId); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 82e6b29d0f..50c045fa20 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -12,127 +12,125 @@ function tmpCwd(): string { const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; -describe("daemon E2E", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - }); - - afterEach(async () => { - await ctx.cleanup(); - }, 60000); - - describe("exploreFileSystem", () => { - test("lists directory contents", async () => { - const cwd = tmpCwd(); - - // Create test files and directories - writeFileSync(path.join(cwd, "test.txt"), "hello world\n"); - writeFileSync(path.join(cwd, "data.json"), '{"key": "value"}\n'); - mkdirSync(path.join(cwd, "subdir")); - writeFileSync(path.join(cwd, "subdir", "nested.txt"), "nested content\n"); - - // Create agent in the directory - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "File Explorer Test", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // List directory contents - const result = await ctx.client.exploreFileSystem(cwd, ".", "list"); - - // Verify listing returned without error - expect(result.error).toBeNull(); - expect(result.mode).toBe("list"); - expect(result.directory).toBeTruthy(); - expect(result.directory!.entries).toBeTruthy(); - - // Find expected entries - const entries = result.directory!.entries; - const testTxt = entries.find((e) => e.name === "test.txt"); - const dataJson = entries.find((e) => e.name === "data.json"); - const subdir = entries.find((e) => e.name === "subdir"); - - expect(testTxt).toBeTruthy(); - expect(testTxt!.kind).toBe("file"); - expect(testTxt!.size).toBeGreaterThan(0); - - expect(dataJson).toBeTruthy(); - expect(dataJson!.kind).toBe("file"); - - expect(subdir).toBeTruthy(); - expect(subdir!.kind).toBe("directory"); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - - test("reads file contents", async () => { - const cwd = tmpCwd(); - const testContent = "This is test file content.\nLine 2."; - const testFile = path.join(cwd, "readme.txt"); - writeFileSync(testFile, testContent); - - // Create agent in the directory - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "File Read Test", - }); - - expect(agent.id).toBeTruthy(); - - // Read file contents - const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file"); - - // Verify file read - expect(result.error).toBeNull(); - expect(result.mode).toBe("file"); - expect(result.file).toBeTruthy(); - // Server may return basename or full path - expect(result.file!.path).toContain("readme.txt"); - expect(result.file!.kind).toBe("text"); - expect(result.file!.content).toBe(testContent); - expect(result.file!.size).toBe(testContent.length); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - - test("returns error for non-existent path", async () => { - const cwd = tmpCwd(); - - // Create agent - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "File Explorer Error Test", - }); - - expect(agent.id).toBeTruthy(); - - // Try to list non-existent path - const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list"); - - // Should return error - expect(result.error).toBeTruthy(); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - }); +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); + +afterEach(async () => { + await ctx.cleanup(); +}, 60000); + +describe("exploreFileSystem", () => { + test("lists directory contents", async () => { + const cwd = tmpCwd(); + + // Create test files and directories + writeFileSync(path.join(cwd, "test.txt"), "hello world\n"); + writeFileSync(path.join(cwd, "data.json"), '{"key": "value"}\n'); + mkdirSync(path.join(cwd, "subdir")); + writeFileSync(path.join(cwd, "subdir", "nested.txt"), "nested content\n"); + + // Create agent in the directory + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "File Explorer Test", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // List directory contents + const result = await ctx.client.exploreFileSystem(cwd, ".", "list"); + + // Verify listing returned without error + expect(result.error).toBeNull(); + expect(result.mode).toBe("list"); + expect(result.directory).toBeTruthy(); + expect(result.directory!.entries).toBeTruthy(); + + // Find expected entries + const entries = result.directory!.entries; + const testTxt = entries.find((e) => e.name === "test.txt"); + const dataJson = entries.find((e) => e.name === "data.json"); + const subdir = entries.find((e) => e.name === "subdir"); + + expect(testTxt).toBeTruthy(); + expect(testTxt!.kind).toBe("file"); + expect(testTxt!.size).toBeGreaterThan(0); + + expect(dataJson).toBeTruthy(); + expect(dataJson!.kind).toBe("file"); + + expect(subdir).toBeTruthy(); + expect(subdir!.kind).toBe("directory"); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, 60000); // 1 minute timeout + + test("reads file contents", async () => { + const cwd = tmpCwd(); + const testContent = "This is test file content.\nLine 2."; + const testFile = path.join(cwd, "readme.txt"); + writeFileSync(testFile, testContent); + + // Create agent in the directory + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "File Read Test", + }); + + expect(agent.id).toBeTruthy(); + + // Read file contents + const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file"); + + // Verify file read + expect(result.error).toBeNull(); + expect(result.mode).toBe("file"); + expect(result.file).toBeTruthy(); + // Server may return basename or full path + expect(result.file!.path).toContain("readme.txt"); + expect(result.file!.kind).toBe("text"); + expect(result.file!.content).toBe(testContent); + expect(result.file!.size).toBe(testContent.length); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, 60000); // 1 minute timeout + + test("returns error for non-existent path", async () => { + const cwd = tmpCwd(); + + // Create agent + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "File Explorer Error Test", + }); + + expect(agent.id).toBeTruthy(); + + // Try to list non-existent path + const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list"); + + // Should return error + expect(result.error).toBeTruthy(); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, 60000); // 1 minute timeout }); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 162c048f0e..b4cd803004 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -150,648 +150,634 @@ function getWorktreeTerminalBootstrapEntries( const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; -describe("daemon E2E", () => { - let ctx: DaemonTestContext; - let collector: MessageCollector; +let ctx: DaemonTestContext; +let collector: MessageCollector; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - collector = createMessageCollector(ctx.client); +beforeEach(async () => { + ctx = await createDaemonTestContext(); + collector = createMessageCollector(ctx.client); +}); + +afterEach(async () => { + collector.unsubscribe(); + await ctx.cleanup(); +}, 60000); + +test("returns diff for modified file in git repo", async () => { + const cwd = tmpCwd(); + + // Initialize git repo + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); + + // Create and commit a file + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "original content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", }); - afterEach(async () => { - collector.unsubscribe(); - await ctx.cleanup(); - }, 60000); + // Modify the file (creates unstaged changes) + writeFileSync(testFile, "modified content\n"); - describe("getCheckoutDiff", () => { - test("returns diff for modified file in git repo", async () => { - const cwd = tmpCwd(); + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); + expect(result.error).toBeNull(); + expect(result.files.length).toBeGreaterThan(0); + const file = result.files.find((entry) => entry.path === "test.txt"); + expect(file).toBeTruthy(); + expect(file?.hunks.length).toBeGreaterThan(0); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout - // Initialize git repo - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); +test("returns empty diff when no changes", async () => { + const cwd = tmpCwd(); - // Create and commit a file - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "original content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); + // Initialize git repo with clean state + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - // Modify the file (creates unstaged changes) - writeFileSync(testFile, "modified content\n"); - - const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - expect(result.error).toBeNull(); - expect(result.files.length).toBeGreaterThan(0); - const file = result.files.find((entry) => entry.path === "test.txt"); - expect(file).toBeTruthy(); - expect(file?.hunks.length).toBeGreaterThan(0); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - - test("returns empty diff when no changes", async () => { - const cwd = tmpCwd(); - - // Initialize git repo with clean state - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - - // Create and commit a file - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); - - const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - - expect(result.error).toBeNull(); - expect(result.files).toEqual([]); - - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - - test("returns error for non-git directory", async () => { - const cwd = tmpCwd(); - // Don't initialize git - just a regular directory - - const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - - expect(result.files).toEqual([]); - expect(result.error).toBeTruthy(); - expect(result.error?.code).toBe("NOT_GIT_REPO"); - - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout + // Create and commit a file + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", }); - describe("getCheckoutStatus", () => { - test("returns repo info for git repo with branch and dirty state", async () => { - const cwd = tmpCwd(); + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - // Initialize git repo - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); + expect(result.error).toBeNull(); + expect(result.files).toEqual([]); - // Create and commit a file - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "original content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout - // Modify the file (makes repo dirty) - writeFileSync(testFile, "modified content\n"); +test("returns error for non-git directory", async () => { + const cwd = tmpCwd(); + // Don't initialize git - just a regular directory - // Create agent in the git repo - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Repo Info Test", - }); + const result = await ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" }); - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); + expect(result.files).toEqual([]); + expect(result.error).toBeTruthy(); + expect(result.error?.code).toBe("NOT_GIT_REPO"); - // Get checkout status - const result = await ctx.client.getCheckoutStatus(cwd); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout - // Verify repo info returned without error - expect(result.error).toBeNull(); - expect(result.isGit).toBe(true); - // macOS symlinks /var to /private/var, so we check containment - expect(result.repoRoot).toContain("daemon-e2e-"); - expect(result.currentBranch).toBeTruthy(); - expect(result.isDirty).toBe(true); +test("returns repo info for git repo with branch and dirty state", async () => { + const cwd = tmpCwd(); - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout + // Initialize git repo + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - test("returns clean state when no uncommitted changes", async () => { - const cwd = tmpCwd(); - - // Initialize git repo with clean state - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - - // Create and commit a file (no uncommitted changes) - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); - - // Create agent in the git repo - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Repo Info Clean Test", - }); - - expect(agent.id).toBeTruthy(); - - // Get checkout status - const result = await ctx.client.getCheckoutStatus(cwd); - - expect(result.error).toBeNull(); - expect(result.isGit).toBe(true); - expect(result.isDirty).toBe(false); - expect(result.currentBranch).toBeTruthy(); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout - - test("returns isGit false for non-git directory", async () => { - const cwd = tmpCwd(); - // Don't initialize git - just a regular directory - - // Create agent in a non-git directory - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Git Repo Info Non-Git Test", - }); - - expect(agent.id).toBeTruthy(); - - // Get checkout status - should return isGit: false - const result = await ctx.client.getCheckoutStatus(cwd); - - expect(result.isGit).toBe(false); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); // 1 minute timeout + // Create and commit a file + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "original content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", }); - describe("worktree setup", () => { - test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => { - const repoRoot = tmpCwd(); + // Modify the file (makes repo dirty) + writeFileSync(testFile, "modified content\n"); - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + // Create agent in the git repo + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Git Repo Info Test", + }); - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); - const setupCommand = - 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ worktree: { setup: [setupCommand] } }), - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add paseo.json'", { - cwd: repoRoot, - stdio: "pipe", - }); + // Get checkout status + const result = await ctx.client.getCheckoutStatus(cwd); - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: repoRoot, - title: "Async Worktree Setup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-test", - worktreeSlug: "async-setup-test", - }, - }), - timeoutMs: 2500, - label: "createAgent should not block on setup", - }); + // Verify repo info returned without error + expect(result.error).toBeNull(); + expect(result.isGit).toBe(true); + // macOS symlinks /var to /private/var, so we check containment + expect(result.repoRoot).toContain("daemon-e2e-"); + expect(result.currentBranch).toBeTruthy(); + expect(result.isDirty).toBe(true); - expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); - expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout - writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); +test("returns clean state when no uncommitted changes", async () => { + const cwd = tmpCwd(); - const completed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); + // Initialize git repo with clean state + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - expect(completed.callId).toBeTruthy(); - expect(completed.detail.type).toBe("worktree_setup"); - if (completed.detail.type === "worktree_setup") { - expect(completed.detail.commands.length).toBeGreaterThan(0); - expect(completed.detail.log.length).toBeGreaterThan(0); - } - expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); + // Create and commit a file (no uncommitted changes) + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", + }); - await ctx.client.deleteAgent(agent.id); - rmSync(repoRoot, { recursive: true, force: true }); - }, 60000); + // Create agent in the git repo + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Git Repo Info Clean Test", + }); - test("bootstraps configured worktree terminals after setup succeeds", async () => { - await withShell("/bin/sh", async () => { - const repoRoot = tmpCwd(); + expect(agent.id).toBeTruthy(); - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + // Get checkout status + const result = await ctx.client.getCheckoutStatus(cwd); - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + expect(result.error).toBeNull(); + expect(result.isGit).toBe(true); + expect(result.isDirty).toBe(false); + expect(result.currentBranch).toBeTruthy(); - const setupCommand = - 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"'; - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [setupCommand], - terminals: [ - { - name: "Dev Server", - command: "tail -f /dev/null", - }, - { - command: "tail -f /dev/null", - }, - ], + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout + +test("returns isGit false for non-git directory", async () => { + const cwd = tmpCwd(); + // Don't initialize git - just a regular directory + + // Create agent in a non-git directory + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Git Repo Info Non-Git Test", + }); + + expect(agent.id).toBeTruthy(); + + // Get checkout status - should return isGit: false + const result = await ctx.client.getCheckoutStatus(cwd); + + expect(result.isGit).toBe(false); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); // 1 minute timeout + +test("runs paseo.json setup asynchronously and reports status via timeline tool_call", async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ worktree: { setup: [setupCommand] } }), + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add paseo.json'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Async Worktree Setup Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "async-setup-test", + worktreeSlug: "async-setup-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + + writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); + + const completed = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "completed", + 20000, + ); + + expect(completed.callId).toBeTruthy(); + expect(completed.detail.type).toBe("worktree_setup"); + if (completed.detail.type === "worktree_setup") { + expect(completed.detail.commands.length).toBeGreaterThan(0); + expect(completed.detail.log.length).toBeGreaterThan(0); + } + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(true); + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); +}, 60000); + +test("bootstraps configured worktree terminals after setup succeeds", async () => { + await withShell("/bin/sh", async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'while [ ! -f "$PASEO_WORKTREE_PATH/allow-setup" ]; do sleep 0.05; done; echo "done" > "$PASEO_WORKTREE_PATH/setup-done.txt"; echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/setup-port.txt"'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Dev Server", + command: "tail -f /dev/null", }, - }), - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { - cwd: repoRoot, - stdio: "pipe", - }); - - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: repoRoot, - title: "Async Worktree Setup + Terminals Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-terminals-test", - worktreeSlug: "async-setup-terminals-test", + { + command: "tail -f /dev/null", }, - }), - timeoutMs: 2500, - label: "createAgent should not block on setup", - }); + ], + }, + }), + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add setup and terminals'", { + cwd: repoRoot, + stdio: "pipe", + }); - expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); - expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); - expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); - - writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); - - await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "completed", - 20000, - ); - const terminalsBootstrapToolCall = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", - 30000, - ); - const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries( - terminalsBootstrapToolCall, - ); - expect(bootstrappedTerminals).toBeTruthy(); - expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); - const failedBootstraps = - bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; - expect(failedBootstraps).toEqual([]); - - const list = await ctx.client.listTerminals(agent.cwd); - expect(list.error).toBeUndefined(); - expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); - expect(list.terminals.length).toBeGreaterThanOrEqual(2); - await waitForPathExists({ - targetPath: path.join(agent.cwd, "setup-port.txt"), - timeoutMs: 30000, - label: "setup runtime port marker", - }); - - const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim(); - expect(setupPort.length).toBeGreaterThan(0); - - const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check"); - expect(createdTerminal.error).toBeNull(); - expect(createdTerminal.terminal).toBeTruthy(); - const manualTerminalId = createdTerminal.terminal?.id; - expect(manualTerminalId).toBeTruthy(); - if (!manualTerminalId) { - throw new Error("Expected manual terminal id"); - } - ctx.client.sendTerminalInput(manualTerminalId, { - type: "input", - data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r', - }); - await waitForPathExists({ - targetPath: path.join(agent.cwd, "manual-terminal-port.txt"), - timeoutMs: 30000, - label: "manual terminal runtime port marker", - }); - const manualTerminalPort = readFileSync( - path.join(agent.cwd, "manual-terminal-port.txt"), - "utf8", - ).trim(); - expect(manualTerminalPort).toBe(setupPort); - - await ctx.client.deleteAgent(agent.id); - rmSync(repoRoot, { recursive: true, force: true }); - }); - }, 60000); - - test("reports failures via timeline tool_call without deleting the created worktree", async () => { - const repoRoot = tmpCwd(); - - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); - - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); - - const setupCommand = - 'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7'; - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [setupCommand], - terminals: [ - { - name: "Should Not Start", - command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null', - }, - ], - }, - }), - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", { - cwd: repoRoot, - stdio: "pipe", - }); - - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: repoRoot, - title: "Async Worktree Setup Failure Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "async-setup-failure-test", - worktreeSlug: "async-setup-failure-test", - }, - }), - timeoutMs: 2500, - label: "createAgent should not block on failing setup", - }); - - expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); - expect(existsSync(agent.cwd)).toBe(true); - - const started = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => item.name === "paseo_worktree_setup" && item.status === "running", - 10000, - ); - - const failed = await waitForTimelineToolCall( - collector.messages, - agent.id, - (item) => - item.name === "paseo_worktree_setup" && - item.callId === started.callId && - item.status === "failed", - 20000, - ); - - expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); - expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); - - expect(failed.detail.type).toBe("worktree_setup"); - if (failed.detail.type === "worktree_setup") { - expect(Array.isArray(failed.detail.commands)).toBe(true); - expect(failed.detail.commands[0]?.exitCode).toBe(7); - expect(failed.detail.log).toContain("Exit 7"); - } - - await ctx.client.deleteAgent(agent.id); - rmSync(repoRoot, { recursive: true, force: true }); - }, 60000); - }); - - describe("createAgent with worktree", () => { - test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", async () => { - const cwd = tmpCwd(); - const projectHash = await deriveWorktreeProjectHash(cwd); - - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); - - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "content\n"); - execSync("git add test.txt", { cwd, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { - cwd, - stdio: "pipe", - }); - - const agent = await ctx.client.createAgent({ + const agent = await withTimeout({ + promise: ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Worktree Agent Test", + cwd: repoRoot, + title: "Async Worktree Setup + Terminals Test", git: { createWorktree: true, createNewBranch: true, - newBranchName: "worktree-test", - worktreeSlug: "worktree-test", baseBranch: "main", + newBranchName: "async-setup-terminals-test", + worktreeSlug: "async-setup-terminals-test", }, - }); + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - expect(realpathSync(agent.cwd)).toBe( - realpathSync(path.join(ctx.daemon.paseoHome, "worktrees", projectHash, "worktree-test")), - ); - expect(existsSync(agent.cwd)).toBe(true); + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(path.join(agent.cwd, "setup-done.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "dev-terminal.txt"))).toBe(false); + expect(existsSync(path.join(agent.cwd, "lint-terminal.txt"))).toBe(false); - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + writeFileSync(path.join(agent.cwd, "allow-setup"), "ok\n"); + + await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "completed", + 20000, + ); + const terminalsBootstrapToolCall = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_terminals" && item.status === "completed", + 30000, + ); + const bootstrappedTerminals = getWorktreeTerminalBootstrapEntries(terminalsBootstrapToolCall); + expect(bootstrappedTerminals).toBeTruthy(); + expect(bootstrappedTerminals?.length ?? 0).toBeGreaterThanOrEqual(2); + const failedBootstraps = + bootstrappedTerminals?.filter((terminal) => terminal.status === "failed") ?? []; + expect(failedBootstraps).toEqual([]); + + const list = await ctx.client.listTerminals(agent.cwd); + expect(list.error).toBeUndefined(); + expect(list.terminals.some((terminal) => terminal.name === "Dev Server")).toBe(true); + expect(list.terminals.length).toBeGreaterThanOrEqual(2); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "setup-port.txt"), + timeoutMs: 30000, + label: "setup runtime port marker", + }); + + const setupPort = readFileSync(path.join(agent.cwd, "setup-port.txt"), "utf8").trim(); + expect(setupPort.length).toBeGreaterThan(0); + + const createdTerminal = await ctx.client.createTerminal(agent.cwd, "Manual Port Check"); + expect(createdTerminal.error).toBeNull(); + expect(createdTerminal.terminal).toBeTruthy(); + const manualTerminalId = createdTerminal.terminal?.id; + expect(manualTerminalId).toBeTruthy(); + if (!manualTerminalId) { + throw new Error("Expected manual terminal id"); + } + ctx.client.sendTerminalInput(manualTerminalId, { + type: "input", + data: 'echo "$PASEO_WORKTREE_PORT" > "$PASEO_WORKTREE_PATH/manual-terminal-port.txt"\r', + }); + await waitForPathExists({ + targetPath: path.join(agent.cwd, "manual-terminal-port.txt"), + timeoutMs: 30000, + label: "manual terminal runtime port marker", + }); + const manualTerminalPort = readFileSync( + path.join(agent.cwd, "manual-terminal-port.txt"), + "utf8", + ).trim(); + expect(manualTerminalPort).toBe(setupPort); + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); + }); +}, 60000); + +test("reports failures via timeline tool_call without deleting the created worktree", async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const setupCommand = + 'echo "started" > "$PASEO_WORKTREE_PATH/setup-start.txt"; sleep 0.1; echo "boom" 1>&2; exit 7'; + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [setupCommand], + terminals: [ + { + name: "Should Not Start", + command: 'echo "should-not-run" > should-not-run.txt; tail -f /dev/null', + }, + ], + }, + }), + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add failing setup'", { + cwd: repoRoot, + stdio: "pipe", }); - describe("archivePaseoWorktree", () => { - test("archives worktree by running teardown commands and shutting down worktree terminals", async () => { - const repoRoot = tmpCwd(); - - const { execSync } = await import("child_process"); - execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); - - writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { - cwd: repoRoot, - stdio: "pipe", - }); - execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); - - const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt"); - writeFileSync( - path.join(repoRoot, "paseo.json"), - JSON.stringify({ - worktree: { - terminals: [ - { - name: "Dev Server", - command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', - }, - ], - teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`], - }, - }), - ); - execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", { - cwd: repoRoot, - stdio: "pipe", - }); - - const agent = await withTimeout({ - promise: ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd: repoRoot, - title: "Worktree Archive Cleanup Test", - git: { - createWorktree: true, - createNewBranch: true, - baseBranch: "main", - newBranchName: "archive-cleanup-test", - worktreeSlug: "archive-cleanup-test", - }, - }), - timeoutMs: 2500, - label: "createAgent should not block on setup", - }); - - await waitForCondition({ - timeoutMs: 30000, - label: `worktree terminal bootstrap for ${agent.cwd}`, - predicate: async () => { - const directories = ctx.daemon.daemon.terminalManager.listDirectories(); - if (!directories.includes(agent.cwd)) { - return false; - } - const terminals = await ctx.client.listTerminals(agent.cwd); - return terminals.terminals.some((terminal) => terminal.name === "Dev Server"); - }, - }); - - const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); - expect(beforeArchiveDirectories).toContain(agent.cwd); - - const archive = await ctx.client.archivePaseoWorktree({ - worktreePath: agent.cwd, - }); - expect(archive.error).toBeNull(); - expect(archive.success).toBe(true); - expect(archive.removedAgents).toContain(agent.id); - - expect(existsSync(agent.cwd)).toBe(false); - expect(existsSync(teardownMarkerPath)).toBe(true); - expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); - - const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); - expect(afterArchiveDirectories).not.toContain(agent.cwd); - - rmSync(repoRoot, { recursive: true, force: true }); - }, 60000); + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Async Worktree Setup Failure Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "async-setup-failure-test", + worktreeSlug: "async-setup-failure-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on failing setup", }); -}); + + expect(agent.cwd).toContain(path.join(".paseo", "worktrees")); + expect(existsSync(agent.cwd)).toBe(true); + + const started = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => item.name === "paseo_worktree_setup" && item.status === "running", + 10000, + ); + + const failed = await waitForTimelineToolCall( + collector.messages, + agent.id, + (item) => + item.name === "paseo_worktree_setup" && + item.callId === started.callId && + item.status === "failed", + 20000, + ); + + expect(existsSync(path.join(agent.cwd, "setup-start.txt"))).toBe(true); + expect(existsSync(path.join(agent.cwd, "should-not-run.txt"))).toBe(false); + + expect(failed.detail.type).toBe("worktree_setup"); + if (failed.detail.type === "worktree_setup") { + expect(Array.isArray(failed.detail.commands)).toBe(true); + expect(failed.detail.commands[0]?.exitCode).toBe(7); + expect(failed.detail.log).toContain("Exit 7"); + } + + await ctx.client.deleteAgent(agent.id); + rmSync(repoRoot, { recursive: true, force: true }); +}, 60000); + +test("creates agent in ~/.paseo/worktrees/{hash} when worktree is requested", async () => { + const cwd = tmpCwd(); + const projectHash = await deriveWorktreeProjectHash(cwd); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd, stdio: "pipe" }); + + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "content\n"); + execSync("git add test.txt", { cwd, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", { + cwd, + stdio: "pipe", + }); + + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Worktree Agent Test", + git: { + createWorktree: true, + createNewBranch: true, + newBranchName: "worktree-test", + worktreeSlug: "worktree-test", + baseBranch: "main", + }, + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + expect(realpathSync(agent.cwd)).toBe( + realpathSync(path.join(ctx.daemon.paseoHome, "worktrees", projectHash, "worktree-test")), + ); + expect(existsSync(agent.cwd)).toBe(true); + + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); + +test("archives worktree by running teardown commands and shutting down worktree terminals", async () => { + const repoRoot = tmpCwd(); + + const { execSync } = await import("child_process"); + execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" }); + + writeFileSync(path.join(repoRoot, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { + cwd: repoRoot, + stdio: "pipe", + }); + execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" }); + + const teardownMarkerPath = path.join(repoRoot, "teardown-marker.txt"); + writeFileSync( + path.join(repoRoot, "paseo.json"), + JSON.stringify({ + worktree: { + terminals: [ + { + name: "Dev Server", + command: 'echo "dev-server" > dev-terminal.txt; tail -f /dev/null', + }, + ], + teardown: [`echo "$PASEO_WORKTREE_PATH" > "${teardownMarkerPath}"`], + }, + }), + ); + execSync("git add paseo.json", { cwd: repoRoot, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'add worktree terminal + teardown'", { + cwd: repoRoot, + stdio: "pipe", + }); + + const agent = await withTimeout({ + promise: ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd: repoRoot, + title: "Worktree Archive Cleanup Test", + git: { + createWorktree: true, + createNewBranch: true, + baseBranch: "main", + newBranchName: "archive-cleanup-test", + worktreeSlug: "archive-cleanup-test", + }, + }), + timeoutMs: 2500, + label: "createAgent should not block on setup", + }); + + await waitForCondition({ + timeoutMs: 30000, + label: `worktree terminal bootstrap for ${agent.cwd}`, + predicate: async () => { + const directories = ctx.daemon.daemon.terminalManager.listDirectories(); + if (!directories.includes(agent.cwd)) { + return false; + } + const terminals = await ctx.client.listTerminals(agent.cwd); + return terminals.terminals.some((terminal) => terminal.name === "Dev Server"); + }, + }); + + const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(beforeArchiveDirectories).toContain(agent.cwd); + + const archive = await ctx.client.archivePaseoWorktree({ + worktreePath: agent.cwd, + }); + expect(archive.error).toBeNull(); + expect(archive.success).toBe(true); + expect(archive.removedAgents).toContain(agent.id); + + expect(existsSync(agent.cwd)).toBe(false); + expect(existsSync(teardownMarkerPath)).toBe(true); + expect(readFileSync(teardownMarkerPath, "utf8").trim()).toBe(agent.cwd); + + const afterArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories(); + expect(afterArchiveDirectories).not.toContain(agent.cwd); + + rmSync(repoRoot, { recursive: true, force: true }); +}, 60000); diff --git a/packages/server/src/server/daemon-e2e/images.e2e.test.ts b/packages/server/src/server/daemon-e2e/images.e2e.test.ts index 44ac739ce1..a83e5eba52 100644 --- a/packages/server/src/server/daemon-e2e/images.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/images.e2e.test.ts @@ -11,138 +11,136 @@ function tmpCwd(): string { const CODEX_TEST_MODEL = "gpt-5.4-mini"; -describe("daemon E2E", () => { - let ctx: DaemonTestContext; - let collector: MessageCollector; +let ctx: DaemonTestContext; +let collector: MessageCollector; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - collector = createMessageCollector(ctx.client); - }); - - afterEach(async () => { - collector.unsubscribe(); - await ctx.cleanup(); - }, 60000); - - describe("sendImages", () => { - // Minimal 1x1 red PNG image encoded in base64 - // This is a valid PNG that can be decoded by image processing libraries - const MINIMAL_PNG_BASE64 = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; - - test("sends message with image attachment to Claude agent", async () => { - const cwd = tmpCwd(); - - // Create Claude agent - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Image Test Agent", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.provider).toBe("claude"); - - // Send message with image attachment - await ctx.client.sendMessage( - agent.id, - "I'm sending you an image. Describe what information you received about the image attachment. Reply with a single short sentence.", - { - images: [ - { - data: MINIMAL_PNG_BASE64, - mimeType: "image/png", - }, - ], - }, - ); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - - expect(finalState.status).toBe("idle"); - expect(finalState.final?.lastError).toBeUndefined(); - - // Verify stream events show the agent processed the message - const queue = collector.messages; - const streamEvents = queue.filter( - (m) => m.type === "agent_stream" && m.payload.agentId === agent.id, - ); - - // Should have received stream events - expect(streamEvents.length).toBeGreaterThan(0); - - // Verify turn completed successfully - const hasTurnCompleted = streamEvents.some( - (m) => m.type === "agent_stream" && m.payload.event.type === "turn_completed", - ); - expect(hasTurnCompleted).toBe(true); - - // Verify there was an assistant message in the timeline - const hasAssistantMessage = streamEvents.some((m) => { - if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") { - return false; - } - const item = m.payload.event.item; - return item.type === "assistant_message" && item.text.length > 0; - }); - expect(hasAssistantMessage).toBe(true); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); // 3 minute timeout for Claude API call - - test("sends message with multiple image attachments", async () => { - const cwd = tmpCwd(); - - // Create Claude agent - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Multi-Image Test Agent", - }); - - expect(agent.id).toBeTruthy(); - - // Send message with two image attachments - await ctx.client.sendMessage( - agent.id, - "I'm sending you two images. How many image attachments are mentioned in the context? Reply with just a number.", - { - images: [ - { - data: MINIMAL_PNG_BASE64, - mimeType: "image/png", - }, - { - data: MINIMAL_PNG_BASE64, - mimeType: "image/jpeg", - }, - ], - }, - ); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - - expect(finalState.status).toBe("idle"); - expect(finalState.final?.lastError).toBeUndefined(); - - // Verify turn completed - const queue = collector.messages; - const hasTurnCompleted = queue.some( - (m) => - m.type === "agent_stream" && - m.payload.agentId === agent.id && - m.payload.event.type === "turn_completed", - ); - expect(hasTurnCompleted).toBe(true); - - // Cleanup - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); // 3 minute timeout for Claude API call - }); +beforeEach(async () => { + ctx = await createDaemonTestContext(); + collector = createMessageCollector(ctx.client); +}); + +afterEach(async () => { + collector.unsubscribe(); + await ctx.cleanup(); +}, 60000); + +describe("sendImages", () => { + // Minimal 1x1 red PNG image encoded in base64 + // This is a valid PNG that can be decoded by image processing libraries + const MINIMAL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + test("sends message with image attachment to Claude agent", async () => { + const cwd = tmpCwd(); + + // Create Claude agent + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Image Test Agent", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.provider).toBe("claude"); + + // Send message with image attachment + await ctx.client.sendMessage( + agent.id, + "I'm sending you an image. Describe what information you received about the image attachment. Reply with a single short sentence.", + { + images: [ + { + data: MINIMAL_PNG_BASE64, + mimeType: "image/png", + }, + ], + }, + ); + + // Wait for agent to complete + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + + expect(finalState.status).toBe("idle"); + expect(finalState.final?.lastError).toBeUndefined(); + + // Verify stream events show the agent processed the message + const queue = collector.messages; + const streamEvents = queue.filter( + (m) => m.type === "agent_stream" && m.payload.agentId === agent.id, + ); + + // Should have received stream events + expect(streamEvents.length).toBeGreaterThan(0); + + // Verify turn completed successfully + const hasTurnCompleted = streamEvents.some( + (m) => m.type === "agent_stream" && m.payload.event.type === "turn_completed", + ); + expect(hasTurnCompleted).toBe(true); + + // Verify there was an assistant message in the timeline + const hasAssistantMessage = streamEvents.some((m) => { + if (m.type !== "agent_stream" || m.payload.event.type !== "timeline") { + return false; + } + const item = m.payload.event.item; + return item.type === "assistant_message" && item.text.length > 0; + }); + expect(hasAssistantMessage).toBe(true); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); // 3 minute timeout for Claude API call + + test("sends message with multiple image attachments", async () => { + const cwd = tmpCwd(); + + // Create Claude agent + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Multi-Image Test Agent", + }); + + expect(agent.id).toBeTruthy(); + + // Send message with two image attachments + await ctx.client.sendMessage( + agent.id, + "I'm sending you two images. How many image attachments are mentioned in the context? Reply with just a number.", + { + images: [ + { + data: MINIMAL_PNG_BASE64, + mimeType: "image/png", + }, + { + data: MINIMAL_PNG_BASE64, + mimeType: "image/jpeg", + }, + ], + }, + ); + + // Wait for agent to complete + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + + expect(finalState.status).toBe("idle"); + expect(finalState.final?.lastError).toBeUndefined(); + + // Verify turn completed + const queue = collector.messages; + const hasTurnCompleted = queue.some( + (m) => + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "turn_completed", + ); + expect(hasTurnCompleted).toBe(true); + + // Cleanup + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); // 3 minute timeout for Claude API call }); diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts index 0ecc3cb437..c8fa1e986b 100644 --- a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -59,192 +59,59 @@ function isBinaryInstalled(binary: string): boolean { const hasCodex = isBinaryInstalled("codex"); const hasOpenCode = isBinaryInstalled("opencode"); -describe("daemon E2E", () => { - let ctx: DaemonTestContext; - let messages: SessionOutboundMessage[] = []; - let unsubscribe: (() => void) | null = null; +let ctx: DaemonTestContext; +let messages: SessionOutboundMessage[] = []; +let unsubscribe: (() => void) | null = null; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - messages = []; - unsubscribe = ctx.client.subscribeRawMessages((message) => { - messages.push(message); - }); - await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); +beforeEach(async () => { + ctx = await createDaemonTestContext(); + messages = []; + unsubscribe = ctx.client.subscribeRawMessages((message) => { + messages.push(message); }); + await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); +}); - afterEach(async () => { - unsubscribe?.(); - await ctx.cleanup(); - }, 60000); +afterEach(async () => { + unsubscribe?.(); + await ctx.cleanup(); +}, 60000); - describe.each(["claude", "codex", "opencode"] as const)( - "live model switching (%s)", - (provider) => { - const shouldRun = - provider === "claude" || - (provider === "codex" && hasCodex) || - (provider === "opencode" && hasOpenCode); +describe.each(["claude", "codex", "opencode"] as const)("live model switching (%s)", (provider) => { + const shouldRun = + provider === "claude" || + (provider === "codex" && hasCodex) || + (provider === "opencode" && hasOpenCode); - test.runIf(shouldRun)( - "updates agent model without restarting", - async () => { - const cwd = tmpCwd(); - try { - const modelList = await ctx.client.listProviderModels(provider); - if (!modelList.models || modelList.models.length === 0) { - throw new Error(`No models returned for provider ${provider}`); - } - const [modelA, modelB] = pickTwoDistinctModels(modelList.models); - - const agent = await ctx.client.createAgent({ - provider, - cwd, - title: `Model Switch (${provider})`, - model: modelA, - }); - - const startIndex = messages.length; - await ctx.client.setAgentModel(agent.id, modelB); - - const updated = await waitForAgentUpdate( - messages, - startIndex, - (a) => a.id === agent.id && a.model === modelB, - { timeoutMs: 20000 }, - ); - - expect(updated.model).toBe(modelB); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - 180000, - ); - }, - ); - - test("live thinking switching works for Claude (off -> on)", async () => { - const cwd = tmpCwd(); - try { - const modelList = await ctx.client.listProviderModels("claude"); - if (!modelList.models || modelList.models.length === 0) { - throw new Error("No Claude models returned"); - } - const modelId = modelList.models[0]!.id; - - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Claude Thinking Switch", - model: modelId, - }); - - const startIndex = messages.length; - await ctx.client.setAgentThinkingOption(agent.id, "on"); - - const updated = await waitForAgentUpdate( - messages, - startIndex, - (a) => a.id === agent.id && a.thinkingOptionId === "on", - { timeoutMs: 20000 }, - ); - - expect(updated.thinkingOptionId).toBe("on"); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 120000); - - test.runIf(hasCodex)( - "live thinking switching works for Codex (default -> non-default)", + test.runIf(shouldRun)( + "updates agent model without restarting", async () => { const cwd = tmpCwd(); try { - const modelList = await ctx.client.listProviderModels("codex"); + const modelList = await ctx.client.listProviderModels(provider); if (!modelList.models || modelList.models.length === 0) { - throw new Error("No Codex models returned"); - } - - const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1); - if (!modelWithOptions) { - throw new Error("No Codex model with thinkingOptions returned"); - } - const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default"; - const nonDefault = - modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? - modelWithOptions.thinkingOptions?.[0]?.id ?? - null; - if (!nonDefault) { - throw new Error("No non-default Codex thinking option found"); + throw new Error(`No models returned for provider ${provider}`); } + const [modelA, modelB] = pickTwoDistinctModels(modelList.models); const agent = await ctx.client.createAgent({ - provider: "codex", + provider, cwd, - title: "Codex Thinking Switch", - model: modelWithOptions.id, + title: `Model Switch (${provider})`, + model: modelA, }); const startIndex = messages.length; - await ctx.client.setAgentThinkingOption(agent.id, nonDefault); + await ctx.client.setAgentModel(agent.id, modelB); const updated = await waitForAgentUpdate( messages, startIndex, - (a) => a.id === agent.id && a.thinkingOptionId === nonDefault, + (a) => a.id === agent.id && a.model === modelB, { timeoutMs: 20000 }, ); - expect(updated.thinkingOptionId).toBe(nonDefault); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - 120000, - ); - - test.runIf(hasOpenCode)( - "live thinking switching works for OpenCode", - async () => { - const cwd = tmpCwd(); - try { - const modelList = await ctx.client.listProviderModels("opencode"); - if (!modelList.models || modelList.models.length === 0) { - throw new Error("No OpenCode models returned"); - } - - const modelWithThinkingOptions = modelList.models.find( - (m) => (m.thinkingOptions?.length ?? 0) > 1, - ); - if (!modelWithThinkingOptions) { - throw new Error("No OpenCode model with thinkingOptions returned"); - } - - const agent = await ctx.client.createAgent({ - provider: "opencode", - cwd, - title: "OpenCode Preferences Switch", - model: modelWithThinkingOptions.id, - }); - - const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default"; - const thinkingId = - modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? - modelWithThinkingOptions.thinkingOptions?.[0]?.id ?? - null; - if (!thinkingId) { - throw new Error("No non-default OpenCode thinking option found"); - } - const startIndex = messages.length; - await ctx.client.setAgentThinkingOption(agent.id, thinkingId); - const updatedThinking = await waitForAgentUpdate( - messages, - startIndex, - (a) => a.id === agent.id && a.thinkingOptionId === thinkingId, - { timeoutMs: 20000 }, - ); - expect(updatedThinking.thinkingOptionId).toBe(thinkingId); + expect(updated.model).toBe(modelB); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -252,3 +119,131 @@ describe("daemon E2E", () => { 180000, ); }); + +test("live thinking switching works for Claude (off -> on)", async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("claude"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No Claude models returned"); + } + const modelId = modelList.models[0]!.id; + + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Claude Thinking Switch", + model: modelId, + }); + + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, "on"); + + const updated = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === "on", + { timeoutMs: 20000 }, + ); + + expect(updated.thinkingOptionId).toBe("on"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 120000); + +test.runIf(hasCodex)( + "live thinking switching works for Codex (default -> non-default)", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("codex"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No Codex models returned"); + } + + const modelWithOptions = modelList.models.find((m) => (m.thinkingOptions?.length ?? 0) > 1); + if (!modelWithOptions) { + throw new Error("No Codex model with thinkingOptions returned"); + } + const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default"; + const nonDefault = + modelWithOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? + modelWithOptions.thinkingOptions?.[0]?.id ?? + null; + if (!nonDefault) { + throw new Error("No non-default Codex thinking option found"); + } + + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Codex Thinking Switch", + model: modelWithOptions.id, + }); + + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, nonDefault); + + const updated = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === nonDefault, + { timeoutMs: 20000 }, + ); + + expect(updated.thinkingOptionId).toBe(nonDefault); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000, +); + +test.runIf(hasOpenCode)( + "live thinking switching works for OpenCode", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("opencode"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No OpenCode models returned"); + } + + const modelWithThinkingOptions = modelList.models.find( + (m) => (m.thinkingOptions?.length ?? 0) > 1, + ); + if (!modelWithThinkingOptions) { + throw new Error("No OpenCode model with thinkingOptions returned"); + } + + const agent = await ctx.client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode Preferences Switch", + model: modelWithThinkingOptions.id, + }); + + const defaultThinkingId = modelWithThinkingOptions.defaultThinkingOptionId ?? "default"; + const thinkingId = + modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== defaultThinkingId)?.id ?? + modelWithThinkingOptions.thinkingOptions?.[0]?.id ?? + null; + if (!thinkingId) { + throw new Error("No non-default OpenCode thinking option found"); + } + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, thinkingId); + const updatedThinking = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === thinkingId, + { timeoutMs: 20000 }, + ); + expect(updatedThinking.thinkingOptionId).toBe(thinkingId); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 180000, +); diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index b59dd5c942..a3a84a17ea 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -13,353 +13,351 @@ function tmpCwd(): string { const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; -describe("daemon E2E", () => { - let ctx: DaemonTestContext; - let messages: SessionOutboundMessage[] = []; - let unsubscribe: (() => void) | null = null; - - beforeEach(async () => { - ctx = await createDaemonTestContext(); - messages = []; - unsubscribe = ctx.client.subscribeRawMessages((message) => { - messages.push(message); - }); - }); - - afterEach(async () => { - unsubscribe?.(); - await ctx.cleanup(); - }, 60000); - - describe("permission flow: Codex", () => { - test("approves permission and executes command", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "permission.txt"); - - // Create Codex agent with on-request approval policy - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Permission Test", - modeId: "read-only", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.status).toBe("idle"); - - // Clear message queue before sending prompt - messages.length = 0; - - // Send a prompt that requires permission - const prompt = [ - 'Request approval to run the command `printf "ok" > permission.txt`.', - "After approval, run it and reply DONE.", - ].join(" "); - - await ctx.client.sendMessage(agent.id, prompt); - - // Wait for permission request - const permissionState = await ctx.client.waitForFinish(agent.id, 60000); - expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); - const permission = permissionState.final!.pendingPermissions[0]; - expect(permission).not.toBeNull(); - expect(permission.id).toBeTruthy(); - expect(permission.kind).toBe("tool"); - - // Approve the permission - await ctx.client.respondToPermission(agent.id, permission.id, { - behavior: "allow", - }); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - expect(finalState.status).toBe("idle"); - - // Verify the file was created - expect(existsSync(filePath)).toBe(true); - - // Verify permission_resolved event was received - const queue = messages; - const hasPermissionResolved = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "allow" - ); - } - return false; - }); - expect(hasPermissionResolved).toBe(true); - - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); - - test("denies permission and prevents execution", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "permission.txt"); - - // Create Codex agent with on-request approval policy - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Permission Deny Test", - modeId: "read-only", - }); - - expect(agent.id).toBeTruthy(); - - // Clear message queue before sending prompt - messages.length = 0; - - // Send a prompt that requires permission - const prompt = [ - 'Request approval to run the command `printf "ok" > permission.txt`.', - "If approval is denied, acknowledge and stop.", - ].join(" "); - - await ctx.client.sendMessage(agent.id, prompt); - - // Wait for permission request - const permissionState = await ctx.client.waitForFinish(agent.id, 60000); - expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); - const permission = permissionState.final!.pendingPermissions[0]; - expect(permission).not.toBeNull(); - expect(permission.id).toBeTruthy(); - - // Deny the permission - await ctx.client.respondToPermission(agent.id, permission.id, { - behavior: "deny", - message: "Not allowed.", - }); - - // Wait for agent to complete - const finalState = await ctx.client.waitForFinish(agent.id, 120000); - expect(finalState.status).toBe("idle"); - - // Verify the file was NOT created - expect(existsSync(filePath)).toBe(false); - - // Verify permission_resolved event was received with deny - const queue = messages; - const hasPermissionDenied = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "deny" - ); - } - return false; - }); - expect(hasPermissionDenied).toBe(true); - - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); - - test("Codex agent can complete a new turn after interrupt", async () => { - const cwd = tmpCwd(); - - // Create Codex agent with full-access (no permissions needed) - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Interrupt Test", - modeId: "full-access", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.currentModeId).toBe("full-access"); - - // Send first message to start a long-running operation so we can interrupt it. - messages.length = 0; - await ctx.client.sendMessage(agent.id, "Run: sleep 30"); - await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 5_000, - ); - - // Cancel while running - await ctx.client.cancelAgent(agent.id); - - // Wait for agent to become idle after cancellation - // Don't use waitForAgentIdle because it requires seeing "running" first, - // but we already saw it above. Just wait for "idle" or "error". - await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "idle" || snapshot.status === "error", - 30000, - ); - - // Now send another message - this should work - messages.length = 0; - await ctx.client.sendMessage(agent.id, "Say 'hello from interrupt test' and nothing else."); - - // Wait for this to complete - await ctx.client.waitForFinish(agent.id, 60000); - - // Verify we got an assistant message in the queue - const queue = messages; - const hasAssistantMessage = queue.some( - (m) => - m.type === "agent_stream" && - m.payload.agentId === agent.id && - m.payload.event.type === "timeline" && - m.payload.event.item.type === "assistant_message", - ); - expect(hasAssistantMessage).toBe(true); - - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); - - test("aborting Codex actually stops execution (sleep + write test)", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "abort-test-file.txt"); - - // Create Codex agent with full-access (no permissions needed) - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Abort Stop Test", - modeId: "full-access", - }); - - expect(agent.id).toBeTruthy(); - - messages.length = 0; - await ctx.client.sendMessage( - agent.id, - "Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt", - ); - - await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 5_000, - ); - - // Cancel/interrupt the agent - await ctx.client.cancelAgent(agent.id); - - await ctx.client.waitForFinish(agent.id, 5_000); - - // Assert the file was NOT created (proving Codex actually stopped) - const fileExists = existsSync(filePath); - expect(fileExists).toBe(false); - - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); - - test("switching from auto to full-access mode allows writes without permission", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "mode-switch-test.txt"); - - // Step 1: Create Codex agent with "auto" mode (requires permission for writes) - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Mode Switch Permission Test", - modeId: "auto", - }); - - expect(agent.id).toBeTruthy(); - expect(agent.currentModeId).toBe("auto"); - - // Step 2: Ask agent to write a file - this should trigger permission request - // Note: We DON'T tell the agent to "stop" if denied - this keeps the conversation - // alive and tests the real scenario where mode switch must work mid-conversation. - messages.length = 0; - const writePrompt = - 'Request approval to run the command `printf "ok" > mode-switch-test.txt`. After approval, run it and stop.'; - - await ctx.client.sendMessage(agent.id, writePrompt); - - // Step 3: Wait for permission request - const permissionState = await ctx.client.waitForFinish(agent.id, 60000); - expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); - const permission = permissionState.final!.pendingPermissions[0]; - expect(permission).not.toBeNull(); - expect(permission.id).toBeTruthy(); - - // Step 4: Deny the permission - await ctx.client.respondToPermission(agent.id, permission.id, { - behavior: "deny", - message: "Permission denied for test.", - }); - - // Wait for agent to complete after denial - await ctx.client.waitForFinish(agent.id, 120000); - - // Verify file was NOT created after denial - expect(existsSync(filePath)).toBe(false); - - // Step 5: Switch to "full-access" mode - messages.length = 0; - const modeStartPosition = messages.length; - - await ctx.client.setAgentMode(agent.id, "full-access"); - - // Wait for mode change to be reflected in agent_update - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Timeout waiting for full-access mode change")); - }, 15000); - - const checkForModeChange = (): void => { - const queue = messages; - for (let i = modeStartPosition; i < queue.length; i++) { - const msg = queue[i]; - if ( - msg.type === "agent_update" && - msg.payload.kind === "upsert" && - msg.payload.agent.id === agent.id && - msg.payload.agent.currentModeId === "full-access" - ) { - clearTimeout(timeout); - clearInterval(interval); - resolve(); - return; - } - } - }; - - const interval = setInterval(checkForModeChange, 50); - }); - - // Step 6: Ask agent to write file again - should succeed WITHOUT permission request - // In full-access mode, the agent should just execute without asking. - messages.length = 0; - const writePrompt2 = 'Run the command `printf "ok" > mode-switch-test.txt` and reply DONE.'; - - await ctx.client.sendMessage(agent.id, writePrompt2); - - // Wait for agent to complete - await ctx.client.waitForFinish(agent.id, 120000); - - // Step 7: Verify file was created (mode switch worked) - expect(existsSync(filePath)).toBe(true); - const content = readFileSync(filePath, "utf-8"); - expect(content).toBe("ok"); - - // Verify no permission was requested in this second attempt - const queue = messages; - const hasPermissionRequest = queue.some( - (m) => m.type === "agent_permission_request" && m.payload.agentId === agent.id, - ); - expect(hasPermissionRequest).toBe(false); - - rmSync(cwd, { recursive: true, force: true }); - }, 30_000); +let ctx: DaemonTestContext; +let messages: SessionOutboundMessage[] = []; +let unsubscribe: (() => void) | null = null; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); + messages = []; + unsubscribe = ctx.client.subscribeRawMessages((message) => { + messages.push(message); }); }); + +afterEach(async () => { + unsubscribe?.(); + await ctx.cleanup(); +}, 60000); + +describe("permission flow: Codex", () => { + test("approves permission and executes command", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "permission.txt"); + + // Create Codex agent with on-request approval policy + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Permission Test", + modeId: "read-only", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.status).toBe("idle"); + + // Clear message queue before sending prompt + messages.length = 0; + + // Send a prompt that requires permission + const prompt = [ + 'Request approval to run the command `printf "ok" > permission.txt`.', + "After approval, run it and reply DONE.", + ].join(" "); + + await ctx.client.sendMessage(agent.id, prompt); + + // Wait for permission request + const permissionState = await ctx.client.waitForFinish(agent.id, 60000); + expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); + const permission = permissionState.final!.pendingPermissions[0]; + expect(permission).not.toBeNull(); + expect(permission.id).toBeTruthy(); + expect(permission.kind).toBe("tool"); + + // Approve the permission + await ctx.client.respondToPermission(agent.id, permission.id, { + behavior: "allow", + }); + + // Wait for agent to complete + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + expect(finalState.status).toBe("idle"); + + // Verify the file was created + expect(existsSync(filePath)).toBe(true); + + // Verify permission_resolved event was received + const queue = messages; + const hasPermissionResolved = queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "allow" + ); + } + return false; + }); + expect(hasPermissionResolved).toBe(true); + + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); + + test("denies permission and prevents execution", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "permission.txt"); + + // Create Codex agent with on-request approval policy + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Permission Deny Test", + modeId: "read-only", + }); + + expect(agent.id).toBeTruthy(); + + // Clear message queue before sending prompt + messages.length = 0; + + // Send a prompt that requires permission + const prompt = [ + 'Request approval to run the command `printf "ok" > permission.txt`.', + "If approval is denied, acknowledge and stop.", + ].join(" "); + + await ctx.client.sendMessage(agent.id, prompt); + + // Wait for permission request + const permissionState = await ctx.client.waitForFinish(agent.id, 60000); + expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); + const permission = permissionState.final!.pendingPermissions[0]; + expect(permission).not.toBeNull(); + expect(permission.id).toBeTruthy(); + + // Deny the permission + await ctx.client.respondToPermission(agent.id, permission.id, { + behavior: "deny", + message: "Not allowed.", + }); + + // Wait for agent to complete + const finalState = await ctx.client.waitForFinish(agent.id, 120000); + expect(finalState.status).toBe("idle"); + + // Verify the file was NOT created + expect(existsSync(filePath)).toBe(false); + + // Verify permission_resolved event was received with deny + const queue = messages; + const hasPermissionDenied = queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "deny" + ); + } + return false; + }); + expect(hasPermissionDenied).toBe(true); + + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); + + test("Codex agent can complete a new turn after interrupt", async () => { + const cwd = tmpCwd(); + + // Create Codex agent with full-access (no permissions needed) + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Interrupt Test", + modeId: "full-access", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.currentModeId).toBe("full-access"); + + // Send first message to start a long-running operation so we can interrupt it. + messages.length = 0; + await ctx.client.sendMessage(agent.id, "Run: sleep 30"); + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000, + ); + + // Cancel while running + await ctx.client.cancelAgent(agent.id); + + // Wait for agent to become idle after cancellation + // Don't use waitForAgentIdle because it requires seeing "running" first, + // but we already saw it above. Just wait for "idle" or "error". + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle" || snapshot.status === "error", + 30000, + ); + + // Now send another message - this should work + messages.length = 0; + await ctx.client.sendMessage(agent.id, "Say 'hello from interrupt test' and nothing else."); + + // Wait for this to complete + await ctx.client.waitForFinish(agent.id, 60000); + + // Verify we got an assistant message in the queue + const queue = messages; + const hasAssistantMessage = queue.some( + (m) => + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "assistant_message", + ); + expect(hasAssistantMessage).toBe(true); + + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); + + test("aborting Codex actually stops execution (sleep + write test)", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "abort-test-file.txt"); + + // Create Codex agent with full-access (no permissions needed) + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Abort Stop Test", + modeId: "full-access", + }); + + expect(agent.id).toBeTruthy(); + + messages.length = 0; + await ctx.client.sendMessage( + agent.id, + "Run this bash command: sleep 15 && echo 'abort-test-completed' > abort-test-file.txt", + ); + + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000, + ); + + // Cancel/interrupt the agent + await ctx.client.cancelAgent(agent.id); + + await ctx.client.waitForFinish(agent.id, 5_000); + + // Assert the file was NOT created (proving Codex actually stopped) + const fileExists = existsSync(filePath); + expect(fileExists).toBe(false); + + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); + + test("switching from auto to full-access mode allows writes without permission", async () => { + const cwd = tmpCwd(); + const filePath = path.join(cwd, "mode-switch-test.txt"); + + // Step 1: Create Codex agent with "auto" mode (requires permission for writes) + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Mode Switch Permission Test", + modeId: "auto", + }); + + expect(agent.id).toBeTruthy(); + expect(agent.currentModeId).toBe("auto"); + + // Step 2: Ask agent to write a file - this should trigger permission request + // Note: We DON'T tell the agent to "stop" if denied - this keeps the conversation + // alive and tests the real scenario where mode switch must work mid-conversation. + messages.length = 0; + const writePrompt = + 'Request approval to run the command `printf "ok" > mode-switch-test.txt`. After approval, run it and stop.'; + + await ctx.client.sendMessage(agent.id, writePrompt); + + // Step 3: Wait for permission request + const permissionState = await ctx.client.waitForFinish(agent.id, 60000); + expect(permissionState.final?.pendingPermissions?.length).toBeGreaterThan(0); + const permission = permissionState.final!.pendingPermissions[0]; + expect(permission).not.toBeNull(); + expect(permission.id).toBeTruthy(); + + // Step 4: Deny the permission + await ctx.client.respondToPermission(agent.id, permission.id, { + behavior: "deny", + message: "Permission denied for test.", + }); + + // Wait for agent to complete after denial + await ctx.client.waitForFinish(agent.id, 120000); + + // Verify file was NOT created after denial + expect(existsSync(filePath)).toBe(false); + + // Step 5: Switch to "full-access" mode + messages.length = 0; + const modeStartPosition = messages.length; + + await ctx.client.setAgentMode(agent.id, "full-access"); + + // Wait for mode change to be reflected in agent_update + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for full-access mode change")); + }, 15000); + + const checkForModeChange = (): void => { + const queue = messages; + for (let i = modeStartPosition; i < queue.length; i++) { + const msg = queue[i]; + if ( + msg.type === "agent_update" && + msg.payload.kind === "upsert" && + msg.payload.agent.id === agent.id && + msg.payload.agent.currentModeId === "full-access" + ) { + clearTimeout(timeout); + clearInterval(interval); + resolve(); + return; + } + } + }; + + const interval = setInterval(checkForModeChange, 50); + }); + + // Step 6: Ask agent to write file again - should succeed WITHOUT permission request + // In full-access mode, the agent should just execute without asking. + messages.length = 0; + const writePrompt2 = 'Run the command `printf "ok" > mode-switch-test.txt` and reply DONE.'; + + await ctx.client.sendMessage(agent.id, writePrompt2); + + // Wait for agent to complete + await ctx.client.waitForFinish(agent.id, 120000); + + // Step 7: Verify file was created (mode switch worked) + expect(existsSync(filePath)).toBe(true); + const content = readFileSync(filePath, "utf-8"); + expect(content).toBe("ok"); + + // Verify no permission was requested in this second attempt + const queue = messages; + const hasPermissionRequest = queue.some( + (m) => m.type === "agent_permission_request" && m.payload.agentId === agent.id, + ); + expect(hasPermissionRequest).toBe(false); + + rmSync(cwd, { recursive: true, force: true }); + }, 30_000); +}); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 8100ce8131..a89df32110 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -92,435 +92,426 @@ async function withConnectedPiDaemon( } } -describe( - "daemon E2E (real pi)", - () => { - let canRun = false; +let canRun = false; - beforeAll(async () => { - canRun = await isProviderAvailable("pi"); - }); +beforeAll(async () => { + canRun = await isProviderAvailable("pi"); +}); - beforeEach((context) => { - if (!canRun) { - context.skip(); - } - }); +beforeEach((context) => { + if (!canRun) { + context.skip(); + } +}); - test( - "bash tool call records completed shell detail and output", - async () => { - const cwd = tmpCwd(); +test( + "bash tool call records completed shell detail and output", + async () => { + const cwd = tmpCwd(); - try { - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-bash-tool-call", - provider: "pi", - model: PI_REAL_TEST_MODEL, - }); - - await client.sendMessage( - agent.id, - "Use the bash tool and run this exact bash command: echo HELLO_PI_TEST", - ); - - const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, agent.id); - const toolCall = findCompletedToolCall( - items, - (item) => - item.detail.type === "shell" && item.detail.command.includes("echo HELLO_PI_TEST"), - ); - - expect(toolCall).toBeDefined(); - expect(toolCall?.status).toBe("completed"); - expect(toolCall?.detail.type).toBe("shell"); - if (toolCall?.detail.type === "shell") { - expect(toolCall.detail.command).toContain("echo HELLO_PI_TEST"); - expect(toolCall.detail.output).toContain("HELLO_PI_TEST"); - } - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "file read tool call captures read detail and content", - async () => { - const cwd = tmpCwd(); - const filename = "pi-read.txt"; - const expectedContent = "PI_READ_CONTENT_12345"; - - try { - writeFileSync(path.join(cwd, filename), expectedContent, "utf8"); - - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-file-read", - provider: "pi", - model: PI_REAL_TEST_MODEL, - }); - - await client.sendMessage( - agent.id, - `Use the read tool to read the file ${filename} and tell me its contents exactly.`, - ); - - const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, agent.id); - const toolCall = findCompletedToolCall( - items, - (item) => - item.detail.type === "read" && - item.detail.filePath.includes(filename) && - item.detail.content?.includes(expectedContent) === true, - ); - - expect(toolCall).toBeDefined(); - expect(toolCall?.detail.type).toBe("read"); - if (toolCall?.detail.type === "read") { - expect(toolCall.detail.filePath).toContain(filename); - expect(toolCall.detail.content).toContain(expectedContent); - } - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "file write tool call captures write detail and writes to disk", - async () => { - const cwd = tmpCwd(); - const filename = "pi-test-write.txt"; - const expectedContent = "PI_WRITE_CONTENT_67890"; - - try { - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-file-write", - provider: "pi", - model: PI_REAL_TEST_MODEL, - }); - - await client.sendMessage( - agent.id, - `Use the write tool to write a file called ${filename} in the current directory with the exact content ${expectedContent}`, - ); - - const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, agent.id); - const toolCall = findCompletedToolCall( - items, - (item) => item.detail.type === "write" && item.detail.filePath.includes(filename), - ); - - expect(toolCall).toBeDefined(); - expect(toolCall?.detail.type).toBe("write"); - expect(existsSync(path.join(cwd, filename))).toBe(true); - expect(readFileSync(path.join(cwd, filename), "utf8")).toBe(expectedContent); - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "file edit tool call captures edit detail and updates the file on disk", - async () => { - const cwd = tmpCwd(); - const filename = "pi-edit.txt"; - const filePath = path.join(cwd, filename); - - try { - writeFileSync(filePath, "BEFORE_EDIT", "utf8"); - - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-file-edit", - provider: "pi", - model: PI_REAL_TEST_MODEL, - }); - - await client.sendMessage( - agent.id, - `Use the edit tool on the file ${filename} and replace BEFORE_EDIT with AFTER_EDIT. Do not just describe the change.`, - ); - - const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, agent.id); - const toolCall = findCompletedToolCall( - items, - (item) => item.detail.type === "edit" && item.detail.filePath.includes(filename), - ); - - expect(toolCall).toBeDefined(); - expect(toolCall?.detail.type).toBe("edit"); - expect(readFileSync(filePath, "utf8")).toContain("AFTER_EDIT"); - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "thinking-enabled runs emit reasoning timeline chunks", - async () => { - const cwd = tmpCwd(); - - try { - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-reasoning", - provider: "pi", - model: PI_REAL_TEST_MODEL, - thinkingOptionId: "high", - }); - - await client.sendMessage( - agent.id, - "Think step by step about what 7 * 13 equals, and give the final answer at the end.", - ); - - const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(finish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, agent.id); - const reasoningItems = items.filter( - (item): item is Extract => - item.type === "reasoning" && item.text.trim().length > 0, - ); - - expect(reasoningItems.length).toBeGreaterThan(0); - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "session persistence survives delete and resume", - async () => { - const cwd = tmpCwd(); - const rememberedToken = "PERSISTENCE_TOKEN_42"; - - try { - await withConnectedPiDaemon(async ({ client }) => { - const agent = await client.createAgent({ - cwd, - title: "pi-persistence", - provider: "pi", - model: PI_REAL_TEST_MODEL, - }); - - await client.sendMessage(agent.id, `Remember this code: ${rememberedToken}`); - - const initialFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); - expect(initialFinish.status).toBe("idle"); - expect(initialFinish.final?.persistence).toBeTruthy(); - - const handle = initialFinish.final?.persistence as AgentPersistenceHandle; - await client.deleteAgent(agent.id); - - const resumed = await client.resumeAgent(handle); - await client.sendMessage(resumed.id, "What was the code I asked you to remember?"); - - const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS); - expect(resumedFinish.status).toBe("idle"); - - const items = await fetchCanonicalTimeline(client, resumed.id); - const assistantText = extractAssistantText(items).toUpperCase(); - expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe( - true, - ); - }); - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "streamHistory replays user/assistant/tool_call timeline after resume", - async () => { - const cwd = tmpCwd("pi-history-prime-"); - const marker = "HISTORY_PRIME_MARKER_4242"; - - const piClient = createPiClient(); - const session = await piClient.createSession({ - provider: "pi", + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ cwd, + title: "pi-bash-tool-call", + provider: "pi", model: PI_REAL_TEST_MODEL, }); - let handle: AgentPersistenceHandle | null = null; + await client.sendMessage( + agent.id, + "Use the bash tool and run this exact bash command: echo HELLO_PI_TEST", + ); - try { - const result = await session.run( - `Use the bash tool to run this exact bash command: echo ${marker}`, - ); + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); - const hadShellToolCall = result.timeline.some( - (item) => - item.type === "tool_call" && - item.status === "completed" && - item.detail.type === "shell" && - item.detail.command.includes(`echo ${marker}`), - ); - expect(hadShellToolCall).toBe(true); + const items = await fetchCanonicalTimeline(client, agent.id); + const toolCall = findCompletedToolCall( + items, + (item) => + item.detail.type === "shell" && item.detail.command.includes("echo HELLO_PI_TEST"), + ); - handle = session.describePersistence(); - expect(handle).toBeTruthy(); - } finally { - await session.close(); + expect(toolCall).toBeDefined(); + expect(toolCall?.status).toBe("completed"); + expect(toolCall?.detail.type).toBe("shell"); + if (toolCall?.detail.type === "shell") { + expect(toolCall.detail.command).toContain("echo HELLO_PI_TEST"); + expect(toolCall.detail.output).toContain("HELLO_PI_TEST"); } - - const resumed = await piClient.resumeSession(handle as AgentPersistenceHandle); - - try { - const events: AgentStreamEvent[] = []; - for await (const event of resumed.streamHistory()) { - events.push(event); - } - - const items = events - .filter( - (event): event is Extract => - event.type === "timeline", - ) - .map((event) => event.item); - - const userItems = items.filter( - (item): item is Extract => - item.type === "user_message", - ); - expect(userItems.length).toBeGreaterThan(0); - expect(userItems.some((item) => item.text.includes(marker))).toBe(true); - expect(userItems.every((item) => typeof item.messageId === "string")).toBe(true); - - const assistantItems = items.filter( - (item): item is Extract => - item.type === "assistant_message", - ); - expect(assistantItems.length).toBeGreaterThan(0); - - const toolCallItem = findCompletedToolCall( - items, - (item) => - item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`), - ); - expect(toolCallItem).toBeDefined(); - if (toolCallItem?.detail.type === "shell") { - expect(toolCallItem.detail.output).toContain(marker); - } - } finally { - await resumed.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "PiDirectAgentClient.listModels returns non-empty Pi model definitions", - async () => { - const client = createPiClient(); - const cwd = tmpCwd("pi-list-models-"); - try { - const models = await client.listModels({ cwd, force: false }); - - expect(models.length).toBeGreaterThan(0); - for (const model of models) { - expect(model.provider).toBe("pi"); - expect(typeof model.id).toBe("string"); - expect(model.id.length).toBeGreaterThan(0); - expect(typeof model.label).toBe("string"); - expect(model.label.length).toBeGreaterThan(0); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "session getRuntimeInfo reflects configured high thinking level", - async () => { - const cwd = tmpCwd("pi-runtime-info-"); - const client = createPiClient(); - - try { - const session = await client.createSession({ - provider: "pi", - cwd, - thinkingOptionId: "high", - }); - - try { - const runtimeInfo = await session.getRuntimeInfo(); - expect(runtimeInfo.thinkingOptionId).toBe("high"); - } finally { - await session.close(); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); - - test( - "session setThinkingOption('low') updates runtime thinking level", - async () => { - const cwd = tmpCwd("pi-feature-"); - const client = createPiClient(); - - try { - const session = await client.createSession({ - provider: "pi", - cwd, - }); - - try { - await session.setThinkingOption?.("low"); - const runtimeInfo = await session.getRuntimeInfo(); - expect(runtimeInfo.thinkingOptionId).toBe("low"); - } finally { - await session.close(); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, - PI_TEST_TIMEOUT_MS, - ); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }, - PI_SUITE_TIMEOUT_MS, + PI_TEST_TIMEOUT_MS, +); + +test( + "file read tool call captures read detail and content", + async () => { + const cwd = tmpCwd(); + const filename = "pi-read.txt"; + const expectedContent = "PI_READ_CONTENT_12345"; + + try { + writeFileSync(path.join(cwd, filename), expectedContent, "utf8"); + + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-file-read", + provider: "pi", + model: PI_REAL_TEST_MODEL, + }); + + await client.sendMessage( + agent.id, + `Use the read tool to read the file ${filename} and tell me its contents exactly.`, + ); + + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, agent.id); + const toolCall = findCompletedToolCall( + items, + (item) => + item.detail.type === "read" && + item.detail.filePath.includes(filename) && + item.detail.content?.includes(expectedContent) === true, + ); + + expect(toolCall).toBeDefined(); + expect(toolCall?.detail.type).toBe("read"); + if (toolCall?.detail.type === "read") { + expect(toolCall.detail.filePath).toContain(filename); + expect(toolCall.detail.content).toContain(expectedContent); + } + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "file write tool call captures write detail and writes to disk", + async () => { + const cwd = tmpCwd(); + const filename = "pi-test-write.txt"; + const expectedContent = "PI_WRITE_CONTENT_67890"; + + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-file-write", + provider: "pi", + model: PI_REAL_TEST_MODEL, + }); + + await client.sendMessage( + agent.id, + `Use the write tool to write a file called ${filename} in the current directory with the exact content ${expectedContent}`, + ); + + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, agent.id); + const toolCall = findCompletedToolCall( + items, + (item) => item.detail.type === "write" && item.detail.filePath.includes(filename), + ); + + expect(toolCall).toBeDefined(); + expect(toolCall?.detail.type).toBe("write"); + expect(existsSync(path.join(cwd, filename))).toBe(true); + expect(readFileSync(path.join(cwd, filename), "utf8")).toBe(expectedContent); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "file edit tool call captures edit detail and updates the file on disk", + async () => { + const cwd = tmpCwd(); + const filename = "pi-edit.txt"; + const filePath = path.join(cwd, filename); + + try { + writeFileSync(filePath, "BEFORE_EDIT", "utf8"); + + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-file-edit", + provider: "pi", + model: PI_REAL_TEST_MODEL, + }); + + await client.sendMessage( + agent.id, + `Use the edit tool on the file ${filename} and replace BEFORE_EDIT with AFTER_EDIT. Do not just describe the change.`, + ); + + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, agent.id); + const toolCall = findCompletedToolCall( + items, + (item) => item.detail.type === "edit" && item.detail.filePath.includes(filename), + ); + + expect(toolCall).toBeDefined(); + expect(toolCall?.detail.type).toBe("edit"); + expect(readFileSync(filePath, "utf8")).toContain("AFTER_EDIT"); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "thinking-enabled runs emit reasoning timeline chunks", + async () => { + const cwd = tmpCwd(); + + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-reasoning", + provider: "pi", + model: PI_REAL_TEST_MODEL, + thinkingOptionId: "high", + }); + + await client.sendMessage( + agent.id, + "Think step by step about what 7 * 13 equals, and give the final answer at the end.", + ); + + const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(finish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, agent.id); + const reasoningItems = items.filter( + (item): item is Extract => + item.type === "reasoning" && item.text.trim().length > 0, + ); + + expect(reasoningItems.length).toBeGreaterThan(0); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "session persistence survives delete and resume", + async () => { + const cwd = tmpCwd(); + const rememberedToken = "PERSISTENCE_TOKEN_42"; + + try { + await withConnectedPiDaemon(async ({ client }) => { + const agent = await client.createAgent({ + cwd, + title: "pi-persistence", + provider: "pi", + model: PI_REAL_TEST_MODEL, + }); + + await client.sendMessage(agent.id, `Remember this code: ${rememberedToken}`); + + const initialFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS); + expect(initialFinish.status).toBe("idle"); + expect(initialFinish.final?.persistence).toBeTruthy(); + + const handle = initialFinish.final?.persistence as AgentPersistenceHandle; + await client.deleteAgent(agent.id); + + const resumed = await client.resumeAgent(handle); + await client.sendMessage(resumed.id, "What was the code I asked you to remember?"); + + const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS); + expect(resumedFinish.status).toBe("idle"); + + const items = await fetchCanonicalTimeline(client, resumed.id); + const assistantText = extractAssistantText(items).toUpperCase(); + expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true); + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "streamHistory replays user/assistant/tool_call timeline after resume", + async () => { + const cwd = tmpCwd("pi-history-prime-"); + const marker = "HISTORY_PRIME_MARKER_4242"; + + const piClient = createPiClient(); + const session = await piClient.createSession({ + provider: "pi", + cwd, + model: PI_REAL_TEST_MODEL, + }); + + let handle: AgentPersistenceHandle | null = null; + + try { + const result = await session.run( + `Use the bash tool to run this exact bash command: echo ${marker}`, + ); + + const hadShellToolCall = result.timeline.some( + (item) => + item.type === "tool_call" && + item.status === "completed" && + item.detail.type === "shell" && + item.detail.command.includes(`echo ${marker}`), + ); + expect(hadShellToolCall).toBe(true); + + handle = session.describePersistence(); + expect(handle).toBeTruthy(); + } finally { + await session.close(); + } + + const resumed = await piClient.resumeSession(handle as AgentPersistenceHandle); + + try { + const events: AgentStreamEvent[] = []; + for await (const event of resumed.streamHistory()) { + events.push(event); + } + + const items = events + .filter( + (event): event is Extract => + event.type === "timeline", + ) + .map((event) => event.item); + + const userItems = items.filter( + (item): item is Extract => + item.type === "user_message", + ); + expect(userItems.length).toBeGreaterThan(0); + expect(userItems.some((item) => item.text.includes(marker))).toBe(true); + expect(userItems.every((item) => typeof item.messageId === "string")).toBe(true); + + const assistantItems = items.filter( + (item): item is Extract => + item.type === "assistant_message", + ); + expect(assistantItems.length).toBeGreaterThan(0); + + const toolCallItem = findCompletedToolCall( + items, + (item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`), + ); + expect(toolCallItem).toBeDefined(); + if (toolCallItem?.detail.type === "shell") { + expect(toolCallItem.detail.output).toContain(marker); + } + } finally { + await resumed.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "PiDirectAgentClient.listModels returns non-empty Pi model definitions", + async () => { + const client = createPiClient(); + const cwd = tmpCwd("pi-list-models-"); + try { + const models = await client.listModels({ cwd, force: false }); + + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + expect(model.provider).toBe("pi"); + expect(typeof model.id).toBe("string"); + expect(model.id.length).toBeGreaterThan(0); + expect(typeof model.label).toBe("string"); + expect(model.label.length).toBeGreaterThan(0); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "session getRuntimeInfo reflects configured high thinking level", + async () => { + const cwd = tmpCwd("pi-runtime-info-"); + const client = createPiClient(); + + try { + const session = await client.createSession({ + provider: "pi", + cwd, + thinkingOptionId: "high", + }); + + try { + const runtimeInfo = await session.getRuntimeInfo(); + expect(runtimeInfo.thinkingOptionId).toBe("high"); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, +); + +test( + "session setThinkingOption('low') updates runtime thinking level", + async () => { + const cwd = tmpCwd("pi-feature-"); + const client = createPiClient(); + + try { + const session = await client.createSession({ + provider: "pi", + cwd, + }); + + try { + await session.setThinkingOption?.("low"); + const runtimeInfo = await session.getRuntimeInfo(); + expect(runtimeInfo.thinkingOptionId).toBe("low"); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + PI_TEST_TIMEOUT_MS, ); diff --git a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts index a3d4d2278d..ce82ac5941 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -423,851 +423,840 @@ async function subscribeRawTerminal( return message.message.payload.slot; } -describe("daemon E2E terminal", () => { - let ctx: DaemonTestContext; - let tempDirs: string[]; +let ctx: DaemonTestContext; +let tempDirs: string[]; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - tempDirs = []; +beforeEach(async () => { + ctx = await createDaemonTestContext(); + tempDirs = []; +}); + +afterEach(async () => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + await ctx.cleanup(); +}, 60000); + +test("lists terminals for a directory", async () => { + const cwd = tmpCwd(); + + const list = await ctx.client.listTerminals(cwd); + + expect(list.cwd).toBe(cwd); + expect(list.terminals).toEqual([]); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("client connects and receives a snapshot of the current terminal state", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf 'hello\\n'\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const snapshotPromise = waitForTerminalSnapshot(ctx.client, terminalId, (state) => + extractStateText(state).includes("hello"), + ); + await ctx.client.subscribeTerminal(terminalId); + const snapshot = await snapshotPromise; + + expect(extractStateText(snapshot)).toContain("hello"); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("propagates debounced terminal titles through list responses and snapshots", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd, undefined, undefined, { + command: "/bin/sh", + args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"], + }); + const terminalId = created.terminal!.id; + + let listedTitle: string | undefined; + const start = Date.now(); + while (Date.now() - start < 10000) { + const list = await ctx.client.listTerminals(cwd); + listedTitle = list.terminals.find((terminal) => terminal.id === terminalId)?.title; + if (listedTitle === "Build Output") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(listedTitle).toBe("Build Output"); + + const snapshotPromise = waitForTerminalSnapshot( + ctx.client, + terminalId, + (state) => state.title === "Build Output", + ); + await ctx.client.subscribeTerminal(terminalId); + const snapshot = await snapshotPromise; + + expect(snapshot.title).toBe("Build Output"); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("subscribe response is sent before the initial snapshot frame", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); + + try { + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf 'hello-ordering\\n'\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const observed = await new Promise>((resolve, reject) => { + const events: Array<"response" | "snapshot"> = []; + const timeoutHandle = setTimeout(() => { + cleanup(); + reject(new Error("Timed out waiting for subscribe response and snapshot")); + }, 10000); + + const cleanup = () => { + clearTimeout(timeoutHandle); + ws.off("message", onMessage); + ws.off("error", onError); + }; + + const maybeResolve = () => { + if (!events.includes("response") || !events.includes("snapshot")) { + return; + } + cleanup(); + resolve(events); + }; + + const onMessage = (raw: WebSocket.RawData) => { + const buffer = toWsBuffer(raw); + const text = typeof raw === "string" ? raw : buffer?.toString("utf8"); + if (text) { + try { + const parsedResult = WSOutboundMessageSchema.safeParse(JSON.parse(text)); + if ( + parsedResult.success && + parsedResult.data.type === "session" && + parsedResult.data.message.type === "subscribe_terminal_response" && + parsedResult.data.message.payload.requestId === "sub-ordering" + ) { + events.push("response"); + maybeResolve(); + return; + } + } catch { + // ignore non-session text frames + } + } + + if (!buffer) { + return; + } + const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); + if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { + return; + } + const state = decodeTerminalSnapshotPayload(frame.payload); + if (!state || !extractStateText(state).includes("hello-ordering")) { + return; + } + events.push("snapshot"); + maybeResolve(); + }; + + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + + ws.on("message", onMessage); + ws.on("error", onError); + ws.send( + JSON.stringify({ + type: "session", + message: { + type: "subscribe_terminal_request", + terminalId, + requestId: "sub-ordering", + }, + }), + ); + }); + + expect(observed).toEqual(["response", "snapshot"]); + } finally { + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("client sends input and receives output as raw bytes", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + const outputPromise = waitForTerminalOutput(ctx.client, terminalId, (text) => + text.includes("binary-stream"), + ); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo binary-stream\r", }); - afterEach(async () => { - for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); - } - await ctx.cleanup(); - }, 60000); + expect(await outputPromise).toContain("binary-stream"); - test("lists terminals for a directory", async () => { - const cwd = tmpCwd(); + rmSync(cwd, { recursive: true, force: true }); +}, 30000); - const list = await ctx.client.listTerminals(cwd); +test("one client can stream two terminals concurrently", async () => { + const cwd = tmpCwd(); + const firstCreated = await ctx.client.createTerminal(cwd, "first"); + const secondCreated = await ctx.client.createTerminal(cwd, "second"); + const firstTerminalId = firstCreated.terminal!.id; + const secondTerminalId = secondCreated.terminal!.id; - expect(list.cwd).toBe(cwd); - expect(list.terminals).toEqual([]); + const firstSubscribe = await ctx.client.subscribeTerminal(firstTerminalId); + const secondSubscribe = await ctx.client.subscribeTerminal(secondTerminalId); + expect(firstSubscribe.error).toBeNull(); + expect(secondSubscribe.error).toBeNull(); + expect(firstSubscribe.slot).not.toBe(secondSubscribe.slot); + + const firstOutput = waitForTerminalOutput(ctx.client, firstTerminalId, (text) => + text.includes("from-first"), + ); + const secondOutput = waitForTerminalOutput(ctx.client, secondTerminalId, (text) => + text.includes("from-second"), + ); + + ctx.client.sendTerminalInput(firstTerminalId, { + type: "input", + data: "echo from-first\r", + }); + ctx.client.sendTerminalInput(secondTerminalId, { + type: "input", + data: "echo from-second\r", + }); + + expect(await firstOutput).toContain("from-first"); + expect(await secondOutput).toContain("from-second"); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("disconnect and reconnect both receive the current snapshot", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + await ctx.client.subscribeTerminal(terminalId); + ctx.client.unsubscribeTerminal(terminalId); + + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo while-detached\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const snapshotPromise = waitForTerminalSnapshot(ctx.client, terminalId, (state) => + extractStateText(state).includes("while-detached"), + ); + await ctx.client.subscribeTerminal(terminalId); + + expect(extractStateText(await snapshotPromise)).toContain("while-detached"); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("fast output to a slow websocket client falls back to a snapshot", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); + + await subscribeRawTerminal(ws, terminalId, "sub-raw"); + await waitForRawBinaryFrame(ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000); + + const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } }) + ._socket; + expect(rawSocket).toBeDefined(); + + rawSocket!.pause(); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: `node -e 'process.stdout.write("A".repeat(${8 * 1024 * 1024}))'\r`, + }); + + await new Promise((resolve) => setTimeout(resolve, 750)); + rawSocket!.resume(); + + const catchUpFrame = await waitForRawBinaryFrame( + ws, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 15000, + ); + expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); +}, 40000); + +test("multiple clients on the same terminal each receive output independently", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const secondClient = await connectClient( + ctx.daemon.port, + `terminal-secondary-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + try { + const firstOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => + text.includes("fanout"), + ); + const secondOutput = waitForTerminalOutput(secondClient, terminalId, (text) => + text.includes("fanout"), + ); + + await ctx.client.subscribeTerminal(terminalId); + await secondClient.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo fanout\r", + }); + + expect(await firstOutput).toContain("fanout"); + expect(await secondOutput).toContain("fanout"); + } finally { + await secondClient.close(); + } + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("resize updates server dimensions without sending a live snapshot", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "resize", + rows: 10, + cols: 40, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); + expect(state?.rows).toBe(10); + expect(state?.cols).toBe(40); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("resize does not stall streamed output for an attached client", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const secondClient = await connectClient( + ctx.daemon.port, + `terminal-resize-stream-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + + try { + await ctx.client.subscribeTerminal(terminalId); + await secondClient.subscribeTerminal(terminalId); + + secondClient.sendTerminalInput(terminalId, { + type: "resize", + rows: 12, + cols: 40, + }); + + const firstOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => + text.includes("after-resize-stream"), + ); + const secondOutput = waitForTerminalOutput(secondClient, terminalId, (text) => + text.includes("after-resize-stream"), + ); + + secondClient.sendTerminalInput(terminalId, { + type: "input", + data: "printf 'after-resize-stream\\n'\r", + }); + + expect(await firstOutput).toContain("after-resize-stream"); + expect(await secondOutput).toContain("after-resize-stream"); + } finally { + await secondClient.close(); rmSync(cwd, { recursive: true, force: true }); - }, 30000); + } +}, 30000); - test("client connects and receives a snapshot of the current terminal state", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; +test("terminal exits notify the client", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + + let sawExit = false; + const unsubscribe = ctx.client.on("terminal_stream_exit", (message) => { + if (message.type !== "terminal_stream_exit") { + return; + } + if (message.payload.terminalId === terminalId) { + sawExit = true; + } + }); + + await ctx.client.subscribeTerminal(terminalId); + const kill = await ctx.client.killTerminal(terminalId); + expect(kill.success).toBe(true); + + await waitForCondition(() => sawExit, 10000); + unsubscribe(); + + rmSync(cwd, { recursive: true, force: true }); +}, 30000); + +test("websocket terminate then new connection gets snapshot with all prior output", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const firstSocket = await connectRawWebSocket(ctx.daemon.port); + + try { + await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); + const initialSnapshot = await waitForRawBinaryFrame( + firstSocket, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 10000, + ); + expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); ctx.client.sendTerminalInput(terminalId, { type: "input", - data: "printf 'hello\\n'\r", + data: "printf 'before-drop\\n'\r", }); - await new Promise((resolve) => setTimeout(resolve, 300)); - - const snapshotPromise = waitForTerminalSnapshot(ctx.client, terminalId, (state) => - extractStateText(state).includes("hello"), + const beforeDropFrame = await waitForRawBinaryFrame( + firstSocket, + (frame) => getFrameText(frame).includes("before-drop"), + 10000, ); - await ctx.client.subscribeTerminal(terminalId); - const snapshot = await snapshotPromise; + expect(getFrameText(beforeDropFrame)).toContain("before-drop"); - expect(extractStateText(snapshot)).toContain("hello"); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("propagates debounced terminal titles through list responses and snapshots", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd, undefined, undefined, { - command: "/bin/sh", - args: ["-lc", "printf '\\033]0;Build Output\\007'; sleep 2"], + await new Promise((resolve) => { + firstSocket.once("close", () => resolve()); + firstSocket.terminate(); }); - const terminalId = created.terminal!.id; - let listedTitle: string | undefined; - const start = Date.now(); - while (Date.now() - start < 10000) { - const list = await ctx.client.listTerminals(cwd); - listedTitle = list.terminals.find((terminal) => terminal.id === terminalId)?.title; - if (listedTitle === "Build Output") { - break; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - expect(listedTitle).toBe("Build Output"); - - const snapshotPromise = waitForTerminalSnapshot( - ctx.client, - terminalId, - (state) => state.title === "Build Output", + const secondClient = await connectClient( + ctx.daemon.port, + `terminal-drop-input-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); - await ctx.client.subscribeTerminal(terminalId); - const snapshot = await snapshotPromise; - - expect(snapshot.title).toBe("Build Output"); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("subscribe response is sent before the initial snapshot frame", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - ctx.client.sendTerminalInput(terminalId, { + secondClient.sendTerminalInput(terminalId, { type: "input", - data: "printf 'hello-ordering\\n'\r", + data: "printf 'while-dead\\n'\r", }); await new Promise((resolve) => setTimeout(resolve, 300)); - - const observed = await new Promise>((resolve, reject) => { - const events: Array<"response" | "snapshot"> = []; - const timeoutHandle = setTimeout(() => { - cleanup(); - reject(new Error("Timed out waiting for subscribe response and snapshot")); - }, 10000); - - const cleanup = () => { - clearTimeout(timeoutHandle); - ws.off("message", onMessage); - ws.off("error", onError); - }; - - const maybeResolve = () => { - if (!events.includes("response") || !events.includes("snapshot")) { - return; - } - cleanup(); - resolve(events); - }; - - const onMessage = (raw: WebSocket.RawData) => { - const buffer = toWsBuffer(raw); - const text = typeof raw === "string" ? raw : buffer?.toString("utf8"); - if (text) { - try { - const parsedResult = WSOutboundMessageSchema.safeParse(JSON.parse(text)); - if ( - parsedResult.success && - parsedResult.data.type === "session" && - parsedResult.data.message.type === "subscribe_terminal_response" && - parsedResult.data.message.payload.requestId === "sub-ordering" - ) { - events.push("response"); - maybeResolve(); - return; - } - } catch { - // ignore non-session text frames - } - } - - if (!buffer) { - return; - } - const frame = decodeTerminalStreamFrame(new Uint8Array(buffer)); - if (frame?.opcode !== TerminalStreamOpcode.Snapshot) { - return; - } - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state || !extractStateText(state).includes("hello-ordering")) { - return; - } - events.push("snapshot"); - maybeResolve(); - }; - - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - - ws.on("message", onMessage); - ws.on("error", onError); - ws.send( - JSON.stringify({ - type: "session", - message: { - type: "subscribe_terminal_request", - terminalId, - requestId: "sub-ordering", - }, - }), - ); - }); - - expect(observed).toEqual(["response", "snapshot"]); } finally { - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); + await secondClient.close(); } - }, 30000); - test("client sends input and receives output as raw bytes", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - const outputPromise = waitForTerminalOutput(ctx.client, terminalId, (text) => - text.includes("binary-stream"), - ); - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo binary-stream\r", - }); - - expect(await outputPromise).toContain("binary-stream"); + const secondSocket = await connectRawWebSocket(ctx.daemon.port); + try { + await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); + const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); + expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); + const state = decodeTerminalSnapshotPayload(reconnectFrame.payload); + expect(state).not.toBeNull(); + expect(extractStateText(state!)).toContain("before-drop"); + expect(extractStateText(state!)).toContain("while-dead"); + } finally { + await closeWebSocket(secondSocket); + } + } finally { rmSync(cwd, { recursive: true, force: true }); - }, 30000); + } +}, 30000); - test("one client can stream two terminals concurrently", async () => { - const cwd = tmpCwd(); - const firstCreated = await ctx.client.createTerminal(cwd, "first"); - const secondCreated = await ctx.client.createTerminal(cwd, "second"); - const firstTerminalId = firstCreated.terminal!.id; - const secondTerminalId = secondCreated.terminal!.id; - - const firstSubscribe = await ctx.client.subscribeTerminal(firstTerminalId); - const secondSubscribe = await ctx.client.subscribeTerminal(secondTerminalId); - - expect(firstSubscribe.error).toBeNull(); - expect(secondSubscribe.error).toBeNull(); - expect(firstSubscribe.slot).not.toBe(secondSubscribe.slot); - - const firstOutput = waitForTerminalOutput(ctx.client, firstTerminalId, (text) => - text.includes("from-first"), - ); - const secondOutput = waitForTerminalOutput(ctx.client, secondTerminalId, (text) => - text.includes("from-second"), - ); - - ctx.client.sendTerminalInput(firstTerminalId, { - type: "input", - data: "echo from-first\r", - }); - ctx.client.sendTerminalInput(secondTerminalId, { - type: "input", - data: "echo from-second\r", - }); - - expect(await firstOutput).toContain("from-first"); - expect(await secondOutput).toContain("from-second"); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("disconnect and reconnect both receive the current snapshot", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; +test("two clients can both send input and each sees its own output", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const secondClient = await connectClient( + ctx.daemon.port, + `terminal-input-dual-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + try { await ctx.client.subscribeTerminal(terminalId); - ctx.client.unsubscribeTerminal(terminalId); + await secondClient.subscribeTerminal(terminalId); + + const firstOwnOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => + text.includes("from-a"), + ); + const secondOwnOutput = waitForTerminalOutput(secondClient, terminalId, (text) => + text.includes("from-b"), + ); ctx.client.sendTerminalInput(terminalId, { type: "input", - data: "echo while-detached\r", + data: "echo from-a\r", + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + secondClient.sendTerminalInput(terminalId, { + type: "input", + data: "echo from-b\r", }); - await new Promise((resolve) => setTimeout(resolve, 300)); - - const snapshotPromise = waitForTerminalSnapshot(ctx.client, terminalId, (state) => - extractStateText(state).includes("while-detached"), - ); - await ctx.client.subscribeTerminal(terminalId); - - expect(extractStateText(await snapshotPromise)).toContain("while-detached"); + expect(await firstOwnOutput).toContain("from-a"); + expect(await secondOwnOutput).toContain("from-b"); + } finally { + await secondClient.close(); rmSync(cwd, { recursive: true, force: true }); - }, 30000); + } +}, 30000); - test("fast output to a slow websocket client falls back to a snapshot", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const ws = await connectRawWebSocket(ctx.daemon.port); +test("snapshot fidelity through websocket decode preserves dimensions and visible text", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd, "Snapshot Fidelity"); + const terminalId = created.terminal!.id; - await subscribeRawTerminal(ws, terminalId, "sub-raw"); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf 'line1\\nline2\\nline3\\n'\r", + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const ws = await connectRawWebSocket(ctx.daemon.port); + try { + await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); + const snapshotFrame = await waitForRawBinaryFrame( + ws, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 10000, + ); + + const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); + expect(state).not.toBeNull(); + expect(state).toMatchObject({ + rows: 24, + cols: 80, + cursor: expect.any(Object), + grid: expect.any(Array), + scrollback: expect.any(Array), + }); + expect(extractStateText(state!)).toContain("line1"); + expect(extractStateText(state!)).toContain("line2"); + expect(extractStateText(state!)).toContain("line3"); + } finally { + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + +test("terminal exit prevents resubscribe and sends no frames after exit", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); + + try { + await subscribeRawTerminal(ws, terminalId, "sub-exit"); await waitForRawBinaryFrame( ws, (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, 10000, ); - const rawSocket = (ws as WebSocket & { _socket?: { pause: () => void; resume: () => void } }) - ._socket; - expect(rawSocket).toBeDefined(); - - rawSocket!.pause(); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: `node -e 'process.stdout.write("A".repeat(${8 * 1024 * 1024}))'\r`, - }); - - await new Promise((resolve) => setTimeout(resolve, 750)); - rawSocket!.resume(); - - const catchUpFrame = await waitForRawBinaryFrame( + const exitMessagePromise = waitForRawSessionMessage( ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 15000, + (message) => + message.message.type === "terminal_stream_exit" && + message.message.payload.terminalId === terminalId, + 10000, ); - expect(catchUpFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); - - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); - }, 40000); - - test("multiple clients on the same terminal each receive output independently", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const secondClient = await connectClient( - ctx.daemon.port, - `terminal-secondary-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - - try { - const firstOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => - text.includes("fanout"), - ); - const secondOutput = waitForTerminalOutput(secondClient, terminalId, (text) => - text.includes("fanout"), - ); - - await ctx.client.subscribeTerminal(terminalId); - await secondClient.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo fanout\r", - }); - - expect(await firstOutput).toContain("fanout"); - expect(await secondOutput).toContain("fanout"); - } finally { - await secondClient.close(); - } - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("resize updates server dimensions without sending a live snapshot", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "resize", - rows: 10, - cols: 40, - }); - - await new Promise((resolve) => setTimeout(resolve, 300)); - - const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState(); - expect(state?.rows).toBe(10); - expect(state?.cols).toBe(40); - - rmSync(cwd, { recursive: true, force: true }); - }, 30000); - - test("resize does not stall streamed output for an attached client", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const secondClient = await connectClient( - ctx.daemon.port, - `terminal-resize-stream-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - - try { - await ctx.client.subscribeTerminal(terminalId); - await secondClient.subscribeTerminal(terminalId); - - secondClient.sendTerminalInput(terminalId, { - type: "resize", - rows: 12, - cols: 40, - }); - - const firstOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => - text.includes("after-resize-stream"), - ); - const secondOutput = waitForTerminalOutput(secondClient, terminalId, (text) => - text.includes("after-resize-stream"), - ); - - secondClient.sendTerminalInput(terminalId, { - type: "input", - data: "printf 'after-resize-stream\\n'\r", - }); - - expect(await firstOutput).toContain("after-resize-stream"); - expect(await secondOutput).toContain("after-resize-stream"); - } finally { - await secondClient.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); - - test("terminal exits notify the client", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - let sawExit = false; - const unsubscribe = ctx.client.on("terminal_stream_exit", (message) => { - if (message.type !== "terminal_stream_exit") { - return; - } - if (message.payload.terminalId === terminalId) { - sawExit = true; - } - }); - - await ctx.client.subscribeTerminal(terminalId); const kill = await ctx.client.killTerminal(terminalId); expect(kill.success).toBe(true); + const exitMessage = await exitMessagePromise; + expect(exitMessage.message.type).toBe("terminal_stream_exit"); - await waitForCondition(() => sawExit, 10000); - unsubscribe(); - + const subscribeResult = await ctx.client.subscribeTerminal(terminalId); + expect(subscribeResult.error).toBe("Terminal not found"); + await waitForNoRawBinaryFrame(ws, 750); + } finally { + await closeWebSocket(ws); rmSync(cwd, { recursive: true, force: true }); - }, 30000); + } +}, 30000); - test("websocket terminate then new connection gets snapshot with all prior output", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const firstSocket = await connectRawWebSocket(ctx.daemon.port); +test("empty input frame does not crash the server", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); - try { - await subscribeRawTerminal(firstSocket, terminalId, "sub-drop-a"); - const initialSnapshot = await waitForRawBinaryFrame( - firstSocket, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 10000, - ); - expect(initialSnapshot.opcode).toBe(TerminalStreamOpcode.Snapshot); - - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "printf 'before-drop\\n'\r", - }); - const beforeDropFrame = await waitForRawBinaryFrame( - firstSocket, - (frame) => getFrameText(frame).includes("before-drop"), - 10000, - ); - expect(getFrameText(beforeDropFrame)).toContain("before-drop"); - - await new Promise((resolve) => { - firstSocket.once("close", () => resolve()); - firstSocket.terminate(); - }); - - const secondClient = await connectClient( - ctx.daemon.port, - `terminal-drop-input-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - try { - secondClient.sendTerminalInput(terminalId, { - type: "input", - data: "printf 'while-dead\\n'\r", - }); - await new Promise((resolve) => setTimeout(resolve, 300)); - } finally { - await secondClient.close(); - } - - const secondSocket = await connectRawWebSocket(ctx.daemon.port); - try { - await subscribeRawTerminal(secondSocket, terminalId, "sub-drop-b"); - const reconnectFrame = await waitForRawBinaryFrame(secondSocket, () => true, 10000); - expect(reconnectFrame.opcode).toBe(TerminalStreamOpcode.Snapshot); - - const state = decodeTerminalSnapshotPayload(reconnectFrame.payload); - expect(state).not.toBeNull(); - expect(extractStateText(state!)).toContain("before-drop"); - expect(extractStateText(state!)).toContain("while-dead"); - } finally { - await closeWebSocket(secondSocket); - } - } finally { - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); - - test("two clients can both send input and each sees its own output", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - const secondClient = await connectClient( - ctx.daemon.port, - `terminal-input-dual-${Date.now()}-${Math.random().toString(36).slice(2)}`, + try { + const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); + await waitForRawBinaryFrame( + ws, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 10000, ); - try { - await ctx.client.subscribeTerminal(terminalId); - await secondClient.subscribeTerminal(terminalId); - - const firstOwnOutput = waitForTerminalOutput(ctx.client, terminalId, (text) => - text.includes("from-a"), - ); - const secondOwnOutput = waitForTerminalOutput(secondClient, terminalId, (text) => - text.includes("from-b"), - ); - - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo from-a\r", - }); - await new Promise((resolve) => setTimeout(resolve, 200)); - secondClient.sendTerminalInput(terminalId, { - type: "input", - data: "echo from-b\r", - }); - - expect(await firstOwnOutput).toContain("from-a"); - expect(await secondOwnOutput).toContain("from-b"); - } finally { - await secondClient.close(); - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); - - test("snapshot fidelity through websocket decode preserves dimensions and visible text", async () => { - const cwd = tmpCwd(); - const created = await ctx.client.createTerminal(cwd, "Snapshot Fidelity"); - const terminalId = created.terminal!.id; + ws.send( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + slot, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(ws.readyState).toBe(WebSocket.OPEN); ctx.client.sendTerminalInput(terminalId, { type: "input", - data: "printf 'line1\\nline2\\nline3\\n'\r", + data: "echo alive\r", }); - await new Promise((resolve) => setTimeout(resolve, 300)); + const aliveFrame = await waitForRawBinaryFrame( + ws, + (frame) => getFrameText(frame).includes("alive"), + 10000, + ); + expect(getFrameText(aliveFrame)).toContain("alive"); + } finally { + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - await subscribeRawTerminal(ws, terminalId, "sub-fidelity"); - const snapshotFrame = await waitForRawBinaryFrame( - ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 10000, - ); +test("1MB output burst keeps frames decodable and terminal usable afterward", async () => { + const cwd = tmpCwd(); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; + const ws = await connectRawWebSocket(ctx.daemon.port); - const state = decodeTerminalSnapshotPayload(snapshotFrame.payload); - expect(state).not.toBeNull(); - expect(state).toMatchObject({ - rows: 24, - cols: 80, - cursor: expect.any(Object), - grid: expect.any(Array), - scrollback: expect.any(Array), - }); - expect(extractStateText(state!)).toContain("line1"); - expect(extractStateText(state!)).toContain("line2"); - expect(extractStateText(state!)).toContain("line3"); - } finally { - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); + try { + await subscribeRawTerminal(ws, terminalId, "sub-large-output"); + await waitForRawBinaryFrame( + ws, + (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, + 10000, + ); + + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: `node -e "process.stdout.write('X'.repeat(${1024 * 1024}))"\r`, + }); + + const frames = await collectRawBinaryFrames( + ws, + (collectedFrames) => { + const outputBytes = collectedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .reduce((sum, frame) => sum + frame.payload.byteLength, 0); + return ( + outputBytes >= 1024 * 1024 || + collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) + ); + }, + 20000, + ); + + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + expect( + frame.opcode === TerminalStreamOpcode.Output || + frame.opcode === TerminalStreamOpcode.Snapshot, + ).toBe(true); } - }, 30000); - test("terminal exit prevents resubscribe and sends no frames after exit", async () => { + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo after-burst\r", + }); + const postBurstFrame = await waitForRawBinaryFrame( + ws, + (frame) => getFrameText(frame).includes("after-burst"), + 10000, + ); + expect(getFrameText(postBurstFrame)).toContain("after-burst"); + } finally { + await closeWebSocket(ws); + rmSync(cwd, { recursive: true, force: true }); + } +}, 40000); + +describe("capture", () => { + test("captures visible terminal output as plain text", async () => { const cwd = tmpCwd(); + tempDirs.push(cwd); const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - await subscribeRawTerminal(ws, terminalId, "sub-exit"); - await waitForRawBinaryFrame( - ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 10000, - ); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo hello world\r", + }); + await waitForTerminalOutput( + ctx.client, + terminalId, + (text) => text.includes("hello world"), + 15000, + ); - const exitMessagePromise = waitForRawSessionMessage( - ws, - (message) => - message.message.type === "terminal_stream_exit" && - message.message.payload.terminalId === terminalId, - 10000, - ); - const kill = await ctx.client.killTerminal(terminalId); - expect(kill.success).toBe(true); - const exitMessage = await exitMessagePromise; - expect(exitMessage.message.type).toBe("terminal_stream_exit"); + const capture = await ctx.client.captureTerminal(terminalId); - const subscribeResult = await ctx.client.subscribeTerminal(terminalId); - expect(subscribeResult.error).toBe("Terminal not found"); - await waitForNoRawBinaryFrame(ws, 750); - } finally { - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); + expect(capture.lines.join("\n")).toContain("hello world"); + expect(capture.totalLines).toBeGreaterThan(0); + }, 15000); - test("empty input frame does not crash the server", async () => { + test("captures with start/end line range", async () => { const cwd = tmpCwd(); + tempDirs.push(cwd); const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - const slot = await subscribeRawTerminal(ws, terminalId, "sub-empty-input"); - await waitForRawBinaryFrame( - ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 10000, - ); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line1\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line2\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo line3\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000); - ws.send( - encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - slot, - }), - ); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(ws.readyState).toBe(WebSocket.OPEN); + const fullCapture = await ctx.client.captureTerminal(terminalId); + const rangedCapture = await ctx.client.captureTerminal(terminalId, { + start: 0, + end: 2, + }); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo alive\r", - }); - const aliveFrame = await waitForRawBinaryFrame( - ws, - (frame) => getFrameText(frame).includes("alive"), - 10000, - ); - expect(getFrameText(aliveFrame)).toContain("alive"); - } finally { - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); + expect(rangedCapture.lines).toHaveLength(3); + expect(rangedCapture.totalLines).toBe(fullCapture.totalLines); + }, 15000); - test("1MB output burst keeps frames decodable and terminal usable afterward", async () => { + test("supports negative line indices", async () => { const cwd = tmpCwd(); + tempDirs.push(cwd); const created = await ctx.client.createTerminal(cwd); const terminalId = created.terminal!.id; - const ws = await connectRawWebSocket(ctx.daemon.port); - try { - await subscribeRawTerminal(ws, terminalId, "sub-large-output"); - await waitForRawBinaryFrame( - ws, - (frame) => frame.opcode === TerminalStreamOpcode.Snapshot, - 10000, - ); - - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: `node -e "process.stdout.write('X'.repeat(${1024 * 1024}))"\r`, - }); - - const frames = await collectRawBinaryFrames( - ws, - (collectedFrames) => { - const outputBytes = collectedFrames - .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) - .reduce((sum, frame) => sum + frame.payload.byteLength, 0); - return ( - outputBytes >= 1024 * 1024 || - collectedFrames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot) - ); - }, - 20000, - ); - - expect(frames.length).toBeGreaterThan(0); - for (const frame of frames) { - expect( - frame.opcode === TerminalStreamOpcode.Output || - frame.opcode === TerminalStreamOpcode.Snapshot, - ).toBe(true); - } - - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo after-burst\r", - }); - const postBurstFrame = await waitForRawBinaryFrame( - ws, - (frame) => getFrameText(frame).includes("after-burst"), - 10000, - ); - expect(getFrameText(postBurstFrame)).toContain("after-burst"); - } finally { - await closeWebSocket(ws); - rmSync(cwd, { recursive: true, force: true }); - } - }, 40000); - - describe("capture", () => { - test("captures visible terminal output as plain text", async () => { - const cwd = tmpCwd(); - tempDirs.push(cwd); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo hello world\r", - }); - await waitForTerminalOutput( - ctx.client, - terminalId, - (text) => text.includes("hello world"), - 15000, - ); - - const capture = await ctx.client.captureTerminal(terminalId); - - expect(capture.lines.join("\n")).toContain("hello world"); - expect(capture.totalLines).toBeGreaterThan(0); - }, 15000); - - test("captures with start/end line range", async () => { - const cwd = tmpCwd(); - tempDirs.push(cwd); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo line1\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line1"), 15000); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo line2\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line2"), 15000); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo line3\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("line3"), 15000); - - const fullCapture = await ctx.client.captureTerminal(terminalId); - const rangedCapture = await ctx.client.captureTerminal(terminalId, { - start: 0, - end: 2, - }); - - expect(rangedCapture.lines).toHaveLength(3); - expect(rangedCapture.totalLines).toBe(fullCapture.totalLines); - }, 15000); - - test("supports negative line indices", async () => { - const cwd = tmpCwd(); - tempDirs.push(cwd); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo alpha\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo beta\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "echo gamma\r", - }); - await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000); - - const capture = await ctx.client.captureTerminal(terminalId, { - start: -3, - }); - - expect(capture.lines).toHaveLength(3); - }, 15000); - - test("strips ANSI by default", async () => { - const cwd = tmpCwd(); - tempDirs.push(cwd); - const created = await ctx.client.createTerminal(cwd); - const terminalId = created.terminal!.id; - - await ctx.client.subscribeTerminal(terminalId); - ctx.client.sendTerminalInput(terminalId, { - type: "input", - data: "printf '\\033[31mred text\\033[0m\\n'\r", - }); - await waitForTerminalOutput( - ctx.client, - terminalId, - (text) => text.includes("red text"), - 15000, - ); - - const capture = await ctx.client.captureTerminal(terminalId); - const capturedText = capture.lines.join("\n"); - - expect(capturedText).toContain("red text"); - expect(capturedText).not.toContain("\u001b[31m"); - }, 15000); - - test("returns empty for non-existent terminal", async () => { - const capture = await ctx.client.captureTerminal("terminal-does-not-exist"); - - expect(capture.lines).toEqual([]); - expect(capture.totalLines).toBe(0); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo alpha\r", }); - }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("alpha"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo beta\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("beta"), 15000); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "echo gamma\r", + }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("gamma"), 15000); - describe("list terminals across directories", () => { - test("lists terminals from all directories when cwd is omitted", async () => { - const cwd1 = tmpCwd(); - const cwd2 = tmpCwd(); - tempDirs.push(cwd1, cwd2); - - const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal"); - const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal"); - - const list = await ctx.client.listTerminals(); - - expect(list).not.toHaveProperty("cwd"); - expect(list.terminals).toEqual( - expect.arrayContaining([ - { - id: firstCreated.terminal!.id, - name: "first-terminal", - }, - { - id: secondCreated.terminal!.id, - name: "second-terminal", - }, - ]), - ); + const capture = await ctx.client.captureTerminal(terminalId, { + start: -3, }); - test("lists terminals for specific directory when cwd is provided", async () => { - const cwd1 = tmpCwd(); - const cwd2 = tmpCwd(); - tempDirs.push(cwd1, cwd2); + expect(capture.lines).toHaveLength(3); + }, 15000); - const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal"); - await ctx.client.createTerminal(cwd2, "cwd-two-terminal"); + test("strips ANSI by default", async () => { + const cwd = tmpCwd(); + tempDirs.push(cwd); + const created = await ctx.client.createTerminal(cwd); + const terminalId = created.terminal!.id; - const list = await ctx.client.listTerminals(cwd1); - - expect(list.cwd).toBe(cwd1); - expect(list.terminals).toEqual([ - { - id: firstCreated.terminal!.id, - name: "cwd-one-terminal", - }, - ]); + await ctx.client.subscribeTerminal(terminalId); + ctx.client.sendTerminalInput(terminalId, { + type: "input", + data: "printf '\\033[31mred text\\033[0m\\n'\r", }); + await waitForTerminalOutput(ctx.client, terminalId, (text) => text.includes("red text"), 15000); + + const capture = await ctx.client.captureTerminal(terminalId); + const capturedText = capture.lines.join("\n"); + + expect(capturedText).toContain("red text"); + expect(capturedText).not.toContain("\u001b[31m"); + }, 15000); + + test("returns empty for non-existent terminal", async () => { + const capture = await ctx.client.captureTerminal("terminal-does-not-exist"); + + expect(capture.lines).toEqual([]); + expect(capture.totalLines).toBe(0); + }); +}); + +describe("list terminals across directories", () => { + test("lists terminals from all directories when cwd is omitted", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + tempDirs.push(cwd1, cwd2); + + const firstCreated = await ctx.client.createTerminal(cwd1, "first-terminal"); + const secondCreated = await ctx.client.createTerminal(cwd2, "second-terminal"); + + const list = await ctx.client.listTerminals(); + + expect(list).not.toHaveProperty("cwd"); + expect(list.terminals).toEqual( + expect.arrayContaining([ + { + id: firstCreated.terminal!.id, + name: "first-terminal", + }, + { + id: secondCreated.terminal!.id, + name: "second-terminal", + }, + ]), + ); + }); + + test("lists terminals for specific directory when cwd is provided", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + tempDirs.push(cwd1, cwd2); + + const firstCreated = await ctx.client.createTerminal(cwd1, "cwd-one-terminal"); + await ctx.client.createTerminal(cwd2, "cwd-two-terminal"); + + const list = await ctx.client.listTerminals(cwd1); + + expect(list.cwd).toBe(cwd1); + expect(list.terminals).toEqual([ + { + id: firstCreated.terminal!.id, + name: "cwd-one-terminal", + }, + ]); }); }); diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index a90f5852f8..abf3225548 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -47,198 +47,196 @@ function isLiveAssistantTimeline( ); } -describe("daemon E2E - timeline reconnect contract", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - }); +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); - afterEach(async () => { - await ctx.cleanup(); - }, 60_000); +afterEach(async () => { + await ctx.cleanup(); +}, 60_000); - test("reconnect catches up committed rows without replaying a provisional seed", async () => { - const cwd = tmpCwd(); - const primaryCollector = createMessageCollector(ctx.client); +test("reconnect catches up committed rows without replaying a provisional seed", async () => { + const cwd = tmpCwd(); + const primaryCollector = createMessageCollector(ctx.client); + + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Reconnect Contract Test", + modeId: "full-access", + }); + + for (let seq = 1; seq <= 120; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: `committed row ${seq}`, + }); + } + const baseline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const epoch = baseline.epoch; + expect(epoch).not.toBe(""); + expect(baseline.endCursor?.epoch).toBe(epoch); + + primaryCollector.clear(); + await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + text: "partial before disconnect", + }); + await waitFor(() => + primaryCollector.messages.some((message) => + isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + ), + ); + + await ctx.client.close(); + + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "finalized while disconnected", + }); + + const reconnectClient = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + }); + await reconnectClient.connect(); + const reconnectCollector = createMessageCollector(reconnectClient); try { - const agent = await ctx.client.createAgent({ - provider: "codex", - cwd, - title: "Reconnect Contract Test", - modeId: "full-access", + await reconnectClient.fetchAgents({ + subscribe: { subscriptionId: "timeline-reconnect-a" }, }); - for (let seq = 1; seq <= 120; seq += 1) { - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { - type: "assistant_message", - text: `committed row ${seq}`, - }); - } - const baseline = await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", + expect( + reconnectCollector.messages.some((message) => + isLiveAssistantTimeline(message, agent.id, epoch), + ), + ).toBe(false); + + const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch, seq: 120 }, limit: 0, projection: "canonical", }); - const epoch = baseline.epoch; - expect(epoch).not.toBe(""); - expect(baseline.endCursor?.epoch).toBe(epoch); - primaryCollector.clear(); - await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { - type: "assistant_message", - text: "partial before disconnect", - }); - await waitFor(() => - primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), - ), - ); - - await ctx.client.close(); - - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + expect(catchUp.epoch).toBe(epoch); + expect(catchUp.reset).toBe(false); + expect(catchUp.staleCursor).toBe(false); + expect(catchUp.gap).toBe(false); + expect(catchUp.entries).toHaveLength(1); + expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); + expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); + expect(catchUp.entries[0]?.seqStart).toBe(121); + expect(catchUp.entries[0]?.seqEnd).toBe(121); + expect(catchUp.entries[0]?.item).toEqual({ type: "assistant_message", text: "finalized while disconnected", }); - - const reconnectClient = new DaemonClient({ - url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, - }); - await reconnectClient.connect(); - const reconnectCollector = createMessageCollector(reconnectClient); - - try { - await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-a" }, - }); - - expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), - ).toBe(false); - - const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { - direction: "after", - cursor: { epoch, seq: 120 }, - limit: 0, - projection: "canonical", - }); - - expect(catchUp.epoch).toBe(epoch); - expect(catchUp.reset).toBe(false); - expect(catchUp.staleCursor).toBe(false); - expect(catchUp.gap).toBe(false); - expect(catchUp.entries).toHaveLength(1); - expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); - expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); - expect(catchUp.entries[0]?.seqStart).toBe(121); - expect(catchUp.entries[0]?.seqEnd).toBe(121); - expect(catchUp.entries[0]?.item).toEqual({ - type: "assistant_message", - text: "finalized while disconnected", - }); - } finally { - reconnectCollector.unsubscribe(); - await reconnectClient.close(); - } } finally { - primaryCollector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); + reconnectCollector.unsubscribe(); + await reconnectClient.close(); } - }, 30_000); + } finally { + primaryCollector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); - test("reconnect with no new committed rows resumes from future live provisional updates only", async () => { - const cwd = tmpCwd(); - const primaryCollector = createMessageCollector(ctx.client); +test("reconnect with no new committed rows resumes from future live provisional updates only", async () => { + const cwd = tmpCwd(); + const primaryCollector = createMessageCollector(ctx.client); + + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Reconnect No Seed Test", + modeId: "full-access", + }); + + for (let seq = 1; seq <= 120; seq += 1) { + await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: `committed row ${seq}`, + }); + } + const baseline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const epoch = baseline.epoch; + expect(epoch).not.toBe(""); + expect(baseline.endCursor?.epoch).toBe(epoch); + + primaryCollector.clear(); + await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + text: "partial before disconnect", + }); + await waitFor(() => + primaryCollector.messages.some((message) => + isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + ), + ); + + await ctx.client.close(); + + const reconnectClient = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + }); + await reconnectClient.connect(); + const reconnectCollector = createMessageCollector(reconnectClient); try { - const agent = await ctx.client.createAgent({ - provider: "codex", - cwd, - title: "Reconnect No Seed Test", - modeId: "full-access", + await reconnectClient.fetchAgents({ + subscribe: { subscriptionId: "timeline-reconnect-b" }, }); - for (let seq = 1; seq <= 120; seq += 1) { - await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { - type: "assistant_message", - text: `committed row ${seq}`, - }); - } - const baseline = await ctx.client.fetchAgentTimeline(agent.id, { - direction: "tail", + expect( + reconnectCollector.messages.some((message) => + isLiveAssistantTimeline(message, agent.id, epoch), + ), + ).toBe(false); + + const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { + direction: "after", + cursor: { epoch, seq: 120 }, limit: 0, projection: "canonical", }); - const epoch = baseline.epoch; - expect(epoch).not.toBe(""); - expect(baseline.endCursor?.epoch).toBe(epoch); - primaryCollector.clear(); + expect(catchUp.epoch).toBe(epoch); + expect(catchUp.reset).toBe(false); + expect(catchUp.staleCursor).toBe(false); + expect(catchUp.gap).toBe(false); + expect(catchUp.entries).toHaveLength(0); + expect(catchUp.startCursor).toBeNull(); + expect(catchUp.endCursor).toBeNull(); + + reconnectCollector.clear(); await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { type: "assistant_message", - text: "partial before disconnect", + text: "fresh live after reconnect", }); await waitFor(() => - primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + reconnectCollector.messages.some((message) => + isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), ), ); - - await ctx.client.close(); - - const reconnectClient = new DaemonClient({ - url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, - }); - await reconnectClient.connect(); - const reconnectCollector = createMessageCollector(reconnectClient); - - try { - await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-b" }, - }); - - expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), - ).toBe(false); - - const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { - direction: "after", - cursor: { epoch, seq: 120 }, - limit: 0, - projection: "canonical", - }); - - expect(catchUp.epoch).toBe(epoch); - expect(catchUp.reset).toBe(false); - expect(catchUp.staleCursor).toBe(false); - expect(catchUp.gap).toBe(false); - expect(catchUp.entries).toHaveLength(0); - expect(catchUp.startCursor).toBeNull(); - expect(catchUp.endCursor).toBeNull(); - - reconnectCollector.clear(); - await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { - type: "assistant_message", - text: "fresh live after reconnect", - }); - await waitFor(() => - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), - ), - ); - } finally { - reconnectCollector.unsubscribe(); - await reconnectClient.close(); - } } finally { - primaryCollector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); + reconnectCollector.unsubscribe(); + await reconnectClient.close(); } - }, 30_000); -}); + } finally { + primaryCollector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30_000); diff --git a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts index 064477df9f..11e81da833 100644 --- a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts @@ -87,301 +87,299 @@ function pathFromToolCallDetail(detail: ToolCallItem["detail"]): string | null { return null; } -describe("daemon E2E", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - }); +beforeEach(async () => { + ctx = await createDaemonTestContext(); +}); - afterEach(async () => { - await ctx.cleanup(); - }, 60000); +afterEach(async () => { + await ctx.cleanup(); +}, 60000); - describe("tool call structure", () => { - // Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version - function extractToolCalls(queue: SessionOutboundMessage[], agentId: string): ToolCallItem[] { - const byCallId = new Map(); - const noCallId: ToolCallItem[] = []; +describe("tool call structure", () => { + // Helper to extract and dedupe tool calls by callId, keeping the last (most complete) version + function extractToolCalls(queue: SessionOutboundMessage[], agentId: string): ToolCallItem[] { + const byCallId = new Map(); + const noCallId: ToolCallItem[] = []; - for (const m of queue) { - if ( - m.type === "agent_stream" && - m.payload.agentId === agentId && - m.payload.event.type === "timeline" && - m.payload.event.item.type === "tool_call" - ) { - const tc = m.payload.event.item; - if (tc.callId) { - byCallId.set(tc.callId, tc); - } else { - noCallId.push(tc); - } + for (const m of queue) { + if ( + m.type === "agent_stream" && + m.payload.agentId === agentId && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "tool_call" + ) { + const tc = m.payload.event.item; + if (tc.callId) { + byCallId.set(tc.callId, tc); + } else { + noCallId.push(tc); } } - - return [...byCallId.values(), ...noCallId]; } - // Helper to log tool call structure in a consistent format - function logToolCall(prefix: string, tc: ToolCallItem): void { - void prefix; - void tc; + return [...byCallId.values(), ...noCallId]; + } + + // Helper to log tool call structure in a consistent format + function logToolCall(prefix: string, tc: ToolCallItem): void { + void prefix; + void tc; + } + + test("Claude agent: Read tool", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); + + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Claude Read Test", + modeId: "bypassPermissions", + }); + + collector.clear(); + + await ctx.client.sendMessage( + agent.id, + "Read the file /etc/hosts and tell me how many lines it has. Be brief.", + ); + + await ctx.client.waitForFinish(agent.id, 120000); + + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); + + for (const tc of toolCalls) { + logToolCall("CLAUDE_READ", tc); } - test("Claude agent: Read tool", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); + const readCall = toolCalls.find((tc) => tc.name === "Read"); + expect(readCall).toBeDefined(); + expect(readCall?.name).toBe("Read"); + expect(readCall?.detail).toBeDefined(); + const readPath = readCall ? pathFromToolCallDetail(readCall.detail) : null; + expect(readPath).toBeTruthy(); + if (readPath) { + expect(readPath).toContain("/etc/hosts"); + } - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Claude Read Test", - modeId: "bypassPermissions", - }); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); - collector.clear(); + test("Claude agent: Bash tool", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); - await ctx.client.sendMessage( - agent.id, - "Read the file /etc/hosts and tell me how many lines it has. Be brief.", - ); + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Claude Bash Test", + modeId: "bypassPermissions", + }); - await ctx.client.waitForFinish(agent.id, 120000); + collector.clear(); - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); + await ctx.client.sendMessage( + agent.id, + "Run `echo hello` and tell me what it outputs. Be brief.", + ); - for (const tc of toolCalls) { - logToolCall("CLAUDE_READ", tc); - } + await ctx.client.waitForFinish(agent.id, 120000); - const readCall = toolCalls.find((tc) => tc.name === "Read"); - expect(readCall).toBeDefined(); - expect(readCall?.name).toBe("Read"); - expect(readCall?.detail).toBeDefined(); - const readPath = readCall ? pathFromToolCallDetail(readCall.detail) : null; - expect(readPath).toBeTruthy(); - if (readPath) { - expect(readPath).toContain("/etc/hosts"); - } + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); + for (const tc of toolCalls) { + logToolCall("CLAUDE_BASH", tc); + } - test("Claude agent: Bash tool", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); + const bashCall = toolCalls.find((tc) => tc.name === "Bash"); + expect(bashCall).toBeDefined(); + expect(bashCall?.name).toBe("Bash"); + expect(bashCall?.detail).toBeDefined(); + const command = bashCall ? commandFromToolCallDetail(bashCall.detail) : null; + expect(command).toBeTruthy(); + if (command) { + expect(command).toContain("echo"); + } - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Claude Bash Test", - modeId: "bypassPermissions", - }); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); - collector.clear(); + test("Claude agent: Edit tool", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "hello world\n"); - await ctx.client.sendMessage( - agent.id, - "Run `echo hello` and tell me what it outputs. Be brief.", - ); + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Claude Edit Test", + modeId: "bypassPermissions", + }); - await ctx.client.waitForFinish(agent.id, 120000); + collector.clear(); - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); + await ctx.client.sendMessage( + agent.id, + `Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`, + ); - for (const tc of toolCalls) { - logToolCall("CLAUDE_BASH", tc); - } + await ctx.client.waitForFinish(agent.id, 120000); - const bashCall = toolCalls.find((tc) => tc.name === "Bash"); - expect(bashCall).toBeDefined(); - expect(bashCall?.name).toBe("Bash"); - expect(bashCall?.detail).toBeDefined(); - const command = bashCall ? commandFromToolCallDetail(bashCall.detail) : null; - expect(command).toBeTruthy(); - if (command) { - expect(command).toContain("echo"); - } + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); + for (const tc of toolCalls) { + logToolCall("CLAUDE_EDIT", tc); + } - test("Claude agent: Edit tool", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "hello world\n"); + const editCall = toolCalls.find((tc) => tc.name === "Edit"); + expect(editCall).toBeDefined(); + expect(editCall?.detail).toBeDefined(); + const editPath = editCall ? pathFromToolCallDetail(editCall.detail) : null; + if (editPath) { + expect(editPath).toBe(testFile); + } else if (editCall?.detail.type === "unknown") { + expect(editCall.detail.input ?? editCall.detail.output).toBeTruthy(); + } else { + expect(editCall?.detail.type).toBe("edit"); + } - const agent = await ctx.client.createAgent({ - provider: "claude", - cwd, - title: "Claude Edit Test", - modeId: "bypassPermissions", - }); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); - collector.clear(); + test("Codex agent: shell command", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); - await ctx.client.sendMessage( - agent.id, - `Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`, - ); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Shell Test", + modeId: "full-access", + }); - await ctx.client.waitForFinish(agent.id, 120000); + collector.clear(); - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); + await ctx.client.sendMessage( + agent.id, + "Run `echo hello` and tell me what it outputs. Be brief.", + ); - for (const tc of toolCalls) { - logToolCall("CLAUDE_EDIT", tc); - } + await ctx.client.waitForFinish(agent.id, 120000); - const editCall = toolCalls.find((tc) => tc.name === "Edit"); - expect(editCall).toBeDefined(); - expect(editCall?.detail).toBeDefined(); - const editPath = editCall ? pathFromToolCallDetail(editCall.detail) : null; - if (editPath) { - expect(editPath).toBe(testFile); - } else if (editCall?.detail.type === "unknown") { - expect(editCall.detail.input ?? editCall.detail.output).toBeTruthy(); - } else { - expect(editCall?.detail.type).toBe("edit"); - } + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); + for (const tc of toolCalls) { + logToolCall("CODEX_SHELL", tc); + } - test("Codex agent: shell command", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); + const shellCalls = toolCalls.filter((tc) => tc.name === "shell"); + expect(shellCalls.length).toBeGreaterThan(0); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Shell Test", - modeId: "full-access", - }); + const echoCall = shellCalls.find((tc) => { + const command = commandFromToolCallDetail(tc.detail); + return typeof command === "string" && command.includes("echo"); + }); + expect(echoCall).toBeDefined(); - collector.clear(); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); - await ctx.client.sendMessage( - agent.id, - "Run `echo hello` and tell me what it outputs. Be brief.", - ); + test("Codex agent: file read", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); - await ctx.client.waitForFinish(agent.id, 120000); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Read Test", + modeId: "full-access", + }); - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); + collector.clear(); - for (const tc of toolCalls) { - logToolCall("CODEX_SHELL", tc); - } + await ctx.client.sendMessage( + agent.id, + "Read the file /etc/hosts and tell me how many lines it has. Be brief.", + ); - const shellCalls = toolCalls.filter((tc) => tc.name === "shell"); - expect(shellCalls.length).toBeGreaterThan(0); + await ctx.client.waitForFinish(agent.id, 120000); - const echoCall = shellCalls.find((tc) => { - const command = commandFromToolCallDetail(tc.detail); - return typeof command === "string" && command.includes("echo"); - }); - expect(echoCall).toBeDefined(); + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); + for (const tc of toolCalls) { + logToolCall("CODEX_READ", tc); + } - test("Codex agent: file read", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); + // Codex may use shell cat or file read + const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "read_file"); + if (readCall) { + expect(readCall.name).toBe("read_file"); + } - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Read Test", - modeId: "full-access", - }); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); - collector.clear(); + test("Codex agent: file edit", async () => { + const cwd = tmpCwd(); + const collector = createMessageCollector(ctx.client); + const testFile = path.join(cwd, "test.txt"); + writeFileSync(testFile, "hello world\n"); - await ctx.client.sendMessage( - agent.id, - "Read the file /etc/hosts and tell me how many lines it has. Be brief.", - ); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Codex Edit Test", + modeId: "full-access", + }); - await ctx.client.waitForFinish(agent.id, 120000); + collector.clear(); - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); + await ctx.client.sendMessage( + agent.id, + `Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`, + ); - for (const tc of toolCalls) { - logToolCall("CODEX_READ", tc); - } + await ctx.client.waitForFinish(agent.id, 120000); - // Codex may use shell cat or file read - const readCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "read_file"); - if (readCall) { - expect(readCall.name).toBe("read_file"); - } + const toolCalls = extractToolCalls(collector.messages, agent.id); + expect(toolCalls.length).toBeGreaterThan(0); - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); + for (const tc of toolCalls) { + logToolCall("CODEX_EDIT", tc); + } - test("Codex agent: file edit", async () => { - const cwd = tmpCwd(); - const collector = createMessageCollector(ctx.client); - const testFile = path.join(cwd, "test.txt"); - writeFileSync(testFile, "hello world\n"); + // Codex uses apply_patch for edits + const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "apply_patch"); + if (editCall) { + expect(editCall.name).toBe("apply_patch"); + } - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Codex Edit Test", - modeId: "full-access", - }); - - collector.clear(); - - await ctx.client.sendMessage( - agent.id, - `Edit the file ${testFile} and change "hello" to "goodbye". Be brief.`, - ); - - await ctx.client.waitForFinish(agent.id, 120000); - - const toolCalls = extractToolCalls(collector.messages, agent.id); - expect(toolCalls.length).toBeGreaterThan(0); - - for (const tc of toolCalls) { - logToolCall("CODEX_EDIT", tc); - } - - // Codex uses apply_patch for edits - const editCall = toolCalls.find((tc) => tc.type === "tool_call" && tc.name === "apply_patch"); - if (editCall) { - expect(editCall.name).toBe("apply_patch"); - } - - await ctx.client.deleteAgent(agent.id); - collector.unsubscribe(); - rmSync(cwd, { recursive: true, force: true }); - }, 180000); - }); + await ctx.client.deleteAgent(agent.id); + collector.unsubscribe(); + rmSync(cwd, { recursive: true, force: true }); + }, 180000); }); diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts index f0b84d9560..886aecb558 100644 --- a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -15,215 +15,213 @@ function tmpCwd(): string { * Tests for waitForFinish edge cases. * Uses haiku for speed. Allow higher timeouts in CI / congested environments. */ -describe("waitForFinish edge cases", () => { - let ctx: DaemonTestContext; - let collector: MessageCollector; +let ctx: DaemonTestContext; +let collector: MessageCollector; - beforeEach(async () => { - ctx = await createDaemonTestContext(); - collector = createMessageCollector(ctx.client); +beforeEach(async () => { + ctx = await createDaemonTestContext(); + collector = createMessageCollector(ctx.client); +}); + +afterEach(async () => { + collector.unsubscribe(); + await ctx.cleanup(); +}, 30000); + +test("waitForFinish immediately after sendMessage", async () => { + const cwd = tmpCwd(); + + const agent = await ctx.client.createAgent({ + provider: "claude", + model: "haiku", + cwd, + title: "Immediate Wait Test", + modeId: "bypassPermissions", }); - afterEach(async () => { - collector.unsubscribe(); - await ctx.cleanup(); - }, 30000); + // This was the original bug: waitForFinish returned old idle states + collector.clear(); + await ctx.client.sendMessage(agent.id, "Say 'hello'"); + const state = await ctx.client.waitForFinish(agent.id, 30000); - test("waitForFinish immediately after sendMessage", async () => { - const cwd = tmpCwd(); + expect(state.status).toBe("idle"); - const agent = await ctx.client.createAgent({ - provider: "claude", - model: "haiku", - cwd, - title: "Immediate Wait Test", - modeId: "bypassPermissions", - }); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 45000); - // This was the original bug: waitForFinish returned old idle states - collector.clear(); - await ctx.client.sendMessage(agent.id, "Say 'hello'"); +test("rapid fire messages then single wait", async () => { + const cwd = tmpCwd(); + + const agent = await ctx.client.createAgent({ + provider: "claude", + model: "haiku", + cwd, + title: "Rapid Fire Test", + modeId: "bypassPermissions", + }); + + // Send 3 messages without waiting - tests that waitForFinish + // finds the idle AFTER the last running state + collector.clear(); + await ctx.client.sendMessage(agent.id, "Say 'one'"); + await ctx.client.sendMessage(agent.id, "Say 'two'"); + await ctx.client.sendMessage(agent.id, "Say 'three'"); + + const state = await ctx.client.waitForFinish(agent.id, 30000); + expect(state.status).toBe("idle"); + + // Verify all 3 messages were recorded + const userMessages = collector.messages.filter( + (m) => + m.type === "agent_stream" && + m.payload.agentId === agent.id && + m.payload.event.type === "timeline" && + m.payload.event.item.type === "user_message", + ); + expect(userMessages.length).toBe(3); + + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); +}, 45000); + +test("two agents: waitForFinish filters by agent", async () => { + const cwd1 = tmpCwd(); + const cwd2 = tmpCwd(); + + const agent1 = await ctx.client.createAgent({ + provider: "claude", + model: "haiku", + cwd: cwd1, + title: "Agent 1", + modeId: "bypassPermissions", + }); + + const agent2 = await ctx.client.createAgent({ + provider: "claude", + model: "haiku", + cwd: cwd2, + title: "Agent 2", + modeId: "bypassPermissions", + }); + + // Start both agents + collector.clear(); + await ctx.client.sendMessage(agent1.id, "Say 'agent one'"); + await ctx.client.sendMessage(agent2.id, "Say 'agent two'"); + + // Wait for each - should not be confused by the other's state + const state2 = await ctx.client.waitForFinish(agent2.id, 30000); + expect(state2.status).toBe("idle"); + expect(state2.final?.id).toBe(agent2.id); + + const state1 = await ctx.client.waitForFinish(agent1.id, 30000); + expect(state1.status).toBe("idle"); + expect(state1.final?.id).toBe(agent1.id); + + await ctx.client.deleteAgent(agent1.id); + await ctx.client.deleteAgent(agent2.id); + rmSync(cwd1, { recursive: true, force: true }); + rmSync(cwd2, { recursive: true, force: true }); +}, 60000); + +test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => { + const cwd = tmpCwd(); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + + await secondary.connect(); + await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } }); + + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Idle Edge Coherence", + modeId: "bypassPermissions", + }); + + let sawRunning = false; + let spawnedSecondRun = false; + let secondRunDrain: Promise | null = null; + const unsubscribe = ctx.daemon.daemon.agentManager.subscribe( + (event) => { + if (event.type !== "agent_state" || event.agent.id !== agent.id) { + return; + } + if (event.agent.lifecycle === "running") { + sawRunning = true; + return; + } + if (event.agent.lifecycle !== "idle" || !sawRunning || spawnedSecondRun) { + return; + } + + spawnedSecondRun = true; + secondRunDrain = (async () => { + await secondary.sendMessage(agent.id, "Reply with exactly: done."); + await secondary.waitForFinish(agent.id, 5_000); + })(); + }, + { agentId: agent.id, replayState: false }, + ); + + try { + await ctx.client.sendMessage(agent.id, "Reply with exactly: first"); const state = await ctx.client.waitForFinish(agent.id, 30000); + expect(spawnedSecondRun).toBe(true); expect(state.status).toBe("idle"); - + expect(state.final?.status === "idle" || state.final?.status === "running").toBe(true); + expect(state.error).toBeNull(); + } finally { + unsubscribe(); + await secondRunDrain; + await secondary.close(); await ctx.client.deleteAgent(agent.id); rmSync(cwd, { recursive: true, force: true }); - }, 45000); + } +}, 60000); - test("rapid fire messages then single wait", async () => { - const cwd = tmpCwd(); +test("waitForFinish stays blocked when sendMessage transactionally replaces a running turn", async () => { + const cwd = tmpCwd(); + const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); - const agent = await ctx.client.createAgent({ - provider: "claude", - model: "haiku", - cwd, - title: "Rapid Fire Test", - modeId: "bypassPermissions", - }); - - // Send 3 messages without waiting - tests that waitForFinish - // finds the idle AFTER the last running state - collector.clear(); - await ctx.client.sendMessage(agent.id, "Say 'one'"); - await ctx.client.sendMessage(agent.id, "Say 'two'"); - await ctx.client.sendMessage(agent.id, "Say 'three'"); - - const state = await ctx.client.waitForFinish(agent.id, 30000); - expect(state.status).toBe("idle"); - - // Verify all 3 messages were recorded - const userMessages = collector.messages.filter( - (m) => - m.type === "agent_stream" && - m.payload.agentId === agent.id && - m.payload.event.type === "timeline" && - m.payload.event.item.type === "user_message", - ); - expect(userMessages.length).toBe(3); - - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - }, 45000); - - test("two agents: waitForFinish filters by agent", async () => { - const cwd1 = tmpCwd(); - const cwd2 = tmpCwd(); - - const agent1 = await ctx.client.createAgent({ - provider: "claude", - model: "haiku", - cwd: cwd1, - title: "Agent 1", - modeId: "bypassPermissions", - }); - - const agent2 = await ctx.client.createAgent({ - provider: "claude", - model: "haiku", - cwd: cwd2, - title: "Agent 2", - modeId: "bypassPermissions", - }); - - // Start both agents - collector.clear(); - await ctx.client.sendMessage(agent1.id, "Say 'agent one'"); - await ctx.client.sendMessage(agent2.id, "Say 'agent two'"); - - // Wait for each - should not be confused by the other's state - const state2 = await ctx.client.waitForFinish(agent2.id, 30000); - expect(state2.status).toBe("idle"); - expect(state2.final?.id).toBe(agent2.id); - - const state1 = await ctx.client.waitForFinish(agent1.id, 30000); - expect(state1.status).toBe("idle"); - expect(state1.final?.id).toBe(agent1.id); - - await ctx.client.deleteAgent(agent1.id); - await ctx.client.deleteAgent(agent2.id); - rmSync(cwd1, { recursive: true, force: true }); - rmSync(cwd2, { recursive: true, force: true }); - }, 60000); - - test("waitForFinish resolves first idle edge even if a new run starts immediately after", async () => { - const cwd = tmpCwd(); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + const agent = await ctx.client.createAgent({ + ...getFullAccessConfig("codex"), + cwd, + title: "Transactional Replacement Wait", + }); + try { await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } }); + await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); - const agent = await ctx.client.createAgent({ - provider: "codex", - cwd, - title: "Idle Edge Coherence", - modeId: "bypassPermissions", - }); - - let sawRunning = false; - let spawnedSecondRun = false; - let secondRunDrain: Promise | null = null; - const unsubscribe = ctx.daemon.daemon.agentManager.subscribe( - (event) => { - if (event.type !== "agent_state" || event.agent.id !== agent.id) { - return; - } - if (event.agent.lifecycle === "running") { - sawRunning = true; - return; - } - if (event.agent.lifecycle !== "idle" || !sawRunning || spawnedSecondRun) { - return; - } - - spawnedSecondRun = true; - secondRunDrain = (async () => { - await secondary.sendMessage(agent.id, "Reply with exactly: done."); - await secondary.waitForFinish(agent.id, 5_000); - })(); - }, - { agentId: agent.id, replayState: false }, + await ctx.client.sendMessage(agent.id, "Run: sleep 5"); + await ctx.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000, ); - try { - await ctx.client.sendMessage(agent.id, "Reply with exactly: first"); - const state = await ctx.client.waitForFinish(agent.id, 30000); + const waitPromise = ctx.client.waitForFinish(agent.id, 5_000); - expect(spawnedSecondRun).toBe(true); - expect(state.status).toBe("idle"); - expect(state.final?.status === "idle" || state.final?.status === "running").toBe(true); - expect(state.error).toBeNull(); - } finally { - unsubscribe(); - await secondRunDrain; - await secondary.close(); - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - } - }, 60000); + await secondary.sendMessage(agent.id, "Run: sleep 5"); + await secondary.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "running", + 5_000, + ); - test("waitForFinish stays blocked when sendMessage transactionally replaces a running turn", async () => { - const cwd = tmpCwd(); - const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + const prematureResolution = await Promise.race([ + waitPromise.then(() => "resolved"), + new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 100)), + ]); + expect(prematureResolution).toBe("pending"); - const agent = await ctx.client.createAgent({ - ...getFullAccessConfig("codex"), - cwd, - title: "Transactional Replacement Wait", - }); - - try { - await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); - - await ctx.client.sendMessage(agent.id, "Run: sleep 5"); - await ctx.client.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 5_000, - ); - - const waitPromise = ctx.client.waitForFinish(agent.id, 5_000); - - await secondary.sendMessage(agent.id, "Run: sleep 5"); - await secondary.waitForAgentUpsert( - agent.id, - (snapshot) => snapshot.status === "running", - 5_000, - ); - - const prematureResolution = await Promise.race([ - waitPromise.then(() => "resolved"), - new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 100)), - ]); - expect(prematureResolution).toBe("pending"); - - const finalState = await waitPromise; - expect(finalState.status).toBe("idle"); - } finally { - await secondary.close(); - await ctx.client.deleteAgent(agent.id); - rmSync(cwd, { recursive: true, force: true }); - } - }, 30000); -}); + const finalState = await waitPromise; + expect(finalState.status).toBe("idle"); + } finally { + await secondary.close(); + await ctx.client.deleteAgent(agent.id); + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 58ce645d66..eadee37377 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -11,129 +11,127 @@ import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspa import { createPaseoWorktree, type CreatePaseoWorktreeDeps } from "./paseo-worktree-service.js"; import { createWorktreeCoreDeps } from "./worktree-core.js"; -describe("createPaseoWorktree", () => { - const cleanupPaths: string[] = []; +const cleanupPaths: string[] = []; - afterEach(() => { - for (const target of cleanupPaths.splice(0)) { - rmSync(target, { recursive: true, force: true }); - } +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + rmSync(target, { recursive: true, force: true }); + } +}); + +test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const events: string[] = []; + const deps = createDeps({ events }); + const sourceProject = createPersistedProjectRecordForTest({ + projectId: "remote:github.com/acme/repo", + rootPath: repoDir, + displayName: "acme/repo", + }); + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: repoDir, + projectId: sourceProject.projectId, + cwd: repoDir, + kind: "local_checkout", + displayName: "main", + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "feature-one", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.created).toBe(true); + expect(result.workspace.cwd).toBe(result.worktree.worktreePath); + expect(result.workspace.kind).toBe("worktree"); + expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); + expect(result.workspace.displayName).toBe("feature-one"); + expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(events).toEqual([ + "project:remote:github.com/acme/repo", + `workspace:${result.workspace.workspaceId}`, + ]); +}); + +test("reuses an existing worktree and still upserts the workspace", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const firstDeps = createDeps(); + const first = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "reuse-me", + runSetup: false, + paseoHome, + }, + firstDeps, + ); + const events: string[] = []; + const deps = createDeps({ + events, + projects: firstDeps.projects, + workspaces: firstDeps.workspaces, }); - test("creates a worktree and registers it in the source workspace project without git snapshot lookup", async () => { - const { repoDir, tempDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const events: string[] = []; - const deps = createDeps({ events }); - const sourceProject = createPersistedProjectRecordForTest({ - projectId: "remote:github.com/acme/repo", - rootPath: repoDir, - displayName: "acme/repo", - }); - const sourceWorkspace = createPersistedWorkspaceRecordForTest({ - workspaceId: repoDir, - projectId: sourceProject.projectId, + const second = await createPaseoWorktree( + { cwd: repoDir, - kind: "local_checkout", - displayName: "main", - }); - deps.projects.set(sourceProject.projectId, sourceProject); - deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); - deps.workspaceGitService.getSnapshot = vi.fn(deps.workspaceGitService.getSnapshot); + worktreeSlug: "reuse-me", + runSetup: false, + paseoHome, + }, + deps, + ); - const result = await createPaseoWorktree( + expect(second.created).toBe(false); + expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); + expect(events).toContain(`workspace:${second.workspace.workspaceId}`); +}); + +test("does not mutate registries or broadcast when core worktree creation fails", async () => { + const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); + cleanupPaths.push(tempDir); + const deps = createDeps(); + + await expect( + createPaseoWorktree( { - cwd: repoDir, - worktreeSlug: "feature-one", + cwd: tempDir, + worktreeSlug: "not-git", runSetup: false, paseoHome: path.join(tempDir, ".paseo"), }, deps, - ); + ), + ).rejects.toThrow("Create worktree requires a git repository"); - expect(result.created).toBe(true); - expect(result.workspace.cwd).toBe(result.worktree.worktreePath); - expect(result.workspace.kind).toBe("worktree"); - expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); - expect(result.workspace.displayName).toBe("feature-one"); - expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(events).toEqual([ - "project:remote:github.com/acme/repo", - `workspace:${result.workspace.workspaceId}`, - ]); + expect(deps.projects.size).toBe(0); + expect(deps.workspaces.size).toBe(0); +}); + +test("keeps direct core worktree creation calls behind the service boundary", () => { + // Keep this literal in the test file so the grep invariant sees createWorktreeCore( here. + const serverSrc = path.dirname(fileURLToPath(import.meta.url)); + const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => { + if (path.basename(filePath) === "worktree-core.ts") { + return []; + } + const contents = readFileSync(filePath, "utf8"); + const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g"); + return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath)); }); - test("reuses an existing worktree and still upserts the workspace", async () => { - const { repoDir, tempDir } = createGitRepo(); - cleanupPaths.push(tempDir); - const paseoHome = path.join(tempDir, ".paseo"); - const firstDeps = createDeps(); - const first = await createPaseoWorktree( - { - cwd: repoDir, - worktreeSlug: "reuse-me", - runSetup: false, - paseoHome, - }, - firstDeps, - ); - const events: string[] = []; - const deps = createDeps({ - events, - projects: firstDeps.projects, - workspaces: firstDeps.workspaces, - }); - - const second = await createPaseoWorktree( - { - cwd: repoDir, - worktreeSlug: "reuse-me", - runSetup: false, - paseoHome, - }, - deps, - ); - - expect(second.created).toBe(false); - expect(second.worktree.worktreePath).toBe(first.worktree.worktreePath); - expect(events).toContain(`workspace:${second.workspace.workspaceId}`); - }); - - test("does not mutate registries or broadcast when core worktree creation fails", async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-")); - cleanupPaths.push(tempDir); - const deps = createDeps(); - - await expect( - createPaseoWorktree( - { - cwd: tempDir, - worktreeSlug: "not-git", - runSetup: false, - paseoHome: path.join(tempDir, ".paseo"), - }, - deps, - ), - ).rejects.toThrow("Create worktree requires a git repository"); - - expect(deps.projects.size).toBe(0); - expect(deps.workspaces.size).toBe(0); - }); - - test("keeps direct core worktree creation calls behind the service boundary", () => { - // Keep this literal in the test file so the grep invariant sees createWorktreeCore( here. - const serverSrc = path.dirname(fileURLToPath(import.meta.url)); - const matches = listTypeScriptFiles(serverSrc).flatMap((filePath) => { - if (path.basename(filePath) === "worktree-core.ts") { - return []; - } - const contents = readFileSync(filePath, "utf8"); - const pattern = new RegExp(["createWorktreeCore", "\\("].join(""), "g"); - return Array.from(contents.matchAll(pattern), () => path.relative(serverSrc, filePath)); - }); - - expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]); - }); + expect(matches).toEqual(["paseo-worktree-service.test.ts", "paseo-worktree-service.ts"]); }); interface TestDeps extends CreatePaseoWorktreeDeps { diff --git a/packages/server/src/server/script-proxy.test.ts b/packages/server/src/server/script-proxy.test.ts index f75de18571..62c53575a9 100644 --- a/packages/server/src/server/script-proxy.test.ts +++ b/packages/server/src/server/script-proxy.test.ts @@ -27,466 +27,458 @@ function closeServer(server: http.Server): Promise { // ScriptRouteStore // --------------------------------------------------------------------------- -describe("ScriptRouteStore", () => { - it("registerRoute and findRoute with exact match", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "route-a.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); - - const route = store.findRoute("route-a.example.localhost"); - expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); +it("registerRoute and findRoute with exact match", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "route-a.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", }); - it("findRoute strips port from host header", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "route-a.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); + const route = store.findRoute("route-a.example.localhost"); + expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); +}); - const route = store.findRoute("route-a.example.localhost:6767"); - expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); +it("findRoute strips port from host header", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "route-a.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", }); - it("findRoute subdomain match", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "editor.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); + const route = store.findRoute("route-a.example.localhost:6767"); + expect(route).toEqual({ hostname: "route-a.example.localhost", port: 3000 }); +}); - const route = store.findRoute("tenant.editor.example.localhost"); - expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 }); +it("findRoute subdomain match", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "editor.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", }); - it("listRoutes returns enriched entries", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ + const route = store.findRoute("tenant.editor.example.localhost"); + expect(route).toEqual({ hostname: "editor.example.localhost", port: 3000 }); +}); + +it("listRoutes returns enriched entries", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", + scriptName: "docs", + }); + + const routes = store.listRoutes(); + expect(routes).toHaveLength(2); + expect(routes).toContainEqual({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "web", + }); + expect(routes).toContainEqual({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", + scriptName: "docs", + }); +}); + +it("listRoutesForWorkspace returns only routes for that workspace", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", + scriptName: "docs", + }); + store.registerRoute({ + hostname: "c.localhost", + port: 5000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "api", + }); + + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([ + { hostname: "a.localhost", port: 3000, workspaceId: "/repo/.paseo/worktrees/feature-a", projectSlug: "repo", scriptName: "web", - }); - store.registerRoute({ - hostname: "b.localhost", - port: 4000, - workspaceId: "/repo/.paseo/worktrees/feature-b", - projectSlug: "repo", - scriptName: "docs", - }); - - const routes = store.listRoutes(); - expect(routes).toHaveLength(2); - expect(routes).toContainEqual({ - hostname: "a.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "web", - }); - expect(routes).toContainEqual({ - hostname: "b.localhost", - port: 4000, - workspaceId: "/repo/.paseo/worktrees/feature-b", - projectSlug: "repo", - scriptName: "docs", - }); - }); - - it("listRoutesForWorkspace returns only routes for that workspace", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "a.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "web", - }); - store.registerRoute({ - hostname: "b.localhost", - port: 4000, - workspaceId: "/repo/.paseo/worktrees/feature-b", - projectSlug: "repo", - scriptName: "docs", - }); - store.registerRoute({ + }, + { hostname: "c.localhost", port: 5000, workspaceId: "/repo/.paseo/worktrees/feature-a", projectSlug: "repo", scriptName: "api", - }); + }, + ]); +}); - expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([ - { - hostname: "a.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "web", - }, - { - hostname: "c.localhost", - port: 5000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "api", - }, - ]); +it("removeRoute works", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "route-a.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", + }); + store.removeRoute("route-a.example.localhost"); + + expect(store.findRoute("route-a.example.localhost")).toBeNull(); +}); + +it("removeRoute cleans up workspace index", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "route-a.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", }); - it("removeRoute works", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "route-a.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); - store.removeRoute("route-a.example.localhost"); + store.removeRoute("route-a.example.localhost"); - expect(store.findRoute("route-a.example.localhost")).toBeNull(); + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); +}); + +it("removeRoutesForPort works", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "api", + }); + store.registerRoute({ + hostname: "c.localhost", + port: 4000, + workspaceId: "/repo/.paseo/worktrees/feature-b", + projectSlug: "repo", + scriptName: "docs", }); - it("removeRoute cleans up workspace index", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "route-a.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); + store.removeRoutesForPort(3000); - store.removeRoute("route-a.example.localhost"); + expect(store.findRoute("a.localhost")).toBeNull(); + expect(store.findRoute("b.localhost")).toBeNull(); + expect(store.findRoute("c.localhost")).toEqual({ + hostname: "c.localhost", + port: 4000, + }); +}); - expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); +it("removeRoutesForPort cleans up workspace index", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "a.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "web", + }); + store.registerRoute({ + hostname: "b.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "api", }); - it("removeRoutesForPort works", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "a.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "web", - }); - store.registerRoute({ - hostname: "b.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "api", - }); - store.registerRoute({ - hostname: "c.localhost", - port: 4000, - workspaceId: "/repo/.paseo/worktrees/feature-b", - projectSlug: "repo", - scriptName: "docs", - }); + store.removeRoutesForPort(3000); - store.removeRoutesForPort(3000); + expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); +}); - expect(store.findRoute("a.localhost")).toBeNull(); - expect(store.findRoute("b.localhost")).toBeNull(); - expect(store.findRoute("c.localhost")).toEqual({ - hostname: "c.localhost", - port: 4000, - }); +it("findRoute returns null for unknown hosts", () => { + const store = new ScriptRouteStore(); + store.registerRoute({ + hostname: "route-a.example.localhost", + port: 3000, + workspaceId: "/repo/.paseo/worktrees/feature-a", + projectSlug: "repo", + scriptName: "editor", }); - it("removeRoutesForPort cleans up workspace index", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "a.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "web", - }); - store.registerRoute({ - hostname: "b.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "api", - }); - - store.removeRoutesForPort(3000); - - expect(store.listRoutesForWorkspace("/repo/.paseo/worktrees/feature-a")).toEqual([]); - }); - - it("findRoute returns null for unknown hosts", () => { - const store = new ScriptRouteStore(); - store.registerRoute({ - hostname: "route-a.example.localhost", - port: 3000, - workspaceId: "/repo/.paseo/worktrees/feature-a", - projectSlug: "repo", - scriptName: "editor", - }); - - expect(store.findRoute("unknown.example.com")).toBeNull(); - }); + expect(store.findRoute("unknown.example.com")).toBeNull(); }); // --------------------------------------------------------------------------- // HTTP proxy // --------------------------------------------------------------------------- -describe("HTTP proxy", () => { - const servers: http.Server[] = []; +const servers: http.Server[] = []; - afterEach(async () => { - await Promise.all(servers.map(closeServer)); - servers.length = 0; +afterEach(async () => { + await Promise.all(servers.map(closeServer)); + servers.length = 0; +}); + +/** Start a real HTTP server that echoes back a known body and records received headers. */ +async function startUpstream(): Promise<{ + port: number; + server: http.Server; + receivedHeaders: () => http.IncomingHttpHeaders; +}> { + const port = await findFreePort(); + let lastHeaders: http.IncomingHttpHeaders = {}; + + const server = http.createServer((req, res) => { + lastHeaders = req.headers; + res.writeHead(200, { "content-type": "text/plain" }); + res.end("upstream-ok"); }); - /** Start a real HTTP server that echoes back a known body and records received headers. */ - async function startUpstream(): Promise<{ - port: number; - server: http.Server; - receivedHeaders: () => http.IncomingHttpHeaders; - }> { - const port = await findFreePort(); - let lastHeaders: http.IncomingHttpHeaders = {}; + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + servers.push(server); - const server = http.createServer((req, res) => { - lastHeaders = req.headers; - res.writeHead(200, { "content-type": "text/plain" }); - res.end("upstream-ok"); - }); + return { + port, + server, + receivedHeaders: () => lastHeaders, + }; +} - await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); - servers.push(server); +/** Start an Express app with the service proxy middleware and an optional fallback. */ +async function startProxy( + routeStore: ScriptRouteStore, + opts?: { fallback?: boolean }, +): Promise<{ port: number; server: http.Server }> { + const port = await findFreePort(); + const app = express(); + app.use(createScriptProxyMiddleware({ routeStore, logger })); - return { - port, - server, - receivedHeaders: () => lastHeaders, - }; - } - - /** Start an Express app with the service proxy middleware and an optional fallback. */ - async function startProxy( - routeStore: ScriptRouteStore, - opts?: { fallback?: boolean }, - ): Promise<{ port: number; server: http.Server }> { - const port = await findFreePort(); - const app = express(); - app.use(createScriptProxyMiddleware({ routeStore, logger })); - - if (opts?.fallback) { - app.use((_req, res) => { - res.status(404).send("no route"); - }); - } - - const server = http.createServer(app); - await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); - servers.push(server); - - return { port, server }; - } - - /** Simple HTTP GET helper that returns status code and body. */ - function httpGet( - port: number, - host: string, - path = "/", - ): Promise<{ status: number; body: string }> { - return new Promise((resolve, reject) => { - const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => { - let body = ""; - res.on("data", (chunk: Buffer) => (body += chunk.toString())); - res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); - }); - req.on("error", reject); + if (opts?.fallback) { + app.use((_req, res) => { + res.status(404).send("no route"); }); } - it("proxies requests to the correct upstream based on Host header", async () => { - const upstream = await startUpstream(); - const routeStore = new ScriptRouteStore(); - routeStore.registerRoute({ - hostname: "test-service.localhost", - port: upstream.port, - workspaceId: "workspace-test", - projectSlug: "test", - scriptName: "service", + const server = http.createServer(app); + await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve)); + servers.push(server); + + return { port, server }; +} + +/** Simple HTTP GET helper that returns status code and body. */ +function httpGet( + port: number, + host: string, + path = "/", +): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get({ hostname: "127.0.0.1", port, path, headers: { host } }, (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => (body += chunk.toString())); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); }); + req.on("error", reject); + }); +} - const proxy = await startProxy(routeStore); - const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`); - - expect(res.status).toBe(200); - expect(res.body).toBe("upstream-ok"); - - const headers = upstream.receivedHeaders(); - expect(headers["x-forwarded-for"]).toBeDefined(); - expect(headers["x-forwarded-host"]).toBe("test-service.localhost"); +it("proxies requests to the correct upstream based on Host header", async () => { + const upstream = await startUpstream(); + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "test-service.localhost", + port: upstream.port, + workspaceId: "workspace-test", + projectSlug: "test", + scriptName: "service", }); - it("falls through when no route matches", async () => { - const routeStore = new ScriptRouteStore(); - const proxy = await startProxy(routeStore, { fallback: true }); + const proxy = await startProxy(routeStore); + const res = await httpGet(proxy.port, `test-service.localhost:${proxy.port}`); - const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`); + expect(res.status).toBe(200); + expect(res.body).toBe("upstream-ok"); - expect(res.status).toBe(404); - expect(res.body).toBe("no route"); + const headers = upstream.receivedHeaders(); + expect(headers["x-forwarded-for"]).toBeDefined(); + expect(headers["x-forwarded-host"]).toBe("test-service.localhost"); +}); + +it("falls through when no route matches", async () => { + const routeStore = new ScriptRouteStore(); + const proxy = await startProxy(routeStore, { fallback: true }); + + const res = await httpGet(proxy.port, `unknown.localhost:${proxy.port}`); + + expect(res.status).toBe(404); + expect(res.body).toBe("no route"); +}); + +it("returns 502 when upstream is down", async () => { + // Get a port that nothing is listening on + const deadPort = await findFreePort(); + + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "dead-service.localhost", + port: deadPort, + workspaceId: "workspace-dead", + projectSlug: "dead", + scriptName: "service", }); - it("returns 502 when upstream is down", async () => { - // Get a port that nothing is listening on - const deadPort = await findFreePort(); + const proxy = await startProxy(routeStore); + const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`); - const routeStore = new ScriptRouteStore(); - routeStore.registerRoute({ - hostname: "dead-service.localhost", - port: deadPort, - workspaceId: "workspace-dead", - projectSlug: "dead", - scriptName: "service", - }); - - const proxy = await startProxy(routeStore); - const res = await httpGet(proxy.port, `dead-service.localhost:${proxy.port}`); - - expect(res.status).toBe(502); - expect(res.body).toBe("502 Bad Gateway"); - }); + expect(res.status).toBe(502); + expect(res.body).toBe("502 Bad Gateway"); }); // --------------------------------------------------------------------------- // WebSocket proxy // --------------------------------------------------------------------------- -describe("WebSocket proxy", () => { - const httpServers: http.Server[] = []; - const wsServers: WebSocketServer[] = []; - const wsClients: WebSocket[] = []; +const httpServers: http.Server[] = []; +const wsServers: WebSocketServer[] = []; +const wsClients: WebSocket[] = []; - afterEach(async () => { - for (const ws of wsClients) { - if (ws.readyState === WebSocket.OPEN) ws.close(); - } - wsClients.length = 0; +afterEach(async () => { + for (const ws of wsClients) { + if (ws.readyState === WebSocket.OPEN) ws.close(); + } + wsClients.length = 0; - for (const wss of wsServers) { - wss.close(); - } - wsServers.length = 0; + for (const wss of wsServers) { + wss.close(); + } + wsServers.length = 0; - await Promise.all(httpServers.map(closeServer)); - httpServers.length = 0; + await Promise.all(httpServers.map(closeServer)); + httpServers.length = 0; +}); + +it("proxies WebSocket connections to the correct upstream", async () => { + // 1. Start a real WebSocket echo server + const upstreamPort = await findFreePort(); + const upstreamServer = http.createServer(); + const wss = new WebSocketServer({ server: upstreamServer }); + wsServers.push(wss); + + wss.on("connection", (ws) => { + ws.on("message", (data) => { + ws.send(`echo: ${data.toString()}`); + }); }); - it("proxies WebSocket connections to the correct upstream", async () => { - // 1. Start a real WebSocket echo server - const upstreamPort = await findFreePort(); - const upstreamServer = http.createServer(); - const wss = new WebSocketServer({ server: upstreamServer }); - wsServers.push(wss); + await new Promise((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve)); + httpServers.push(upstreamServer); - wss.on("connection", (ws) => { - ws.on("message", (data) => { - ws.send(`echo: ${data.toString()}`); - }); - }); - - await new Promise((resolve) => upstreamServer.listen(upstreamPort, "127.0.0.1", resolve)); - httpServers.push(upstreamServer); - - // 2. Create the proxy server with the upgrade handler - const routeStore = new ScriptRouteStore(); - routeStore.registerRoute({ - hostname: "ws-service.localhost", - port: upstreamPort, - workspaceId: "workspace-ws", - projectSlug: "ws", - scriptName: "service", - }); - - const proxyPort = await findFreePort(); - const proxyServer = http.createServer((_req, res) => { - res.writeHead(404); - res.end(); - }); - - const upgradeHandler = createScriptProxyUpgradeHandler({ - routeStore, - logger, - }); - proxyServer.on("upgrade", upgradeHandler); - - await new Promise((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve)); - httpServers.push(proxyServer); - - // 3. Connect a WebSocket client through the proxy - const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, { - headers: { host: `ws-service.localhost:${proxyPort}` }, - }); - wsClients.push(ws); - - await new Promise((resolve, reject) => { - ws.on("open", resolve); - ws.on("error", reject); - }); - - // 4. Send a message and verify echo - const reply = await new Promise((resolve, reject) => { - ws.on("message", (data) => resolve(data.toString())); - ws.on("error", reject); - ws.send("hello proxy"); - }); - - expect(reply).toBe("echo: hello proxy"); + // 2. Create the proxy server with the upgrade handler + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "ws-service.localhost", + port: upstreamPort, + workspaceId: "workspace-ws", + projectSlug: "ws", + scriptName: "service", }); + + const proxyPort = await findFreePort(); + const proxyServer = http.createServer((_req, res) => { + res.writeHead(404); + res.end(); + }); + + const upgradeHandler = createScriptProxyUpgradeHandler({ + routeStore, + logger, + }); + proxyServer.on("upgrade", upgradeHandler); + + await new Promise((resolve) => proxyServer.listen(proxyPort, "127.0.0.1", resolve)); + httpServers.push(proxyServer); + + // 3. Connect a WebSocket client through the proxy + const ws = new WebSocket(`ws://127.0.0.1:${proxyPort}`, { + headers: { host: `ws-service.localhost:${proxyPort}` }, + }); + wsClients.push(ws); + + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + + // 4. Send a message and verify echo + const reply = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + ws.send("hello proxy"); + }); + + expect(reply).toBe("echo: hello proxy"); }); // --------------------------------------------------------------------------- // findFreePort // --------------------------------------------------------------------------- -describe("findFreePort", () => { - it("returns a number", async () => { - const port = await findFreePort(); - expect(typeof port).toBe("number"); - expect(port).toBeGreaterThan(0); - expect(port).toBeLessThan(65536); - }); +it("returns a number", async () => { + const port = await findFreePort(); + expect(typeof port).toBe("number"); + expect(port).toBeGreaterThan(0); + expect(port).toBeLessThan(65536); +}); - it("returns a port that is actually available", async () => { - const port = await findFreePort(); +it("returns a port that is actually available", async () => { + const port = await findFreePort(); - // Verify we can bind a server to it - const server = net.createServer(); - await new Promise((resolve, reject) => { - server.listen(port, "127.0.0.1", () => resolve()); - server.on("error", reject); - }); + // Verify we can bind a server to it + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.listen(port, "127.0.0.1", () => resolve()); + server.on("error", reject); + }); - const addr = server.address(); - expect(addr).not.toBeNull(); - expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port); + const addr = server.address(); + expect(addr).not.toBeNull(); + expect(typeof addr === "object" && addr !== null ? addr.port : -1).toBe(port); - await new Promise((resolve) => server.close(() => resolve())); - }); + await new Promise((resolve) => server.close(() => resolve())); }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 688fbfb12a..bc1d00ea4f 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -250,2330 +250,2406 @@ function createSessionForWorkspaceTests( return session; } -describe("workspace aggregation", () => { - test("workspace reconciliation reports archived workspaces to subscribed clients", async () => { - const missingCwd = path.join(tmpdir(), `paseo-missing-workspace-${Date.now()}`); - rmSync(missingCwd, { recursive: true, force: true }); - const projects = new Map([ - [ - "proj-missing", - createPersistedProjectRecord({ - projectId: "proj-missing", - rootPath: missingCwd, - kind: "non_git", - displayName: "missing", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ], - ]); - const workspaces = new Map([ - [ - "ws-missing", - createPersistedWorkspaceRecord({ - workspaceId: "ws-missing", - projectId: "proj-missing", - cwd: missingCwd, - kind: "directory", - displayName: "missing", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ], - ]); - const session = createSessionForWorkspaceTests() as any; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const project = projects.get(projectId); - if (project) { - projects.set(projectId, { ...project, archivedAt }); - } - }; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { - const workspace = workspaces.get(workspaceId); - if (workspace) { - workspaces.set(workspaceId, { ...workspace, archivedAt }); - } - }; +test("workspace reconciliation reports archived workspaces to subscribed clients", async () => { + const missingCwd = path.join(tmpdir(), `paseo-missing-workspace-${Date.now()}`); + rmSync(missingCwd, { recursive: true, force: true }); + const projects = new Map([ + [ + "proj-missing", + createPersistedProjectRecord({ + projectId: "proj-missing", + rootPath: missingCwd, + kind: "non_git", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + ]); + const workspaces = new Map([ + [ + "ws-missing", + createPersistedWorkspaceRecord({ + workspaceId: "ws-missing", + projectId: "proj-missing", + cwd: missingCwd, + kind: "directory", + displayName: "missing", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ], + ]); + const session = createSessionForWorkspaceTests() as any; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const project = projects.get(projectId); + if (project) { + projects.set(projectId, { ...project, archivedAt }); + } + }; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const workspace = workspaces.get(workspaceId); + if (workspace) { + workspaces.set(workspaceId, { ...workspace, archivedAt }); + } + }; - const changedWorkspaceIds = await session.reconcileActiveWorkspaceRecords(); + const changedWorkspaceIds = await session.reconcileActiveWorkspaceRecords(); - expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"])); - expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy(); - expect(projects.get("proj-missing")?.archivedAt).toBeTruthy(); + expect(changedWorkspaceIds).toEqual(new Set(["ws-missing"])); + expect(workspaces.get("ws-missing")?.archivedAt).toBeTruthy(); + expect(projects.get("proj-missing")?.archivedAt).toBeTruthy(); +}); + +test("agent_update placement does not refresh git snapshots", async () => { + const emitted: SessionOutboundMessage[] = []; + const getSnapshot = vi.fn(async () => { + throw new Error("getSnapshot should not be called for agent_update placement"); + }); + const workspaceGitService = { + ...createNoopWorkspaceGitService(), + getSnapshot, + peekSnapshot: vi.fn(() => null), + }; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "proj-1", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", }); - test("agent_update placement does not refresh git snapshots", async () => { - const emitted: SessionOutboundMessage[] = []; - const getSnapshot = vi.fn(async () => { - throw new Error("getSnapshot should not be called for agent_update placement"); - }); - const workspaceGitService = { + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.workspaceRegistry.list = async () => [workspace]; + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: {}, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + + await session.forwardAgentUpdate( + makeManagedAgent({ + id: "agent-1", + cwd: "/tmp/repo", + lifecycle: "running", + updatedAt: "2026-03-30T15:00:00.000Z", + }), + ); + + expect(getSnapshot).not.toHaveBeenCalled(); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-1", + status: "running", + }, + project: { + projectKey: "proj-1", + projectName: "repo", + }, + }); +}); + +test("agent_update emits fallback placement when no workspace is registered", async () => { + const emitted: SessionOutboundMessage[] = []; + const getSnapshot = vi.fn(async () => { + throw new Error("getSnapshot should not be called for fallback agent_update placement"); + }); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + workspaceGitService: { ...createNoopWorkspaceGitService(), getSnapshot, peekSnapshot: vi.fn(() => null), - }; - const session = createSessionForWorkspaceTests({ - onMessage: (message) => emitted.push(message), - workspaceGitService, - }) as any; - const project = createPersistedProjectRecord({ - projectId: "proj-1", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", + }, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: {}, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + + await session.forwardAgentUpdate( + makeManagedAgent({ + id: "agent-1", + cwd: "/tmp/unregistered", + lifecycle: "running", + updatedAt: "2026-03-30T15:00:00.000Z", + }), + ); + + expect(getSnapshot).not.toHaveBeenCalled(); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + project: { + projectKey: "/tmp/unregistered", + projectName: "unregistered", + checkout: { + cwd: "/tmp/unregistered", + isGit: false, + }, + }, + }); +}); + +test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const archivedRecord = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + lastActivityAt: "2026-03-30T15:00:00.000Z", + lastUserMessageAt: null, + lastStatus: "idle", + lastModeId: null, + runtimeInfo: null, + config: { + provider: "codex", + cwd: "/tmp/repo", + }, + persistence: null, + title: "Archive me", + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }; + + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveAgent: async () => { + const archivedAt = new Date().toISOString(); + Object.assign(archivedRecord, { + archivedAt, + updatedAt: archivedAt, + }); + return { archivedAt }; + }, + archiveSnapshot: async (_agentId: string, archivedAt: string) => { + Object.assign(archivedRecord, { archivedAt, updatedAt: archivedAt }); + return archivedRecord; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [archivedRecord], + get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), + upsert: async (record: typeof archivedRecord) => { + Object.assign(archivedRecord, record); + }, + } as any, + projectRegistry: (() => { + const proj = createPersistedProjectRecord({ + projectId: "proj-1", + rootPath: "/tmp/repo", + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [proj], + get: async (id: string) => (id === "proj-1" ? proj : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + workspaceRegistry: (() => { + const ws = createPersistedWorkspaceRecord({ + workspaceId: "ws-1", + projectId: "proj-1", + cwd: "/tmp/repo", + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [ws], + get: async (id: string) => (id === "ws-1" ? ws : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + workspaceGitService: createNoopWorkspaceGitService(), + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: null, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + + await session.handleArchiveAgentRequest("agent-1", "req-archive"); + + const update = emitted.find((message) => message.type === "agent_update"); + expect(update?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-1", + archivedAt: expect.any(String), + }, + }); + expect(emitted.find((message) => message.type === "agent_archived")?.payload).toMatchObject({ + agentId: "agent-1", + archivedAt: expect.any(String), + requestId: "req-archive", + }); +}); + +test("close_items_request archives agents and kills terminals in one batch", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const archivedAt = "2026-04-01T00:00:00.000Z"; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const archivedRecord = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/repo", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { provider: "codex", sessionId: null }, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }; + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), + archiveAgent: async () => ({ archivedAt }), + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId !== "agent-1") { + return null; + } + archivedRecord.archivedAt = archivedAt; + archivedRecord.updatedAt = archivedAt; + return archivedRecord; + }, + } as any, + projectRegistry: (() => { + const proj = createPersistedProjectRecord({ + projectId: "proj-close", + rootPath: "/tmp/repo", + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [proj], + get: async (id: string) => (id === "proj-close" ? proj : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + workspaceRegistry: (() => { + const ws = createPersistedWorkspaceRecord({ + workspaceId: "ws-close", + projectId: "proj-close", + cwd: "/tmp/repo", + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [ws], + get: async (id: string) => (id === "ws-close" ? ws : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + workspaceGitService: createNoopWorkspaceGitService(), + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-1"], + terminalIds: ["term-1"], + requestId: "req-close-items", + }); + + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); + expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [{ agentId: "agent-1", archivedAt }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-items", + }); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-1", + archivedAt, + }, + }); +}); + +test("close_items_request archives stored agents that are not currently loaded", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const liveArchivedAt = "2026-04-01T00:00:00.000Z"; + const storedAgentId = "agent-stored"; + const liveRecord = { + ...makeAgent({ + id: "agent-live", + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: null as string | null, + }; + const storedRecord = { + ...makeAgent({ + id: storedAgentId, + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:05:00.000Z", + }), + archivedAt: null as string | null, + }; + const upsertStoredRecord = vi.fn(async (record: typeof storedRecord) => { + if (record.id === storedAgentId) { + storedRecord.archivedAt = record.archivedAt; + storedRecord.updatedAt = record.updatedAt; + storedRecord.status = record.status; + storedRecord.requiresAttention = record.requiresAttention; + storedRecord.attentionReason = record.attentionReason; + storedRecord.attentionTimestamp = record.attentionTimestamp; + } + }); + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null), + archiveAgent: async (agentId: string) => { + if (agentId !== "agent-live") { + throw new Error(`Unexpected live archive: ${agentId}`); + } + liveRecord.archivedAt = liveArchivedAt; + liveRecord.updatedAt = liveArchivedAt; + return { archivedAt: liveArchivedAt }; + }, + archiveSnapshot: async (_agentId: string, archivedAt: string) => { + storedRecord.archivedAt = archivedAt; + storedRecord.updatedAt = archivedAt; + storedRecord.status = "completed"; + storedRecord.requiresAttention = false; + storedRecord.attentionReason = null; + storedRecord.attentionTimestamp = null; + return storedRecord; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId === "agent-live") { + return liveRecord; + } + if (agentId === storedAgentId) { + return storedRecord; + } + return null; + }, + upsert: upsertStoredRecord, + } as any, + projectRegistry: (() => { + const proj = createPersistedProjectRecord({ + projectId: "proj-stored", + rootPath: "/tmp/repo", + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [proj], + get: async (id: string) => (id === "proj-stored" ? proj : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + workspaceRegistry: (() => { + const ws = createPersistedWorkspaceRecord({ + workspaceId: "ws-stored", + projectId: "proj-stored", + cwd: "/tmp/repo", + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [ws], + get: async (id: string) => (id === "ws-stored" ? ws : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + workspaceGitService: createNoopWorkspaceGitService(), + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-live", storedAgentId], + terminalIds: [], + requestId: "req-close-stored", + }); + + expect(storedRecord.archivedAt).toEqual(expect.any(String)); + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [ + { agentId: "agent-live", archivedAt: liveArchivedAt }, + { agentId: storedAgentId, archivedAt: storedRecord.archivedAt }, + ], + terminals: [], + requestId: "req-close-stored", + }); + expect(sessionLogger.warn).not.toHaveBeenCalled(); +}); + +test("close_items_request continues after an archive failure", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const sessionLogger = { + child: () => sessionLogger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const archivedAt = "2026-04-01T00:00:00.000Z"; + const goodRecord = { + ...makeAgent({ + id: "agent-good", + cwd: "/tmp/repo", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: null as string | null, + }; + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: sessionLogger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: (agentId: string) => + agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null, + archiveAgent: async (agentId: string) => { + if (agentId === "agent-bad") { + throw new Error("archive failed"); + } + return { archivedAt }; + }, + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async (agentId: string) => { + if (agentId !== "agent-good") { + return null; + } + goodRecord.archivedAt = archivedAt; + goodRecord.updatedAt = archivedAt; + return goodRecord; + }, + } as any, + projectRegistry: (() => { + const proj = createPersistedProjectRecord({ + projectId: "proj-err", + rootPath: "/tmp/repo", + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [proj], + get: async (id: string) => (id === "proj-err" ? proj : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + workspaceRegistry: (() => { + const ws = createPersistedWorkspaceRecord({ + workspaceId: "ws-err", + projectId: "proj-err", + cwd: "/tmp/repo", + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [ws], + get: async (id: string) => (id === "ws-err" ? ws : null), + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; + })() as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + } as any, + workspaceGitService: createNoopWorkspaceGitService(), + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: { + killTerminal: vi.fn(), + subscribeTerminalsChanged: () => () => {}, + } as any, + }) as any; + + session.agentUpdatesSubscription = { + subscriptionId: "sub-agents", + filter: { includeArchived: true }, + isBootstrapping: false, + pendingUpdatesByAgentId: new Map(), + }; + session.interruptAgentIfRunning = vi.fn(); + + await session.handleMessage({ + type: "close_items_request", + agentIds: ["agent-bad", "agent-good"], + terminalIds: ["term-1"], + requestId: "req-close-best-effort", + }); + + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad"); + expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good"); + expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); + expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ + agents: [{ agentId: "agent-good", archivedAt }], + terminals: [{ terminalId: "term-1", success: true }], + requestId: "req-close-best-effort", + }); + expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ + kind: "upsert", + agent: { + id: "agent-good", + archivedAt, + }, + }); + expect(sessionLogger.warn).toHaveBeenCalled(); +}); + +test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-non-git", + projectId: "proj-non-git", + cwd: "/tmp/non-git", + kind: "directory", + displayName: "non-git", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-1", - projectId: project.projectId, + }), + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/non-git", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-1", + }); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]?.name).toBe("non-git"); + expect(result.entries[0]?.name).not.toBe("Unknown branch"); +}); + +test("active-scoped fetch_agents includes only unarchived agents in active exact workspaces", async () => { + const session = createSessionForWorkspaceTests() as any; + const archivedAt = "2026-03-02T12:00:00.000Z"; + const activeProject = createPersistedProjectRecord({ + projectId: "proj-active", + rootPath: "/tmp/active", + kind: "non_git", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedProject = createPersistedProjectRecord({ + projectId: "proj-archived", + rootPath: "/tmp/archived-project", + kind: "non_git", + displayName: "archived project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt, + }); + const activeWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-active", + projectId: activeProject.projectId, + cwd: "/tmp/active", + kind: "directory", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived", + projectId: activeProject.projectId, + cwd: "/tmp/archived-workspace", + kind: "directory", + displayName: "archived workspace", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt, + }); + const workspaceInArchivedProject = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-project", + projectId: archivedProject.projectId, + cwd: "/tmp/archived-project", + kind: "directory", + displayName: "archived project", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.projectRegistry.list = async () => [activeProject, archivedProject]; + session.projectRegistry.get = async (projectId: string) => + [activeProject, archivedProject].find((project) => project.projectId === projectId) ?? null; + session.workspaceRegistry.list = async () => [ + activeWorkspace, + archivedWorkspace, + workspaceInArchivedProject, + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "agent-active", + cwd: "/tmp/active", + status: "idle", + updatedAt: "2026-03-01T12:04:00.000Z", + }), + makeAgent({ + id: "agent-subdir", + cwd: "/tmp/active/packages/app", + status: "idle", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + makeAgent({ + id: "agent-archived-workspace", + cwd: "/tmp/archived-workspace", + status: "idle", + updatedAt: "2026-03-01T12:02:00.000Z", + }), + makeAgent({ + id: "agent-archived-project", + cwd: "/tmp/archived-project", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + { + ...makeAgent({ + id: "agent-archived", + cwd: "/tmp/active", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt, + }, + ]; + + const result = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-agents", + scope: "active", + filter: { includeArchived: true }, + }); + + expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active"]); + expect(result.pageInfo.hasMore).toBe(false); +}); + +test("active-scoped fetch_agents pages within active scope instead of global history", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-active-pages", + rootPath: "/tmp/pages", + kind: "non_git", + displayName: "pages", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeOne = createPersistedWorkspaceRecord({ + workspaceId: "ws-active-one", + projectId: project.projectId, + cwd: "/tmp/pages/one", + kind: "directory", + displayName: "one", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeTwo = createPersistedWorkspaceRecord({ + workspaceId: "ws-active-two", + projectId: project.projectId, + cwd: "/tmp/pages/two", + kind: "directory", + displayName: "two", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-stale", + projectId: project.projectId, + cwd: "/tmp/pages/stale", + kind: "directory", + displayName: "stale", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [activeOne, activeTwo, archivedWorkspace]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "active-one", + cwd: "/tmp/pages/one", + status: "idle", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + makeAgent({ + id: "stale-between", + cwd: "/tmp/pages/stale", + status: "idle", + updatedAt: "2026-03-01T12:02:00.000Z", + }), + makeAgent({ + id: "active-two", + cwd: "/tmp/pages/two", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + ]; + + const firstPage = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-page-1", + scope: "active", + page: { limit: 1 }, + }); + const secondPage = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-active-page-2", + scope: "active", + page: { limit: 1, cursor: firstPage.pageInfo.nextCursor }, + }); + + expect(agentIdsFromEntries(firstPage.entries)).toEqual(["active-one"]); + expect(firstPage.pageInfo.hasMore).toBe(true); + expect(agentIdsFromEntries(secondPage.entries)).toEqual(["active-two"]); + expect(secondPage.pageInfo.hasMore).toBe(false); +}); + +test("legacy unscoped fetch_agents keeps global workspace behavior", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-legacy-global", + rootPath: "/tmp/legacy", + kind: "non_git", + displayName: "legacy", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const activeWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-legacy-active", + projectId: project.projectId, + cwd: "/tmp/legacy/active", + kind: "directory", + displayName: "active", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const archivedWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-legacy-archived", + projectId: project.projectId, + cwd: "/tmp/legacy/archived", + kind: "directory", + displayName: "archived", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "legacy-active", + cwd: "/tmp/legacy/active", + status: "idle", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + makeAgent({ + id: "legacy-archived-workspace", + cwd: "/tmp/legacy/archived", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + + const result = await session.listFetchAgentsEntries({ + type: "fetch_agents_request", + requestId: "req-legacy-global", + }); + + expect(agentIdsFromEntries(result.entries)).toEqual([ + "legacy-active", + "legacy-archived-workspace", + ]); +}); + +test("fetch_agent_history_request pages archived historical rows separately", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-history", + rootPath: "/tmp/history", + kind: "non_git", + displayName: "history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-history", + projectId: project.projectId, + cwd: "/tmp/history", + kind: "directory", + displayName: "history", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + archivedAt: "2026-03-02T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async () => project; + session.workspaceRegistry.list = async () => [workspace]; + session.listAgentPayloads = async () => [ + { + ...makeAgent({ + id: "history-archived", + cwd: "/tmp/history", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: "2026-03-02T12:00:00.000Z", + }, + ]; + + await session.handleMessage({ + type: "fetch_agent_history_request", + requestId: "req-history", + page: { limit: 25 }, + }); + + expect(emitted).toEqual([ + { + type: "fetch_agent_history_response", + payload: expect.objectContaining({ + requestId: "req-history", + entries: [ + expect.objectContaining({ + agent: expect.objectContaining({ id: "history-archived" }), + }), + ], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }), + }, + ]); + expect(session.agentUpdatesSubscription).toBeNull(); +}); + +test("fetch_agent_request still resolves archived historical agents", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const session = createSessionForWorkspaceTests() as any; + const agent = { + ...makeAgent({ + id: "archived-history-agent", + cwd: "/tmp/history-detail", + status: "idle", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + archivedAt: "2026-03-02T12:00:00.000Z", + title: "Archived History Agent", + }; + session.emit = (message: any) => emitted.push(message); + session.resolveAgentIdentifier = async (identifier: string) => + identifier === "Archived History Agent" + ? { ok: true, agentId: agent.id } + : { ok: false, error: `Agent not found: ${identifier}` }; + session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); + session.buildProjectPlacementForCwd = async (cwd: string) => ({ + projectKey: "proj-history-detail", + projectName: "history detail", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "fetch_agent_request", + requestId: "req-agent-detail", + agentId: "Archived History Agent", + }); + + expect(emitted).toEqual([ + { + type: "fetch_agent_response", + payload: { + requestId: "req-agent-detail", + agent, + project: expect.objectContaining({ + projectKey: "proj-history-detail", + }), + error: null, + }, + }, + ]); +}); + +test("git branch workspace uses branch as canonical name", async () => { + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-branch", + projectId: "proj-repo-branch", + cwd: "/tmp/repo-branch", + kind: "local_checkout", + displayName: "feature/name-from-server", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/repo-branch", + status: "running", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo-branch", + checkout: { + cwd, + isGit: true, + currentBranch: "feature/name-from-server", + remoteUrl: "https://github.com/acme/repo-branch.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-branch", + }); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]?.name).toBe("feature/name-from-server"); +}); + +test("branch/detached policies and dominant status bucket are deterministic", async () => { + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-status", + projectId: "proj-repo-status", cwd: "/tmp/repo", kind: "local_checkout", displayName: "repo", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", - }); - - session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); - session.workspaceRegistry.list = async () => [workspace]; - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: {}, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - - await session.forwardAgentUpdate( - makeManagedAgent({ - id: "agent-1", - cwd: "/tmp/repo", - lifecycle: "running", - updatedAt: "2026-03-30T15:00:00.000Z", - }), - ); - - expect(getSnapshot).not.toHaveBeenCalled(); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-1", - status: "running", - }, - project: { - projectKey: "proj-1", - projectName: "repo", - }, - }); - }); - - test("agent_update emits fallback placement when no workspace is registered", async () => { - const emitted: SessionOutboundMessage[] = []; - const getSnapshot = vi.fn(async () => { - throw new Error("getSnapshot should not be called for fallback agent_update placement"); - }); - const session = createSessionForWorkspaceTests({ - onMessage: (message) => emitted.push(message), - workspaceGitService: { - ...createNoopWorkspaceGitService(), - getSnapshot, - peekSnapshot: vi.fn(() => null), - }, - }) as any; - - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: {}, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - - await session.forwardAgentUpdate( - makeManagedAgent({ - id: "agent-1", - cwd: "/tmp/unregistered", - lifecycle: "running", - updatedAt: "2026-03-30T15:00:00.000Z", - }), - ); - - expect(getSnapshot).not.toHaveBeenCalled(); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - project: { - projectKey: "/tmp/unregistered", - projectName: "unregistered", - checkout: { - cwd: "/tmp/unregistered", - isGit: false, - }, - }, - }); - }); - - test("archive emits an authoritative agent_update upsert for subscribed clients", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const archivedRecord = { - id: "agent-1", - provider: "codex", + }), + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "a1", cwd: "/tmp/repo", - createdAt: "2026-03-30T15:00:00.000Z", - updatedAt: "2026-03-30T15:00:00.000Z", - lastActivityAt: "2026-03-30T15:00:00.000Z", - lastUserMessageAt: null, - lastStatus: "idle", - lastModeId: null, - runtimeInfo: null, - config: { - provider: "codex", - cwd: "/tmp/repo", - }, - persistence: null, - title: "Archive me", - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }; - - const logger = { - child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: logger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: () => null, - archiveAgent: async () => { - const archivedAt = new Date().toISOString(); - Object.assign(archivedRecord, { - archivedAt, - updatedAt: archivedAt, - }); - return { archivedAt }; - }, - archiveSnapshot: async (_agentId: string, archivedAt: string) => { - Object.assign(archivedRecord, { archivedAt, updatedAt: archivedAt }); - return archivedRecord; - }, - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, - } as any, - agentStorage: { - list: async () => [archivedRecord], - get: async (agentId: string) => (agentId === archivedRecord.id ? archivedRecord : null), - upsert: async (record: typeof archivedRecord) => { - Object.assign(archivedRecord, record); - }, - } as any, - projectRegistry: (() => { - const proj = createPersistedProjectRecord({ - projectId: "proj-1", - rootPath: "/tmp/repo", - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [proj], - get: async (id: string) => (id === "proj-1" ? proj : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - workspaceRegistry: (() => { - const ws = createPersistedWorkspaceRecord({ - workspaceId: "ws-1", - projectId: "proj-1", - cwd: "/tmp/repo", - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [ws], - get: async (id: string) => (id === "ws-1" ? ws : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp/repo", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, - workspaceGitService: createNoopWorkspaceGitService(), - mcpBaseUrl: null, - stt: null, - tts: null, - terminalManager: null, - }) as any; - - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - - await session.handleArchiveAgentRequest("agent-1", "req-archive"); - - const update = emitted.find((message) => message.type === "agent_update"); - expect(update?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-1", - archivedAt: expect.any(String), - }, - }); - expect(emitted.find((message) => message.type === "agent_archived")?.payload).toMatchObject({ - agentId: "agent-1", - archivedAt: expect.any(String), - requestId: "req-archive", - }); - }); - - test("close_items_request archives agents and kills terminals in one batch", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const archivedAt = "2026-04-01T00:00:00.000Z"; - const sessionLogger = { - child: () => sessionLogger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const archivedRecord = { - id: "agent-1", - provider: "codex", - cwd: "/tmp/repo", - model: null, - thinkingOptionId: null, - effectiveThinkingOptionId: null, - createdAt: "2026-03-01T12:00:00.000Z", + status: "running", updatedAt: "2026-03-01T12:00:00.000Z", - lastUserMessageAt: null, + }), + makeAgent({ + id: "a2", + cwd: "/tmp/repo", + status: "error", + updatedAt: "2026-03-01T12:01:00.000Z", + }), + makeAgent({ + id: "a3", + cwd: "/tmp/repo", status: "idle", - capabilities: { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { provider: "codex", sessionId: null }, - title: null, - labels: {}, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }; - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: sessionLogger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: (agentId: string) => (agentId === "agent-1" ? { id: agentId } : null), - archiveAgent: async () => ({ archivedAt }), - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, - } as any, - agentStorage: { - list: async () => [], - get: async (agentId: string) => { - if (agentId !== "agent-1") { - return null; - } - archivedRecord.archivedAt = archivedAt; - archivedRecord.updatedAt = archivedAt; - return archivedRecord; - }, - } as any, - projectRegistry: (() => { - const proj = createPersistedProjectRecord({ - projectId: "proj-close", - rootPath: "/tmp/repo", - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [proj], - get: async (id: string) => (id === "proj-close" ? proj : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - workspaceRegistry: (() => { - const ws = createPersistedWorkspaceRecord({ - workspaceId: "ws-close", - projectId: "proj-close", - cwd: "/tmp/repo", - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [ws], - get: async (id: string) => (id === "ws-close" ? ws : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, - workspaceGitService: createNoopWorkspaceGitService(), - mcpBaseUrl: null, - stt: null, - tts: null, - terminalManager: { - killTerminal: vi.fn(), - subscribeTerminalsChanged: () => () => {}, - } as any, - }) as any; - - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.interruptAgentIfRunning = vi.fn(); - - await session.handleMessage({ - type: "close_items_request", - agentIds: ["agent-1"], - terminalIds: ["term-1"], - requestId: "req-close-items", - }); - - expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1"); - expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); - expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ - agents: [{ agentId: "agent-1", archivedAt }], - terminals: [{ terminalId: "term-1", success: true }], - requestId: "req-close-items", - }); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-1", - archivedAt, - }, - }); + updatedAt: "2026-03-01T12:02:00.000Z", + pendingPermissions: 1, + }), + ]; + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-2", }); - test("close_items_request archives stored agents that are not currently loaded", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const sessionLogger = { - child: () => sessionLogger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const liveArchivedAt = "2026-04-01T00:00:00.000Z"; - const storedAgentId = "agent-stored"; - const liveRecord = { - ...makeAgent({ - id: "agent-live", - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", + expect(result.entries).toHaveLength(1); + expect(result.entries[0]?.name).toBe("repo"); + expect(result.entries[0]?.status).toBe("needs_input"); +}); + +test("subdirectory agents map to an existing parent workspace descriptor", async () => { + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-subdir", + projectId: "proj-repo-subdir", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/repo/packages/app", + status: "running", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + ]; + + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-subdir-agent", + }); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + id: "ws-repo-subdir", + status: "done", + activityAt: null, + }); +}); + +test("workspace update stream keeps persisted workspace visible after agents stop", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const session = new Session({ + clientId: "test-client", + onMessage: (message) => emitted.push(message as any), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: { + subscribe: async () => ({ + initial: { cwd: "/tmp", files: [], error: null }, + unsubscribe: () => {}, }), - archivedAt: null as string | null, - }; - const storedRecord = { - ...makeAgent({ - id: storedAgentId, - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:05:00.000Z", + scheduleRefreshForCwd: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, }), - archivedAt: null as string | null, - }; - const upsertStoredRecord = vi.fn(async (record: typeof storedRecord) => { - if (record.id === storedAgentId) { - storedRecord.archivedAt = record.archivedAt; - storedRecord.updatedAt = record.updatedAt; - storedRecord.status = record.status; - storedRecord.requiresAttention = record.requiresAttention; - storedRecord.attentionReason = record.attentionReason; - storedRecord.attentionTimestamp = record.attentionTimestamp; - } - }); + dispose: () => {}, + } as any, + workspaceGitService: createNoopWorkspaceGitService(), + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: null, + }) as any; - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: sessionLogger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: (agentId: string) => (agentId === "agent-live" ? { id: agentId } : null), - archiveAgent: async (agentId: string) => { - if (agentId !== "agent-live") { - throw new Error(`Unexpected live archive: ${agentId}`); - } - liveRecord.archivedAt = liveArchivedAt; - liveRecord.updatedAt = liveArchivedAt; - return { archivedAt: liveArchivedAt }; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-1", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "/tmp/repo", + { + id: "ws-repo-running", + projectId: "proj-repo-running", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", + status: "running", + activityAt: "2026-03-01T12:00:00.000Z", }, - archiveSnapshot: async (_agentId: string, archivedAt: string) => { - storedRecord.archivedAt = archivedAt; - storedRecord.updatedAt = archivedAt; - storedRecord.status = "completed"; - storedRecord.requiresAttention = false; - storedRecord.attentionReason = null; - storedRecord.attentionTimestamp = null; - return storedRecord; - }, - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, - } as any, - agentStorage: { - list: async () => [], - get: async (agentId: string) => { - if (agentId === "agent-live") { - return liveRecord; - } - if (agentId === storedAgentId) { - return storedRecord; - } - return null; - }, - upsert: upsertStoredRecord, - } as any, - projectRegistry: (() => { - const proj = createPersistedProjectRecord({ - projectId: "proj-stored", - rootPath: "/tmp/repo", - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [proj], - get: async (id: string) => (id === "proj-stored" ? proj : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - workspaceRegistry: (() => { - const ws = createPersistedWorkspaceRecord({ - workspaceId: "ws-stored", - projectId: "proj-stored", - cwd: "/tmp/repo", - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [ws], - get: async (id: string) => (id === "ws-stored" ? ws : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, - workspaceGitService: createNoopWorkspaceGitService(), - mcpBaseUrl: null, - stt: null, - tts: null, - terminalManager: { - killTerminal: vi.fn(), - subscribeTerminalsChanged: () => () => {}, - } as any, - }) as any; - - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.interruptAgentIfRunning = vi.fn(); - - await session.handleMessage({ - type: "close_items_request", - agentIds: ["agent-live", storedAgentId], - terminalIds: [], - requestId: "req-close-stored", - }); - - expect(storedRecord.archivedAt).toEqual(expect.any(String)); - expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ - agents: [ - { agentId: "agent-live", archivedAt: liveArchivedAt }, - { agentId: storedAgentId, archivedAt: storedRecord.archivedAt }, ], - terminals: [], - requestId: "req-close-stored", - }); - expect(sessionLogger.warn).not.toHaveBeenCalled(); - }); - - test("close_items_request continues after an archive failure", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const sessionLogger = { - child: () => sessionLogger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const archivedAt = "2026-04-01T00:00:00.000Z"; - const goodRecord = { - ...makeAgent({ - id: "agent-good", - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - archivedAt: null as string | null, - }; - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: sessionLogger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: (agentId: string) => - agentId === "agent-bad" || agentId === "agent-good" ? { id: agentId } : null, - archiveAgent: async (agentId: string) => { - if (agentId === "agent-bad") { - throw new Error("archive failed"); - } - return { archivedAt }; - }, - clearAgentAttention: async () => {}, - notifyAgentState: () => {}, - } as any, - agentStorage: { - list: async () => [], - get: async (agentId: string) => { - if (agentId !== "agent-good") { - return null; - } - goodRecord.archivedAt = archivedAt; - goodRecord.updatedAt = archivedAt; - return goodRecord; - }, - } as any, - projectRegistry: (() => { - const proj = createPersistedProjectRecord({ - projectId: "proj-err", - rootPath: "/tmp/repo", - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [proj], - get: async (id: string) => (id === "proj-err" ? proj : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - workspaceRegistry: (() => { - const ws = createPersistedWorkspaceRecord({ - workspaceId: "ws-err", - projectId: "proj-err", - cwd: "/tmp/repo", - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - return { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [ws], - get: async (id: string) => (id === "ws-err" ? ws : null), - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - }; - })() as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, - workspaceGitService: createNoopWorkspaceGitService(), - mcpBaseUrl: null, - stt: null, - tts: null, - terminalManager: { - killTerminal: vi.fn(), - subscribeTerminalsChanged: () => () => {}, - } as any, - }) as any; - - session.agentUpdatesSubscription = { - subscriptionId: "sub-agents", - filter: { includeArchived: true }, - isBootstrapping: false, - pendingUpdatesByAgentId: new Map(), - }; - session.interruptAgentIfRunning = vi.fn(); - - await session.handleMessage({ - type: "close_items_request", - agentIds: ["agent-bad", "agent-good"], - terminalIds: ["term-1"], - requestId: "req-close-best-effort", - }); - - expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-bad"); - expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-good"); - expect(session.terminalManager.killTerminal).toHaveBeenCalledWith("term-1"); - expect(emitted.find((message) => message.type === "close_items_response")?.payload).toEqual({ - agents: [{ agentId: "agent-good", archivedAt }], - terminals: [{ terminalId: "term-1", success: true }], - requestId: "req-close-best-effort", - }); - expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({ - kind: "upsert", - agent: { - id: "agent-good", - archivedAt, - }, - }); - expect(sessionLogger.warn).toHaveBeenCalled(); - }); - - test("non-git workspace uses deterministic directory name and no unknown branch fallback", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-non-git", - projectId: "proj-non-git", - cwd: "/tmp/non-git", - kind: "directory", - displayName: "non-git", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/non-git", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-1", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("non-git"); - expect(result.entries[0]?.name).not.toBe("Unknown branch"); - }); - - test("active-scoped fetch_agents includes only unarchived agents in active exact workspaces", async () => { - const session = createSessionForWorkspaceTests() as any; - const archivedAt = "2026-03-02T12:00:00.000Z"; - const activeProject = createPersistedProjectRecord({ - projectId: "proj-active", - rootPath: "/tmp/active", - kind: "non_git", - displayName: "active", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const archivedProject = createPersistedProjectRecord({ - projectId: "proj-archived", - rootPath: "/tmp/archived-project", - kind: "non_git", - displayName: "archived project", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - archivedAt, - }); - const activeWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-active", - projectId: activeProject.projectId, - cwd: "/tmp/active", - kind: "directory", - displayName: "active", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const archivedWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-archived", - projectId: activeProject.projectId, - cwd: "/tmp/archived-workspace", - kind: "directory", - displayName: "archived workspace", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - archivedAt, - }); - const workspaceInArchivedProject = createPersistedWorkspaceRecord({ - workspaceId: "ws-archived-project", - projectId: archivedProject.projectId, - cwd: "/tmp/archived-project", - kind: "directory", - displayName: "archived project", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - - session.projectRegistry.list = async () => [activeProject, archivedProject]; - session.projectRegistry.get = async (projectId: string) => - [activeProject, archivedProject].find((project) => project.projectId === projectId) ?? null; - session.workspaceRegistry.list = async () => [ - activeWorkspace, - archivedWorkspace, - workspaceInArchivedProject, - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "agent-active", - cwd: "/tmp/active", - status: "idle", - updatedAt: "2026-03-01T12:04:00.000Z", - }), - makeAgent({ - id: "agent-subdir", - cwd: "/tmp/active/packages/app", - status: "idle", - updatedAt: "2026-03-01T12:03:00.000Z", - }), - makeAgent({ - id: "agent-archived-workspace", - cwd: "/tmp/archived-workspace", - status: "idle", - updatedAt: "2026-03-01T12:02:00.000Z", - }), - makeAgent({ - id: "agent-archived-project", - cwd: "/tmp/archived-project", - status: "idle", - updatedAt: "2026-03-01T12:01:00.000Z", - }), - { - ...makeAgent({ - id: "agent-archived", - cwd: "/tmp/active", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - archivedAt, - }, - ]; - - const result = await session.listFetchAgentsEntries({ - type: "fetch_agents_request", - requestId: "req-active-agents", - scope: "active", - filter: { includeArchived: true }, - }); - - expect(agentIdsFromEntries(result.entries)).toEqual(["agent-active"]); - expect(result.pageInfo.hasMore).toBe(false); - }); - - test("active-scoped fetch_agents pages within active scope instead of global history", async () => { - const session = createSessionForWorkspaceTests() as any; - const project = createPersistedProjectRecord({ - projectId: "proj-active-pages", - rootPath: "/tmp/pages", - kind: "non_git", - displayName: "pages", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const activeOne = createPersistedWorkspaceRecord({ - workspaceId: "ws-active-one", - projectId: project.projectId, - cwd: "/tmp/pages/one", - kind: "directory", - displayName: "one", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const activeTwo = createPersistedWorkspaceRecord({ - workspaceId: "ws-active-two", - projectId: project.projectId, - cwd: "/tmp/pages/two", - kind: "directory", - displayName: "two", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const archivedWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-stale", - projectId: project.projectId, - cwd: "/tmp/pages/stale", - kind: "directory", - displayName: "stale", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - archivedAt: "2026-03-02T12:00:00.000Z", - }); - - session.projectRegistry.list = async () => [project]; - session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [activeOne, activeTwo, archivedWorkspace]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "active-one", - cwd: "/tmp/pages/one", - status: "idle", - updatedAt: "2026-03-01T12:03:00.000Z", - }), - makeAgent({ - id: "stale-between", - cwd: "/tmp/pages/stale", - status: "idle", - updatedAt: "2026-03-01T12:02:00.000Z", - }), - makeAgent({ - id: "active-two", - cwd: "/tmp/pages/two", - status: "idle", - updatedAt: "2026-03-01T12:01:00.000Z", - }), - ]; - - const firstPage = await session.listFetchAgentsEntries({ - type: "fetch_agents_request", - requestId: "req-active-page-1", - scope: "active", - page: { limit: 1 }, - }); - const secondPage = await session.listFetchAgentsEntries({ - type: "fetch_agents_request", - requestId: "req-active-page-2", - scope: "active", - page: { limit: 1, cursor: firstPage.pageInfo.nextCursor }, - }); - - expect(agentIdsFromEntries(firstPage.entries)).toEqual(["active-one"]); - expect(firstPage.pageInfo.hasMore).toBe(true); - expect(agentIdsFromEntries(secondPage.entries)).toEqual(["active-two"]); - expect(secondPage.pageInfo.hasMore).toBe(false); - }); - - test("legacy unscoped fetch_agents keeps global workspace behavior", async () => { - const session = createSessionForWorkspaceTests() as any; - const project = createPersistedProjectRecord({ - projectId: "proj-legacy-global", - rootPath: "/tmp/legacy", - kind: "non_git", - displayName: "legacy", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const activeWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-legacy-active", - projectId: project.projectId, - cwd: "/tmp/legacy/active", - kind: "directory", - displayName: "active", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const archivedWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-legacy-archived", - projectId: project.projectId, - cwd: "/tmp/legacy/archived", - kind: "directory", - displayName: "archived", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - archivedAt: "2026-03-02T12:00:00.000Z", - }); - - session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [activeWorkspace, archivedWorkspace]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "legacy-active", - cwd: "/tmp/legacy/active", - status: "idle", - updatedAt: "2026-03-01T12:01:00.000Z", - }), - makeAgent({ - id: "legacy-archived-workspace", - cwd: "/tmp/legacy/archived", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - - const result = await session.listFetchAgentsEntries({ - type: "fetch_agents_request", - requestId: "req-legacy-global", - }); - - expect(agentIdsFromEntries(result.entries)).toEqual([ - "legacy-active", - "legacy-archived-workspace", ]); - }); + await session.emitWorkspaceUpdateForCwd("/tmp/repo"); - test("fetch_agent_history_request pages archived historical rows separately", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const session = createSessionForWorkspaceTests() as any; - const project = createPersistedProjectRecord({ - projectId: "proj-history", - rootPath: "/tmp/history", - kind: "non_git", - displayName: "history", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-history", - projectId: project.projectId, - cwd: "/tmp/history", - kind: "directory", - displayName: "history", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - archivedAt: "2026-03-02T12:00:00.000Z", - }); - - session.emit = (message: any) => emitted.push(message); - session.projectRegistry.get = async () => project; - session.workspaceRegistry.list = async () => [workspace]; - session.listAgentPayloads = async () => [ - { - ...makeAgent({ - id: "history-archived", - cwd: "/tmp/history", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - archivedAt: "2026-03-02T12:00:00.000Z", - }, - ]; - - await session.handleMessage({ - type: "fetch_agent_history_request", - requestId: "req-history", - page: { limit: 25 }, - }); - - expect(emitted).toEqual([ - { - type: "fetch_agent_history_response", - payload: expect.objectContaining({ - requestId: "req-history", - entries: [ - expect.objectContaining({ - agent: expect.objectContaining({ id: "history-archived" }), - }), - ], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }), - }, - ]); - expect(session.agentUpdatesSubscription).toBeNull(); - }); - - test("fetch_agent_request still resolves archived historical agents", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const session = createSessionForWorkspaceTests() as any; - const agent = { - ...makeAgent({ - id: "archived-history-agent", - cwd: "/tmp/history-detail", - status: "idle", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - archivedAt: "2026-03-02T12:00:00.000Z", - title: "Archived History Agent", - }; - session.emit = (message: any) => emitted.push(message); - session.resolveAgentIdentifier = async (identifier: string) => - identifier === "Archived History Agent" - ? { ok: true, agentId: agent.id } - : { ok: false, error: `Agent not found: ${identifier}` }; - session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); - session.buildProjectPlacementForCwd = async (cwd: string) => ({ - projectKey: "proj-history-detail", - projectName: "history detail", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - await session.handleMessage({ - type: "fetch_agent_request", - requestId: "req-agent-detail", - agentId: "Archived History Agent", - }); - - expect(emitted).toEqual([ - { - type: "fetch_agent_response", - payload: { - requestId: "req-agent-detail", - agent, - project: expect.objectContaining({ - projectKey: "proj-history-detail", - }), - error: null, + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "/tmp/repo", + { + id: "ws-repo-running", + projectId: "proj-repo-running", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", + status: "done", + activityAt: null, }, - }, + ], ]); - }); + await session.emitWorkspaceUpdateForCwd("/tmp/repo"); - test("git branch workspace uses branch as canonical name", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-branch", - projectId: "proj-repo-branch", - cwd: "/tmp/repo-branch", - kind: "local_checkout", - displayName: "feature/name-from-server", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/repo-branch", - status: "running", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo-branch", - checkout: { - cwd, - isGit: true, - currentBranch: "feature/name-from-server", - remoteUrl: "https://github.com/acme/repo-branch.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-branch", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("feature/name-from-server"); - }); - - test("branch/detached policies and dominant status bucket are deterministic", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-status", - projectId: "proj-repo-status", - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/repo", - status: "running", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - makeAgent({ - id: "a2", - cwd: "/tmp/repo", - status: "error", - updatedAt: "2026-03-01T12:01:00.000Z", - }), - makeAgent({ - id: "a3", - cwd: "/tmp/repo", - status: "idle", - updatedAt: "2026-03-01T12:02:00.000Z", - pendingPermissions: 1, - }), - ]; - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-2", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]?.name).toBe("repo"); - expect(result.entries[0]?.status).toBe("needs_input"); - }); - - test("subdirectory agents map to an existing parent workspace descriptor", async () => { - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-subdir", - projectId: "proj-repo-subdir", - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.listAgentPayloads = async () => [ - makeAgent({ - id: "a1", - cwd: "/tmp/repo/packages/app", - status: "running", - updatedAt: "2026-03-01T12:03:00.000Z", - }), - ]; - - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-subdir-agent", - }); - - expect(result.entries).toHaveLength(1); - expect(result.entries[0]).toMatchObject({ - id: "ws-repo-subdir", + const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update"); + expect(workspaceUpdates).toHaveLength(2); + expect((workspaceUpdates[0] as any).payload.kind).toBe("upsert"); + expect((workspaceUpdates[1] as any).payload).toEqual({ + kind: "upsert", + workspace: { + id: "ws-repo-running", + projectId: "proj-repo-running", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "non_git", + workspaceKind: "directory", + name: "repo", status: "done", activityAt: null, - }); + }, }); +}); - test("workspace update stream keeps persisted workspace visible after agents stop", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const logger = { - child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: logger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: () => null, - } as any, - agentStorage: { - list: async () => [], - get: async () => null, - } as any, - projectRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - workspaceRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - checkoutDiffManager: { - subscribe: async () => ({ - initial: { cwd: "/tmp", files: [], error: null }, - unsubscribe: () => {}, - }), - scheduleRefreshForCwd: () => {}, - getMetrics: () => ({ - checkoutDiffTargetCount: 0, - checkoutDiffSubscriptionCount: 0, - checkoutDiffWatcherCount: 0, - checkoutDiffFallbackRefreshTargetCount: 0, - }), - dispose: () => {}, - } as any, - workspaceGitService: createNoopWorkspaceGitService(), - mcpBaseUrl: null, - stt: null, - tts: null, - terminalManager: null, - }) as any; - - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); - - session.buildWorkspaceDescriptorMap = async () => - new Map([ - [ - "/tmp/repo", - { - id: "ws-repo-running", - projectId: "proj-repo-running", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", - name: "repo", - status: "running", - activityAt: "2026-03-01T12:00:00.000Z", - }, - ], - ]); - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); - - session.buildWorkspaceDescriptorMap = async () => - new Map([ - [ - "/tmp/repo", - { - id: "ws-repo-running", - projectId: "proj-repo-running", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", - name: "repo", - status: "done", - activityAt: null, - }, - ], - ]); - await session.emitWorkspaceUpdateForCwd("/tmp/repo"); - - const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update"); - expect(workspaceUpdates).toHaveLength(2); - expect((workspaceUpdates[0] as any).payload.kind).toBe("upsert"); - expect((workspaceUpdates[1] as any).payload).toEqual({ - kind: "upsert", - workspace: { - id: "ws-repo-running", - projectId: "proj-repo-running", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "non_git", - workspaceKind: "directory", - name: "repo", - status: "done", - activityAt: null, - }, - }); - }); - - test("create paseo worktree request returns a registered workspace descriptor", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); - const repoDir = path.join(tempDir, "repo"); - const paseoHome = path.join(tempDir, "paseo-home"); - execSync(`mkdir -p ${repoDir}`); - execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); - execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); - writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); - execSync("git add .", { cwd: repoDir, stdio: "pipe" }); - execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); - const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { - if (cwd === repoDir) { - return createWorkspaceRuntimeSnapshot(cwd, { - git: { - repoRoot: repoDir, - currentBranch: "main", - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - } - - if (cwd.includes("worktree-123")) { - return createWorkspaceRuntimeSnapshot(cwd, { - git: { - repoRoot: cwd, - currentBranch: "worktree-123", - remoteUrl: null, - isPaseoOwnedWorktree: true, - mainRepoRoot: repoDir, - }, - }); - } - +test("create paseo worktree request returns a registered workspace descriptor", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-worktree-test-"))); + const repoDir = path.join(tempDir, "repo"); + const paseoHome = path.join(tempDir, "paseo-home"); + execSync(`mkdir -p ${repoDir}`); + execSync("git init -b main", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" }); + execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoDir, stdio: "pipe" }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.getSnapshot = vi.fn(async (cwd: string) => { + if (cwd === repoDir) { return createWorkspaceRuntimeSnapshot(cwd, { git: { - repoRoot: cwd, + repoRoot: repoDir, currentBranch: "main", remoteUrl: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, }); - }); - const session = createSessionForWorkspaceTests({ - workspaceGitService, - }) as any; - - const workspaces = new Map(); - const projects = new Map(); - session.paseoHome = paseoHome; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async (record: any) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async (record: any) => { - projects.set(record.projectId, record); - }; - session.emit = (message: { type: string; payload: unknown }) => { - emitted.push(message); - }; - try { - await session.handleCreatePaseoWorktreeRequest({ - type: "create_paseo_worktree_request", - cwd: repoDir, - worktreeSlug: "worktree-123", - requestId: "req-worktree", - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); } - const response = emitted.find( - (message) => message.type === "create_paseo_worktree_response", - ) as { type: "create_paseo_worktree_response"; payload: any } | undefined; + if (cwd.includes("worktree-123")) { + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, + currentBranch: "worktree-123", + remoteUrl: null, + isPaseoOwnedWorktree: true, + mainRepoRoot: repoDir, + }, + }); + } - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace).toMatchObject({ - projectDisplayName: "repo", - projectKind: "git", - workspaceKind: "worktree", - name: "worktree-123", - status: "done", - }); - expect(response?.payload.workspace?.id).toContain(path.join("worktree-123")); - expect(workspaces.has(response?.payload.workspace?.id)).toBe(true); - expect(projects.has(response?.payload.workspace?.projectId)).toBe(true); - }); - - test("workspace update fanout for multiple cwd values is deduplicated", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - session.workspaceRegistry.list = async () => [ - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-main", - projectId: "proj-repo-main", - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-feature", - projectId: "proj-repo-main", - cwd: "/tmp/repo/worktree", - kind: "worktree", - displayName: "feature", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ]; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-dedup", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; - session.reconcileActiveWorkspaceRecords = async () => - new Set(["ws-repo-main", "ws-repo-feature"]); - session.buildWorkspaceDescriptorMap = async () => - new Map([ - [ - "ws-repo-main", - { - id: "ws-repo-main", - projectId: "proj-repo-main", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "done", - activityAt: null, - }, - ], - [ - "ws-repo-feature", - { - id: "ws-repo-feature", - projectId: "proj-repo-main", - projectDisplayName: "repo", - projectRootPath: "/tmp/repo", - projectKind: "git", - workspaceKind: "worktree", - name: "feature", - status: "running", - activityAt: "2026-03-01T12:00:00.000Z", - }, - ], - ]); - session.onMessage = (message: { type: string; payload: unknown }) => { - emitted.push(message); - }; - - await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree"); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(2); - expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); - expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ - "ws-repo-feature", - "ws-repo-main", - ]); - }); - - test("open_project_request registers a workspace before any agent exists", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - session.emit = (message: any) => emitted.push(message); - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - worktreeRoot: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - await session.handleMessage({ - type: "open_project_request", - cwd: "/tmp/repo", - requestId: "req-open", - }); - - expect(workspaces.get("/tmp/repo")).toBeTruthy(); - const response = emitted.find((message) => message.type === "open_project_response") as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe("/tmp/repo"); - }); - - test("open_project_request unarchives an existing archived workspace and project", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - const cwd = "/tmp/repo"; - projects.set( - cwd, - createPersistedProjectRecord({ - projectId: cwd, - rootPath: cwd, - kind: "non_git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }), - ); - workspaces.set( - cwd, - createPersistedWorkspaceRecord({ - workspaceId: cwd, - projectId: cwd, - cwd, - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-10T00:00:00.000Z", - archivedAt: "2026-03-10T00:00:00.000Z", - }), - ); - - session.emit = (message: any) => emitted.push(message); - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - - await session.handleMessage({ - type: "open_project_request", - cwd, - requestId: "req-open-unarchive", - }); - - expect(workspaces.get(cwd)?.archivedAt).toBeNull(); - expect(projects.get(cwd)?.archivedAt).toBeNull(); - const response = emitted.find((message) => message.type === "open_project_response") as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(cwd); - }); - - test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - const repoRoot = "/tmp/repo"; - const subdir = "/tmp/repo/packages/app"; - - session.emit = (message: any) => emitted.push(message); - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: repoRoot, - projectName: "repo", - checkout: { - cwd, - isGit: true, + return createWorkspaceRuntimeSnapshot(cwd, { + git: { + repoRoot: cwd, currentBranch: "main", remoteUrl: null, - worktreeRoot: repoRoot, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, }); - - await session.handleMessage({ - type: "open_project_request", - cwd: subdir, - requestId: "req-open-subdir", - }); - - expect(workspaces.get(repoRoot)).toBeTruthy(); - expect(workspaces.has(subdir)).toBe(false); - const response = emitted.find((message) => message.type === "open_project_response") as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.workspace?.id).toBe(repoRoot); }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; - test("list_available_editors_request returns available targets", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }) as any; - - session.emit = (message: any) => emitted.push(message); - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors", + const workspaces = new Map(); + const projects = new Map(); + session.paseoHome = paseoHome; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async (record: any) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async (record: any) => { + projects.set(record.projectId, record); + }; + session.emit = (message: { type: string; payload: unknown }) => { + emitted.push(message); + }; + try { + await session.handleCreatePaseoWorktreeRequest({ + type: "create_paseo_worktree_request", + cwd: repoDir, + worktreeSlug: "worktree-123", + requestId: "req-worktree", }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } - const response = emitted.find( - (message) => message.type === "list_available_editors_response", - ) as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); + const response = emitted.find((message) => message.type === "create_paseo_worktree_response") as + | { type: "create_paseo_worktree_response"; payload: any } + | undefined; + + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace).toMatchObject({ + projectDisplayName: "repo", + projectKind: "git", + workspaceKind: "worktree", + name: "worktree-123", + status: "done", }); + expect(response?.payload.workspace?.id).toContain(path.join("worktree-123")); + expect(workspaces.has(response?.payload.workspace?.id)).toBe(true); + expect(projects.has(response?.payload.workspace?.projectId)).toBe(true); +}); - test("list_available_editors_request coalesces concurrent discovery", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests({ - appVersion: "0.1.50", - onMessage: (message) => emitted.push(message), - }); - let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {}; - let discoveryCalls = 0; - - vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => { - discoveryCalls += 1; - return await new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }); - - const first = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-1", - }); - const second = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-2", - }); - - await Promise.resolve(); - expect(discoveryCalls).toBe(1); - - resolveDiscovery([{ id: "cursor", label: "Cursor" }]); - await Promise.all([first, second]); - - const responses = emitted.filter( - ( - message, - ): message is Extract => - message.type === "list_available_editors_response", - ); - expect(responses).toHaveLength(2); - expect(responses.map((response) => response.payload.requestId)).toEqual([ - "req-editors-1", - "req-editors-2", - ]); - expect(responses.map((response) => response.payload.editors)).toEqual([ - [{ id: "cursor", label: "Cursor" }], - [{ id: "cursor", label: "Cursor" }], - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-3", - }); - - expect(discoveryCalls).toBe(1); - }); - - test("list_available_editors_request filters unsupported ids for legacy clients", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }) as any; - - session.emit = (message: any) => emitted.push(message); - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "unknown-editor", label: "Unknown Editor" }, - { id: "finder", label: "Finder" }, - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-legacy", - }); - - const response = emitted.find( - (message) => message.type === "list_available_editors_response", - ) as any; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "finder", label: "Finder" }, - ]); - }); - - test("open_in_editor_request launches the selected target", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const calls: Array<{ editorId: string; path: string }> = []; - - session.emit = (message: any) => emitted.push(message); - session.openEditorTarget = async (input: { editorId: string; path: string }) => { - calls.push(input); - }; - - await session.handleMessage({ - type: "open_in_editor_request", - requestId: "req-open-editor", - editorId: "vscode", - path: "/tmp/repo", - }); - - expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]); - const response = emitted.find((message) => message.type === "open_in_editor_response") as any; - expect(response?.payload.error).toBeNull(); - }); - - test("archive_workspace_request hides non-destructive workspace records", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-repo-archive", - projectId: "proj-repo-archive", - cwd: "/tmp/repo", - kind: "directory", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - - session.emit = (message: any) => emitted.push(message); - session.workspaceRegistry.get = async () => workspace; - session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => { - workspace.archivedAt = archivedAt; - }; - session.workspaceRegistry.list = async () => [workspace]; - session.projectRegistry.archive = async () => {}; - - await session.handleMessage({ - type: "archive_workspace_request", - workspaceId: "ws-repo-archive", - requestId: "req-archive", - }); - - expect(workspace.archivedAt).toBeTruthy(); - const response = emitted.find( - (message) => message.type === "archive_workspace_response", - ) as any; - expect(response?.payload.error).toBeNull(); - }); - - test.skip("opening a new worktree reconciles older local workspaces into the remote project", async () => { - const emitted: Array<{ type: string; payload: unknown }> = []; - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-reconcile-"))); - const mainWorkspaceId = path.join(tempDir, "inkwell"); - const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); - const localProjectId = mainWorkspaceId; - const remoteProjectId = "remote:github.com/zimakki/inkwell"; - - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); - - projects.set( - localProjectId, - createPersistedProjectRecord({ - projectId: localProjectId, - rootPath: mainWorkspaceId, - kind: "git", - displayName: "inkwell", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - workspaces.set( - mainWorkspaceId, - createPersistedWorkspaceRecord({ - workspaceId: mainWorkspaceId, - projectId: localProjectId, - cwd: mainWorkspaceId, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - - session.emit = (message: any) => emitted.push(message); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-reconcile", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; - session.listAgentPayloads = async () => []; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const existing = projects.get(projectId); - if (!existing) return; - projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: remoteProjectId, - projectName: "zimakki/inkwell", - checkout: { - cwd, - isGit: true, - currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", - remoteUrl: "https://github.com/zimakki/inkwell.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: cwd !== mainWorkspaceId, - mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, - }, - }); - - try { - await session.handleMessage({ - type: "open_project_request", - cwd: worktreeWorkspaceId, - requestId: "req-open-worktree", - }); - - const mainWorkspaceProjectId = workspaces.get(mainWorkspaceId)?.projectId; - expect([localProjectId, remoteProjectId]).toContain(mainWorkspaceProjectId); - expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId); - expect(Boolean(projects.get(localProjectId)?.archivedAt)).toBe( - mainWorkspaceProjectId === remoteProjectId, - ); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as any[]; - expect(workspaceUpdates).toHaveLength(1); - expect(workspaceUpdates[0]?.payload.workspace.id).toBe(worktreeWorkspaceId); - expect(workspaceUpdates[0]?.payload.workspace.projectId).toBe(remoteProjectId); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test.skip("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => { - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-"))); - const mainWorkspaceId = path.join(tempDir, "inkwell"); - const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); - const oldProjectId = "remote:github.com/old-owner/inkwell"; - const newProjectId = "remote:github.com/new-owner/inkwell"; - - execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); - - projects.set( - oldProjectId, - createPersistedProjectRecord({ - projectId: oldProjectId, - rootPath: mainWorkspaceId, - kind: "git", - displayName: "old-owner/inkwell", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - - for (const [workspaceId, displayName] of [ - [mainWorkspaceId, "main"], - [worktreeWorkspaceId, "feature-a"], - ] as const) { - workspaces.set( - workspaceId, - createPersistedWorkspaceRecord({ - workspaceId, - projectId: oldProjectId, - cwd: workspaceId, - kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree", - displayName, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - } - - session.listAgentPayloads = async () => []; - session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { - const existing = projects.get(projectId); - if (!existing) return; - projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: newProjectId, - projectName: "new-owner/inkwell", - checkout: { - cwd, - isGit: true, - currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", - remoteUrl: "https://github.com/new-owner/inkwell.git", - worktreeRoot: cwd, - isPaseoOwnedWorktree: cwd !== mainWorkspaceId, - mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, - }, - }); - - try { - await session.reconcileWorkspaceRecord(mainWorkspaceId); - await session.reconcileWorkspaceRecord(worktreeWorkspaceId); - - const result = await session.listFetchWorkspacesEntries({ - type: "fetch_workspaces_request", - requestId: "req-fetch-reconcile", - }); - - expect(result.entries.map((entry: any) => entry.projectId)).toEqual([ - newProjectId, - newProjectId, - ]); - expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId); - expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId); - expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy(); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test.skip("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => { - const session = createSessionForWorkspaceTests() as any; - const projects = new Map>(); - const workspaces = new Map>(); - - const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-"))); - const repoRoot = path.join(tempDir, "repo"); - const subdirWorkspaceId = path.join(repoRoot, "packages", "app"); - const projectId = "remote:github.com/acme/repo"; - - execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`); - - projects.set( - projectId, - createPersistedProjectRecord({ - projectId, - rootPath: repoRoot, - kind: "git", - displayName: "acme/repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - workspaces.set( - repoRoot, - createPersistedWorkspaceRecord({ - workspaceId: repoRoot, - projectId, - cwd: repoRoot, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - workspaces.set( - subdirWorkspaceId, - createPersistedWorkspaceRecord({ - workspaceId: subdirWorkspaceId, - projectId, - cwd: subdirWorkspaceId, - kind: "directory", - displayName: "app", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }), - ); - - session.projectRegistry.get = async (nextProjectId: string) => - projects.get(nextProjectId) ?? null; - session.projectRegistry.list = async () => Array.from(projects.values()); - session.projectRegistry.upsert = async ( - record: ReturnType, - ) => { - projects.set(record.projectId, record); - }; - session.workspaceRegistry.get = async (workspaceId: string) => - workspaces.get(workspaceId) ?? null; - session.workspaceRegistry.list = async () => Array.from(workspaces.values()); - session.workspaceRegistry.upsert = async ( - record: ReturnType, - ) => { - workspaces.set(record.workspaceId, record); - }; - session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { - const existing = workspaces.get(workspaceId); - if (!existing) return; - workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt }); - }; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: projectId, - projectName: "acme/repo", - checkout: { - cwd, - isGit: true, - currentBranch: "main", - remoteUrl: "https://github.com/acme/repo.git", - worktreeRoot: repoRoot, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - try { - const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId); - - expect(result.changed).toBe(true); - expect(result.workspace.workspaceId).toBe(repoRoot); - expect(result.removedWorkspaceId).toBe(subdirWorkspaceId); - expect(workspaces.get(repoRoot)?.archivedAt).toBeNull(); - expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy(); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => { - const session = createSessionForWorkspaceTests() as any; - const project = createPersistedProjectRecord({ - projectId: "proj-baseline", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-baseline", - projectId: project.projectId, +test("workspace update fanout for multiple cwd values is deduplicated", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-main", + projectId: "proj-repo-main", cwd: "/tmp/repo", kind: "local_checkout", displayName: "main", createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", - }); + }), + createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-feature", + projectId: "proj-repo-main", + cwd: "/tmp/repo/worktree", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-dedup", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => + new Set(["ws-repo-main", "ws-repo-feature"]); + session.buildWorkspaceDescriptorMap = async () => + new Map([ + [ + "ws-repo-main", + { + id: "ws-repo-main", + projectId: "proj-repo-main", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + activityAt: null, + }, + ], + [ + "ws-repo-feature", + { + id: "ws-repo-feature", + projectId: "proj-repo-main", + projectDisplayName: "repo", + projectRootPath: "/tmp/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "running", + activityAt: "2026-03-01T12:00:00.000Z", + }, + ], + ]); + session.onMessage = (message: { type: string; payload: unknown }) => { + emitted.push(message); + }; - session.listAgentPayloads = async () => []; - session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; + await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree"); + await new Promise((resolve) => setTimeout(resolve, 0)); - const baselineDescriptor = { - id: workspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - projectKind: project.kind, - workspaceKind: workspace.kind, - name: "main", - status: "done", - activityAt: null, - diffStat: null, - } as const; - const gitDescriptor = { - ...baselineDescriptor, - diffStat: { additions: 3, deletions: 1 }, - } as const; + const workspaceUpdates = emitted.filter( + (message) => message.type === "workspace_update", + ) as any[]; + expect(workspaceUpdates).toHaveLength(2); + expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(["upsert", "upsert"]); + expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([ + "ws-repo-feature", + "ws-repo-main", + ]); +}); - session.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor); - session.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor); +test("open_project_request registers a workspace before any agent exists", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); - const descriptors = Array.from( - ( - await session.buildWorkspaceDescriptorMap({ - includeGitData: false, - }) - ).values(), - ); - - expect(session.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); - expect(session.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); - expect(descriptors).toEqual([baselineDescriptor]); + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, }); - test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { - git: { + await session.handleMessage({ + type: "open_project_request", + cwd: "/tmp/repo", + requestId: "req-open", + }); + + expect(workspaces.get("/tmp/repo")).toBeTruthy(); + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe("/tmp/repo"); +}); + +test("open_project_request unarchives an existing archived workspace and project", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + + const cwd = "/tmp/repo"; + projects.set( + cwd, + createPersistedProjectRecord({ + projectId: cwd, + rootPath: cwd, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + workspaces.set( + cwd, + createPersistedWorkspaceRecord({ + workspaceId: cwd, + projectId: cwd, + cwd, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-10T00:00:00.000Z", + archivedAt: "2026-03-10T00:00:00.000Z", + }), + ); + + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "open_project_request", + cwd, + requestId: "req-open-unarchive", + }); + + expect(workspaces.get(cwd)?.archivedAt).toBeNull(); + expect(projects.get(cwd)?.archivedAt).toBeNull(); + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(cwd); +}); + +test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + const repoRoot = "/tmp/repo"; + const subdir = "/tmp/repo/packages/app"; + + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: repoRoot, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: repoRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "open_project_request", + cwd: subdir, + requestId: "req-open-subdir", + }); + + expect(workspaces.get(repoRoot)).toBeTruthy(); + expect(workspaces.has(subdir)).toBe(false); + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(repoRoot); +}); + +test("list_available_editors_request returns available targets", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }) as any; + + session.emit = (message: any) => emitted.push(message); + session.getAvailableEditorTargets = async () => + session.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); + + await session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors", + }); + + const response = emitted.find( + (message) => message.type === "list_available_editors_response", + ) as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.editors).toEqual([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "finder", label: "Finder" }, + { id: "unknown-editor", label: "Unknown Editor" }, + ]); +}); + +test("list_available_editors_request coalesces concurrent discovery", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + appVersion: "0.1.50", + onMessage: (message) => emitted.push(message), + }); + let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {}; + let discoveryCalls = 0; + + vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => { + discoveryCalls += 1; + return await new Promise((resolve) => { + resolveDiscovery = resolve; + }); + }); + + const first = session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-1", + }); + const second = session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-2", + }); + + await Promise.resolve(); + expect(discoveryCalls).toBe(1); + + resolveDiscovery([{ id: "cursor", label: "Cursor" }]); + await Promise.all([first, second]); + + const responses = emitted.filter( + ( + message, + ): message is Extract => + message.type === "list_available_editors_response", + ); + expect(responses).toHaveLength(2); + expect(responses.map((response) => response.payload.requestId)).toEqual([ + "req-editors-1", + "req-editors-2", + ]); + expect(responses.map((response) => response.payload.editors)).toEqual([ + [{ id: "cursor", label: "Cursor" }], + [{ id: "cursor", label: "Cursor" }], + ]); + + await session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-3", + }); + + expect(discoveryCalls).toBe(1); +}); + +test("list_available_editors_request filters unsupported ids for legacy clients", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }) as any; + + session.emit = (message: any) => emitted.push(message); + session.getAvailableEditorTargets = async () => + session.filterEditorsForClient([ + { id: "cursor", label: "Cursor" }, + { id: "webstorm", label: "WebStorm" }, + { id: "unknown-editor", label: "Unknown Editor" }, + { id: "finder", label: "Finder" }, + ]); + + await session.handleMessage({ + type: "list_available_editors_request", + requestId: "req-editors-legacy", + }); + + const response = emitted.find( + (message) => message.type === "list_available_editors_response", + ) as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.editors).toEqual([ + { id: "cursor", label: "Cursor" }, + { id: "finder", label: "Finder" }, + ]); +}); + +test("open_in_editor_request launches the selected target", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const calls: Array<{ editorId: string; path: string }> = []; + + session.emit = (message: any) => emitted.push(message); + session.openEditorTarget = async (input: { editorId: string; path: string }) => { + calls.push(input); + }; + + await session.handleMessage({ + type: "open_in_editor_request", + requestId: "req-open-editor", + editorId: "vscode", + path: "/tmp/repo", + }); + + expect(calls).toEqual([{ editorId: "vscode", path: "/tmp/repo" }]); + const response = emitted.find((message) => message.type === "open_in_editor_response") as any; + expect(response?.payload.error).toBeNull(); +}); + +test("archive_workspace_request hides non-destructive workspace records", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-repo-archive", + projectId: "proj-repo-archive", + cwd: "/tmp/repo", + kind: "directory", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.workspaceRegistry.get = async () => workspace; + session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => { + workspace.archivedAt = archivedAt; + }; + session.workspaceRegistry.list = async () => [workspace]; + session.projectRegistry.archive = async () => {}; + + await session.handleMessage({ + type: "archive_workspace_request", + workspaceId: "ws-repo-archive", + requestId: "req-archive", + }); + + expect(workspace.archivedAt).toBeTruthy(); + const response = emitted.find((message) => message.type === "archive_workspace_response") as any; + expect(response?.payload.error).toBeNull(); +}); + +test.skip("opening a new worktree reconciles older local workspaces into the remote project", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-reconcile-"))); + const mainWorkspaceId = path.join(tempDir, "inkwell"); + const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); + const localProjectId = mainWorkspaceId; + const remoteProjectId = "remote:github.com/zimakki/inkwell"; + + execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + + projects.set( + localProjectId, + createPersistedProjectRecord({ + projectId: localProjectId, + rootPath: mainWorkspaceId, + kind: "git", + displayName: "inkwell", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + workspaces.set( + mainWorkspaceId, + createPersistedWorkspaceRecord({ + workspaceId: mainWorkspaceId, + projectId: localProjectId, + cwd: mainWorkspaceId, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + + session.emit = (message: any) => emitted.push(message); + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-reconcile", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.listAgentPayloads = async () => []; + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId); + if (!existing) return; + projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: remoteProjectId, + projectName: "zimakki/inkwell", + checkout: { + cwd, + isGit: true, + currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", + remoteUrl: "https://github.com/zimakki/inkwell.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: cwd !== mainWorkspaceId, + mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + }, + }); + + try { + await session.handleMessage({ + type: "open_project_request", + cwd: worktreeWorkspaceId, + requestId: "req-open-worktree", + }); + + const mainWorkspaceProjectId = workspaces.get(mainWorkspaceId)?.projectId; + expect([localProjectId, remoteProjectId]).toContain(mainWorkspaceProjectId); + expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId); + expect(Boolean(projects.get(localProjectId)?.archivedAt)).toBe( + mainWorkspaceProjectId === remoteProjectId, + ); + + const workspaceUpdates = emitted.filter( + (message) => message.type === "workspace_update", + ) as any[]; + expect(workspaceUpdates).toHaveLength(1); + expect(workspaceUpdates[0]?.payload.workspace.id).toBe(worktreeWorkspaceId); + expect(workspaceUpdates[0]?.payload.workspace.projectId).toBe(remoteProjectId); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test.skip("fetch_workspaces_request reconciles remote URL changes for existing workspaces", async () => { + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-fetch-"))); + const mainWorkspaceId = path.join(tempDir, "inkwell"); + const worktreeWorkspaceId = path.join(mainWorkspaceId, ".paseo", "worktrees", "feature-a"); + const oldProjectId = "remote:github.com/old-owner/inkwell"; + const newProjectId = "remote:github.com/new-owner/inkwell"; + + execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`); + + projects.set( + oldProjectId, + createPersistedProjectRecord({ + projectId: oldProjectId, + rootPath: mainWorkspaceId, + kind: "git", + displayName: "old-owner/inkwell", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + + for (const [workspaceId, displayName] of [ + [mainWorkspaceId, "main"], + [worktreeWorkspaceId, "feature-a"], + ] as const) { + workspaces.set( + workspaceId, + createPersistedWorkspaceRecord({ + workspaceId, + projectId: oldProjectId, + cwd: workspaceId, + kind: workspaceId === mainWorkspaceId ? "local_checkout" : "worktree", + displayName, + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + } + + session.listAgentPayloads = async () => []; + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.projectRegistry.archive = async (projectId: string, archivedAt: string) => { + const existing = projects.get(projectId); + if (!existing) return; + projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt }); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: newProjectId, + projectName: "new-owner/inkwell", + checkout: { + cwd, + isGit: true, + currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", + remoteUrl: "https://github.com/new-owner/inkwell.git", + worktreeRoot: cwd, + isPaseoOwnedWorktree: cwd !== mainWorkspaceId, + mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, + }, + }); + + try { + await session.reconcileWorkspaceRecord(mainWorkspaceId); + await session.reconcileWorkspaceRecord(worktreeWorkspaceId); + + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-fetch-reconcile", + }); + + expect(result.entries.map((entry: any) => entry.projectId)).toEqual([ + newProjectId, + newProjectId, + ]); + expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId); + expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId); + expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test.skip("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => { + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-"))); + const repoRoot = path.join(tempDir, "repo"); + const subdirWorkspaceId = path.join(repoRoot, "packages", "app"); + const projectId = "remote:github.com/acme/repo"; + + execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`); + + projects.set( + projectId, + createPersistedProjectRecord({ + projectId, + rootPath: repoRoot, + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + workspaces.set( + repoRoot, + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + workspaces.set( + subdirWorkspaceId, + createPersistedWorkspaceRecord({ + workspaceId: subdirWorkspaceId, + projectId, + cwd: subdirWorkspaceId, + kind: "directory", + displayName: "app", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + + session.projectRegistry.get = async (nextProjectId: string) => + projects.get(nextProjectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const existing = workspaces.get(workspaceId); + if (!existing) return; + workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt }); + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: projectId, + projectName: "acme/repo", + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: repoRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + try { + const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId); + + expect(result.changed).toBe(true); + expect(result.workspace.workspaceId).toBe(repoRoot); + expect(result.removedWorkspaceId).toBe(subdirWorkspaceId); + expect(workspaces.get(repoRoot)?.archivedAt).toBeNull(); + expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline descriptor path", async () => { + const session = createSessionForWorkspaceTests() as any; + const project = createPersistedProjectRecord({ + projectId: "proj-baseline", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-baseline", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + + const baselineDescriptor = { + id: workspace.workspaceId, + projectId: project.projectId, + projectDisplayName: project.displayName, + projectRootPath: project.rootPath, + projectKind: project.kind, + workspaceKind: workspace.kind, + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as const; + const gitDescriptor = { + ...baselineDescriptor, + diffStat: { additions: 3, deletions: 1 }, + } as const; + + session.describeWorkspaceRecord = vi.fn(async () => baselineDescriptor); + session.describeWorkspaceRecordWithGitData = vi.fn(async () => gitDescriptor); + + const descriptors = Array.from( + ( + await session.buildWorkspaceDescriptorMap({ + includeGitData: false, + }) + ).values(), + ); + + expect(session.describeWorkspaceRecord).toHaveBeenCalledWith(workspace, project); + expect(session.describeWorkspaceRecordWithGitData).not.toHaveBeenCalled(); + expect(descriptors).toEqual([baselineDescriptor]); +}); + +test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "runtime-branch", + isDirty: true, + aheadBehind: { ahead: 3, behind: 1 }, + aheadOfOrigin: 3, + behindOfOrigin: 1, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/456", + title: "Ship runtime payloads", + state: "open", + baseRefName: "main", + headRefName: "runtime-branch", + isMerged: false, + }, + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); + workspaceGitService.registerWorkspace = vi.fn(() => ({ + unsubscribe: () => {}, + })); + + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "proj-runtime-fetch", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-runtime-fetch", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + + session.emit = (message: any) => emitted.push(message); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-runtime", + }); + + const response = emitted.find((message) => message.type === "fetch_workspaces_response") as + | { type: "fetch_workspaces_response"; payload: any } + | undefined; + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(response?.payload.entries).toEqual([ + expect.objectContaining({ + id: "ws-runtime-fetch", + gitRuntime: { currentBranch: "runtime-branch", + remoteUrl: "https://github.com/acme/repo.git", + isPaseoOwnedWorktree: false, isDirty: true, aheadBehind: { ahead: 3, behind: 1 }, aheadOfOrigin: 3, behindOfOrigin: 1, }, - github: { + githubRuntime: { + featuresEnabled: true, pullRequest: { url: "https://github.com/acme/repo/pull/456", title: "Ship runtime payloads", @@ -2582,358 +2658,275 @@ describe("workspace aggregation", () => { headRefName: "runtime-branch", isMerged: false, }, + error: null, }, + }), + ]); +}); + +test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => { + const events: string[] = []; + const emitted: Array<{ type: string; payload: any }> = []; + const workspaceGitService = createNoopWorkspaceGitService(); + const getSnapshot = vi.fn(async (cwd: string) => { + events.push(`git:${cwd}`); + return createWorkspaceRuntimeSnapshot(cwd); + }); + workspaceGitService.getSnapshot = getSnapshot; + workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => { + queueMicrotask(() => { + void getSnapshot(params.cwd); }); - const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); - workspaceGitService.registerWorkspace = vi.fn(() => ({ + return { unsubscribe: () => {}, - })); + }; + }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "proj-fetch-boundary", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-fetch-boundary", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); - const session = createSessionForWorkspaceTests({ - workspaceGitService, - }) as any; - const project = createPersistedProjectRecord({ - projectId: "proj-runtime-fetch", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-runtime-fetch", - projectId: project.projectId, - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); + session.emit = (message: any) => { + if (message.type === "fetch_workspaces_response") { + events.push("response"); + } + emitted.push(message); + }; + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; - session.emit = (message: any) => emitted.push(message); - session.listAgentPayloads = async () => []; - session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: true, - currentBranch: runtimeSnapshot.git.currentBranch, - remoteUrl: runtimeSnapshot.git.remoteUrl, - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces-boundary", + subscribe: {}, + }); + + expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined(); + expect(events[0]).toBe("response"); +}); + +test("workspace_update includes updated runtime fields", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/runtime-payloads", + isDirty: true, + }, + github: { + pullRequest: { + url: "https://github.com/acme/repo/pull/789", + title: "Updated runtime payloads", + state: "merged", + baseRefName: "main", + headRefName: "feature/runtime-payloads", + isMerged: true, }, - }); + }, + }); + const workspaceGitService = createNoopWorkspaceGitService(); + workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-fetch-workspaces-runtime", - }); + const session = createSessionForWorkspaceTests({ + workspaceGitService, + }) as any; + const project = createPersistedProjectRecord({ + projectId: "proj-runtime-update", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-runtime-update", + projectId: project.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); - const response = emitted.find((message) => message.type === "fetch_workspaces_response") as - | { type: "fetch_workspaces_response"; payload: any } - | undefined; + session.emit = (message: any) => emitted.push(message); + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-runtime", + filter: undefined, + isBootstrapping: false, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + }; + session.reconcileActiveWorkspaceRecords = async () => new Set(); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [project]; + session.workspaceRegistry.list = async () => [workspace]; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: runtimeSnapshot.git.currentBranch, + remoteUrl: runtimeSnapshot.git.remoteUrl, + worktreeRoot: cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); - expect(response?.payload.entries).toEqual([ - expect.objectContaining({ - id: "ws-runtime-fetch", - gitRuntime: { - currentBranch: "runtime-branch", - remoteUrl: "https://github.com/acme/repo.git", - isPaseoOwnedWorktree: false, + await session.emitWorkspaceUpdateForCwd("/tmp/repo", { + skipReconcile: true, + }); + + expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); + expect(emitted).toContainEqual({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: expect.objectContaining({ + id: "ws-runtime-update", + gitRuntime: expect.objectContaining({ + currentBranch: "feature/runtime-payloads", isDirty: true, - aheadBehind: { ahead: 3, behind: 1 }, - aheadOfOrigin: 3, - behindOfOrigin: 1, - }, - githubRuntime: { + }), + githubRuntime: expect.objectContaining({ featuresEnabled: true, - pullRequest: { - url: "https://github.com/acme/repo/pull/456", - title: "Ship runtime payloads", - state: "open", - baseRefName: "main", - headRefName: "runtime-branch", - isMerged: false, - }, - error: null, - }, - }), - ]); - }); - - test("fetch_workspaces_response emits before cold registration-triggered git work starts", async () => { - const events: string[] = []; - const emitted: Array<{ type: string; payload: any }> = []; - const workspaceGitService = createNoopWorkspaceGitService(); - const getSnapshot = vi.fn(async (cwd: string) => { - events.push(`git:${cwd}`); - return createWorkspaceRuntimeSnapshot(cwd); - }); - workspaceGitService.getSnapshot = getSnapshot; - workspaceGitService.registerWorkspace = vi.fn((params: { cwd: string }) => { - queueMicrotask(() => { - void getSnapshot(params.cwd); - }); - return { - unsubscribe: () => {}, - }; - }); - const session = createSessionForWorkspaceTests({ - workspaceGitService, - }) as any; - const project = createPersistedProjectRecord({ - projectId: "proj-fetch-boundary", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-fetch-boundary", - projectId: project.projectId, - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - - session.emit = (message: any) => { - if (message.type === "fetch_workspaces_response") { - events.push("response"); - } - emitted.push(message); - }; - session.listAgentPayloads = async () => []; - session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; - - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-fetch-workspaces-boundary", - subscribe: {}, - }); - - expect(emitted.find((message) => message.type === "fetch_workspaces_response")).toBeDefined(); - expect(events[0]).toBe("response"); - }); - - test("workspace_update includes updated runtime fields", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const runtimeSnapshot = createWorkspaceRuntimeSnapshot("/tmp/repo", { - git: { - currentBranch: "feature/runtime-payloads", - isDirty: true, - }, - github: { - pullRequest: { - url: "https://github.com/acme/repo/pull/789", - title: "Updated runtime payloads", - state: "merged", - baseRefName: "main", - headRefName: "feature/runtime-payloads", - isMerged: true, - }, - }, - }); - const workspaceGitService = createNoopWorkspaceGitService(); - workspaceGitService.peekSnapshot = vi.fn(() => runtimeSnapshot); - - const session = createSessionForWorkspaceTests({ - workspaceGitService, - }) as any; - const project = createPersistedProjectRecord({ - projectId: "proj-runtime-update", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-runtime-update", - projectId: project.projectId, - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - - session.emit = (message: any) => emitted.push(message); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-runtime", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; - session.reconcileActiveWorkspaceRecords = async () => new Set(); - session.listAgentPayloads = async () => []; - session.projectRegistry.list = async () => [project]; - session.workspaceRegistry.list = async () => [workspace]; - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: "repo", - checkout: { - cwd, - isGit: true, - currentBranch: runtimeSnapshot.git.currentBranch, - remoteUrl: runtimeSnapshot.git.remoteUrl, - worktreeRoot: cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }); - - await session.emitWorkspaceUpdateForCwd("/tmp/repo", { - skipReconcile: true, - }); - - expect(workspaceGitService.peekSnapshot).toHaveBeenCalledWith("/tmp/repo"); - expect(emitted).toContainEqual({ - type: "workspace_update", - payload: { - kind: "upsert", - workspace: expect.objectContaining({ - id: "ws-runtime-update", - gitRuntime: expect.objectContaining({ - currentBranch: "feature/runtime-payloads", - isDirty: true, - }), - githubRuntime: expect.objectContaining({ - featuresEnabled: true, - pullRequest: expect.objectContaining({ - title: "Updated runtime payloads", - isMerged: true, - }), + pullRequest: expect.objectContaining({ + title: "Updated runtime payloads", + isMerged: true, }), }), - }, - }); - }); - - test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const session = createSessionForWorkspaceTests() as any; - const gitProject = createPersistedProjectRecord({ - projectId: "proj-git-subscribe", - rootPath: "/tmp/repo", - kind: "git", - displayName: "repo", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const directoryProject = createPersistedProjectRecord({ - projectId: "proj-docs-subscribe", - rootPath: "/tmp/docs", - kind: "non_git", - displayName: "docs", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const gitWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-git-subscribe", - projectId: gitProject.projectId, - cwd: "/tmp/repo", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const directoryWorkspace = createPersistedWorkspaceRecord({ - workspaceId: "ws-docs-subscribe", - projectId: directoryProject.projectId, - cwd: "/tmp/docs", - kind: "directory", - displayName: "docs", - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - }); - const baselineGitDescriptor = { - id: gitWorkspace.workspaceId, - projectId: gitProject.projectId, - projectDisplayName: gitProject.displayName, - projectRootPath: gitProject.rootPath, - workspaceDirectory: gitWorkspace.cwd, - projectKind: gitProject.kind, - workspaceKind: gitWorkspace.kind, - name: "main", - status: "done", - activityAt: null, - diffStat: null, - } as const; - const enrichedGitDescriptor = { - ...baselineGitDescriptor, - diffStat: { additions: 3, deletions: 1 }, - } as const; - const directoryDescriptor = { - id: directoryWorkspace.workspaceId, - projectId: directoryProject.projectId, - projectDisplayName: directoryProject.displayName, - projectRootPath: directoryProject.rootPath, - workspaceDirectory: directoryWorkspace.cwd, - projectKind: directoryProject.kind, - workspaceKind: directoryWorkspace.kind, - name: "docs", - status: "done", - activityAt: null, - diffStat: null, - } as const; - - session.emit = (message: any) => emitted.push(message); - session.listAgentPayloads = async () => []; - session.projectRegistry.list = async () => [gitProject, directoryProject]; - session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace]; - session.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {}); - session.describeWorkspaceRecord = vi.fn( - async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => { - if (workspace.workspaceId === gitWorkspace.workspaceId) { - expect(project).toEqual(gitProject); - return baselineGitDescriptor; - } - expect(project).toEqual(directoryProject); - return directoryDescriptor; - }, - ); - session.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor); - - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-fetch-workspaces", - subscribe: {}, - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - const response = emitted.find((message) => message.type === "fetch_workspaces_response") as - | { type: "fetch_workspaces_response"; payload: any } - | undefined; - expect( - response?.payload.entries.map( - (entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [ - entry.id, - entry.diffStat, - ], - ), - ).toEqual([ - [directoryDescriptor.id, directoryDescriptor.diffStat], - [enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat], - ]); - - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as Array<{ type: "workspace_update"; payload: any }>; - expect(workspaceUpdates).toEqual([]); - expect(session.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith( - gitWorkspace, - gitProject, - ); + }), + }, }); }); + +test("subscribed fetch_workspaces includes git enrichment in the initial snapshot", async () => { + const emitted: Array<{ type: string; payload: any }> = []; + const session = createSessionForWorkspaceTests() as any; + const gitProject = createPersistedProjectRecord({ + projectId: "proj-git-subscribe", + rootPath: "/tmp/repo", + kind: "git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const directoryProject = createPersistedProjectRecord({ + projectId: "proj-docs-subscribe", + rootPath: "/tmp/docs", + kind: "non_git", + displayName: "docs", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const gitWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-git-subscribe", + projectId: gitProject.projectId, + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const directoryWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-docs-subscribe", + projectId: directoryProject.projectId, + cwd: "/tmp/docs", + kind: "directory", + displayName: "docs", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + const baselineGitDescriptor = { + id: gitWorkspace.workspaceId, + projectId: gitProject.projectId, + projectDisplayName: gitProject.displayName, + projectRootPath: gitProject.rootPath, + workspaceDirectory: gitWorkspace.cwd, + projectKind: gitProject.kind, + workspaceKind: gitWorkspace.kind, + name: "main", + status: "done", + activityAt: null, + diffStat: null, + } as const; + const enrichedGitDescriptor = { + ...baselineGitDescriptor, + diffStat: { additions: 3, deletions: 1 }, + } as const; + const directoryDescriptor = { + id: directoryWorkspace.workspaceId, + projectId: directoryProject.projectId, + projectDisplayName: directoryProject.displayName, + projectRootPath: directoryProject.rootPath, + workspaceDirectory: directoryWorkspace.cwd, + projectKind: directoryProject.kind, + workspaceKind: directoryWorkspace.kind, + name: "docs", + status: "done", + activityAt: null, + diffStat: null, + } as const; + + session.emit = (message: any) => emitted.push(message); + session.listAgentPayloads = async () => []; + session.projectRegistry.list = async () => [gitProject, directoryProject]; + session.workspaceRegistry.list = async () => [gitWorkspace, directoryWorkspace]; + session.reconcileAndEmitWorkspaceUpdates = vi.fn(async () => {}); + session.describeWorkspaceRecord = vi.fn( + async (workspace: typeof gitWorkspace | typeof directoryWorkspace, project: any) => { + if (workspace.workspaceId === gitWorkspace.workspaceId) { + expect(project).toEqual(gitProject); + return baselineGitDescriptor; + } + expect(project).toEqual(directoryProject); + return directoryDescriptor; + }, + ); + session.describeWorkspaceRecordWithGitData = vi.fn(async () => enrichedGitDescriptor); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-fetch-workspaces", + subscribe: {}, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const response = emitted.find((message) => message.type === "fetch_workspaces_response") as + | { type: "fetch_workspaces_response"; payload: any } + | undefined; + expect( + response?.payload.entries.map( + (entry: typeof baselineGitDescriptor | typeof directoryDescriptor) => [ + entry.id, + entry.diffStat, + ], + ), + ).toEqual([ + [directoryDescriptor.id, directoryDescriptor.diffStat], + [enrichedGitDescriptor.id, enrichedGitDescriptor.diffStat], + ]); + + const workspaceUpdates = emitted.filter( + (message) => message.type === "workspace_update", + ) as Array<{ type: "workspace_update"; payload: any }>; + expect(workspaceUpdates).toEqual([]); + expect(session.describeWorkspaceRecordWithGitData).toHaveBeenCalledWith(gitWorkspace, gitProject); +}); diff --git a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts index 15bd4e2237..6f7db0434b 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/speech-download.e2e.test.ts @@ -96,207 +96,205 @@ function toAudioPcmFormat(format: string): string { return `audio/pcm;${trimmed}`; } -describe("speech models (download E2E)", () => { - downloadTest( - "downloads models and runs streaming STT + streaming TTS", - async () => { - const logger = pino({ level: "silent" }); - const set = getModelSet(); +downloadTest( + "downloads models and runs streaming STT + streaming TTS", + async () => { + const logger = pino({ level: "silent" }); + const set = getModelSet(); - const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-")); - const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech"); + const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-speech-download-")); + const modelsDir = path.join(paseoHomeRoot, ".paseo", "models", "local-speech"); - const modelIds: SherpaOnnxModelId[] = - set === "parakeet-pocket" - ? ["parakeet-tdt-0.6b-v3-int8", "pocket-tts-onnx-int8"] - : ["zipformer-bilingual-zh-en-2023-02-20", "kitten-nano-en-v0_1-fp16"]; + const modelIds: SherpaOnnxModelId[] = + set === "parakeet-pocket" + ? ["parakeet-tdt-0.6b-v3-int8", "pocket-tts-onnx-int8"] + : ["zipformer-bilingual-zh-en-2023-02-20", "kitten-nano-en-v0_1-fp16"]; - await ensureSherpaOnnxModels({ - modelsDir, - modelIds, - logger, - }); + await ensureSherpaOnnxModels({ + modelsDir, + modelIds, + logger, + }); - const ctx = await createDaemonTestContext({ - paseoHomeRoot, - dictationFinalTimeoutMs: 8000, - speech: { - providers: { - dictationStt: { provider: "local", explicit: true }, - voiceStt: { provider: "local", explicit: true }, - voiceTts: { provider: "local", explicit: true }, - }, - local: { - modelsDir, - models: { - dictationStt: - set === "parakeet-pocket" - ? "parakeet-tdt-0.6b-v3-int8" - : "zipformer-bilingual-zh-en-2023-02-20", - voiceStt: - set === "parakeet-pocket" - ? "parakeet-tdt-0.6b-v3-int8" - : "zipformer-bilingual-zh-en-2023-02-20", - voiceTts: - set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16", - }, + const ctx = await createDaemonTestContext({ + paseoHomeRoot, + dictationFinalTimeoutMs: 8000, + speech: { + providers: { + dictationStt: { provider: "local", explicit: true }, + voiceStt: { provider: "local", explicit: true }, + voiceTts: { provider: "local", explicit: true }, + }, + local: { + modelsDir, + models: { + dictationStt: + set === "parakeet-pocket" + ? "parakeet-tdt-0.6b-v3-int8" + : "zipformer-bilingual-zh-en-2023-02-20", + voiceStt: + set === "parakeet-pocket" + ? "parakeet-tdt-0.6b-v3-int8" + : "zipformer-bilingual-zh-en-2023-02-20", + voiceTts: + set === "parakeet-pocket" ? "pocket-tts-onnx-int8" : "kitten-nano-en-v0_1-fp16", }, }, + }, + }); + + try { + const wav = await readFixtureWav(); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + const format = "audio/pcm;rate=16000;bits=16"; + + // Streaming STT: dictation path (verifies websocket streaming + partials + final) + const dictationId = `dict-download-${Date.now()}`; + let partialCount = 0; + const unsubscribe = ctx.client.on("dictation_stream_partial", (message) => { + if (message.type !== "dictation_stream_partial") return; + if (message.payload.dictationId !== dictationId) return; + partialCount += 1; }); - try { - const wav = await readFixtureWav(); - const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); - expect(sampleRate).toBe(16000); - const format = "audio/pcm;rate=16000;bits=16"; - - // Streaming STT: dictation path (verifies websocket streaming + partials + final) - const dictationId = `dict-download-${Date.now()}`; - let partialCount = 0; - const unsubscribe = ctx.client.on("dictation_stream_partial", (message) => { - if (message.type !== "dictation_stream_partial") return; - if (message.payload.dictationId !== dictationId) return; - partialCount += 1; - }); - - await ctx.client.startDictationStream(dictationId, format); - const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 - let seq = 0; - for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { - const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); - seq += 1; - } - const finalSeq = seq - 1; - const dictationFinal = await ctx.client.finishDictationStream(dictationId, finalSeq); - unsubscribe(); - - expect(dictationFinal.text.toLowerCase()).toContain("voice note"); - const baseline = await readBaseline(); - expect(wordSimilarity(dictationFinal.text, baseline)).toBeGreaterThan(0.45); - expect(partialCount).toBeGreaterThan(0); - - // Voice-mode STT: chunked upload until isLast=true - const transcriptionPromise = waitForSignal(30000, (resolve, reject) => { - const offResult = ctx.client.on("transcription_result", (message) => { - if (message.type !== "transcription_result") return; - resolve(message.payload.text); - }); - const offError = ctx.client.on("activity_log", (message) => { - if (message.type !== "activity_log") return; - const payload = message.payload as { type?: unknown; content?: unknown }; - if (payload.type !== "error") return; - const content = typeof payload.content === "string" ? payload.content : null; - if (!content) return; - reject(new Error(content)); - }); - return () => { - offResult(); - offError(); - }; - }); - - const voiceCwd = mkdtempSync(path.join(tmpdir(), "speech-download-voice-agent-")); - const voiceAgent = await ctx.client.createAgent({ - config: { - provider: "codex", - cwd: voiceCwd, - modeId: "full-access", - model: "gpt-5.4-mini", - thinkingOptionId: "low", - }, - }); - await ctx.client.setVoiceMode(true, voiceAgent.id); - for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { - const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - const isLast = offset + chunkBytes >= pcm16.length; - await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast); - } - const voiceText = (await transcriptionPromise).trim().toLowerCase(); - if (voiceText.length > 0) { - expect(voiceText).toContain("voice note"); - } - await ctx.client.setVoiceMode(false); - rmSync(voiceCwd, { recursive: true, force: true }); - - // Streaming TTS: generate locally from downloaded model and validate chunking. - const ttsText = "This is a voice note."; - if (set === "parakeet-pocket") { - const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8"); - const tts = await PocketTtsOnnxTTS.create( - { modelDir, precision: "int8", targetChunkMs: 50 }, - logger, - ); - const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); - const { chunks, combined } = await readAllChunks(stream); - - expect(ttsFormat).toMatch(/rate=\d+/); - expect(chunks.length).toBeGreaterThan(3); - expect(combined.byteLength).toBeGreaterThan(2000); - - // Round trip: TTS -> STT (offline parakeet) - const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v3-int8"); - const engine = new SherpaOfflineRecognizerEngine( - { - model: { - kind: "nemo_transducer", - encoder: `${sttModelDir}/encoder.int8.onnx`, - decoder: `${sttModelDir}/decoder.int8.onnx`, - joiner: `${sttModelDir}/joiner.int8.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - }, - numThreads: 2, - debug: 0, - }, - logger, - ); - const stt = new SherpaOnnxParakeetSTT({ engine }, logger); - const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat)); - engine.free(); - expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25); - } else { - const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16"); - const tts = new SherpaOnnxTTS( - { preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir }, - logger, - ); - const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); - const { chunks, combined } = await readAllChunks(stream); - tts.free(); - - expect(ttsFormat).toMatch(/rate=\d+/); - expect(chunks.length).toBeGreaterThan(3); - expect(combined.byteLength).toBeGreaterThan(2000); - - // Round trip: TTS -> STT (online zipformer, offline segment) - const sttModelDir = path.join( - modelsDir, - "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20", - ); - const engine = new SherpaOnlineRecognizerEngine( - { - model: { - kind: "transducer", - encoder: `${sttModelDir}/encoder-epoch-99-avg-1.onnx`, - decoder: `${sttModelDir}/decoder-epoch-99-avg-1.onnx`, - joiner: `${sttModelDir}/joiner-epoch-99-avg-1.onnx`, - tokens: `${sttModelDir}/tokens.txt`, - modelType: "zipformer", - }, - numThreads: 1, - debug: 0, - }, - logger, - ); - const stt = new SherpaOnnxSTT({ engine }, logger); - const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat)); - engine.free(); - expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25); - } - } finally { - await ctx.cleanup(); + await ctx.client.startDictationStream(dictationId, format); + const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16 + let seq = 0; + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + ctx.client.sendDictationStreamChunk(dictationId, seq, chunk.toString("base64"), format); + seq += 1; } - }, - 15 * 60_000, - ); -}); + const finalSeq = seq - 1; + const dictationFinal = await ctx.client.finishDictationStream(dictationId, finalSeq); + unsubscribe(); + + expect(dictationFinal.text.toLowerCase()).toContain("voice note"); + const baseline = await readBaseline(); + expect(wordSimilarity(dictationFinal.text, baseline)).toBeGreaterThan(0.45); + expect(partialCount).toBeGreaterThan(0); + + // Voice-mode STT: chunked upload until isLast=true + const transcriptionPromise = waitForSignal(30000, (resolve, reject) => { + const offResult = ctx.client.on("transcription_result", (message) => { + if (message.type !== "transcription_result") return; + resolve(message.payload.text); + }); + const offError = ctx.client.on("activity_log", (message) => { + if (message.type !== "activity_log") return; + const payload = message.payload as { type?: unknown; content?: unknown }; + if (payload.type !== "error") return; + const content = typeof payload.content === "string" ? payload.content : null; + if (!content) return; + reject(new Error(content)); + }); + return () => { + offResult(); + offError(); + }; + }); + + const voiceCwd = mkdtempSync(path.join(tmpdir(), "speech-download-voice-agent-")); + const voiceAgent = await ctx.client.createAgent({ + config: { + provider: "codex", + cwd: voiceCwd, + modeId: "full-access", + model: "gpt-5.4-mini", + thinkingOptionId: "low", + }, + }); + await ctx.client.setVoiceMode(true, voiceAgent.id); + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + const isLast = offset + chunkBytes >= pcm16.length; + await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast); + } + const voiceText = (await transcriptionPromise).trim().toLowerCase(); + if (voiceText.length > 0) { + expect(voiceText).toContain("voice note"); + } + await ctx.client.setVoiceMode(false); + rmSync(voiceCwd, { recursive: true, force: true }); + + // Streaming TTS: generate locally from downloaded model and validate chunking. + const ttsText = "This is a voice note."; + if (set === "parakeet-pocket") { + const modelDir = getSherpaOnnxModelDir(modelsDir, "pocket-tts-onnx-int8"); + const tts = await PocketTtsOnnxTTS.create( + { modelDir, precision: "int8", targetChunkMs: 50 }, + logger, + ); + const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); + const { chunks, combined } = await readAllChunks(stream); + + expect(ttsFormat).toMatch(/rate=\d+/); + expect(chunks.length).toBeGreaterThan(3); + expect(combined.byteLength).toBeGreaterThan(2000); + + // Round trip: TTS -> STT (offline parakeet) + const sttModelDir = getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v3-int8"); + const engine = new SherpaOfflineRecognizerEngine( + { + model: { + kind: "nemo_transducer", + encoder: `${sttModelDir}/encoder.int8.onnx`, + decoder: `${sttModelDir}/decoder.int8.onnx`, + joiner: `${sttModelDir}/joiner.int8.onnx`, + tokens: `${sttModelDir}/tokens.txt`, + }, + numThreads: 2, + debug: 0, + }, + logger, + ); + const stt = new SherpaOnnxParakeetSTT({ engine }, logger); + const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat)); + engine.free(); + expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25); + } else { + const ttsModelDir = path.join(modelsDir, "kitten-nano-en-v0_1-fp16"); + const tts = new SherpaOnnxTTS( + { preset: "kitten-nano-en-v0_1-fp16", modelDir: ttsModelDir }, + logger, + ); + const { stream, format: ttsFormat } = await tts.synthesizeSpeech(ttsText); + const { chunks, combined } = await readAllChunks(stream); + tts.free(); + + expect(ttsFormat).toMatch(/rate=\d+/); + expect(chunks.length).toBeGreaterThan(3); + expect(combined.byteLength).toBeGreaterThan(2000); + + // Round trip: TTS -> STT (online zipformer, offline segment) + const sttModelDir = path.join( + modelsDir, + "sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20", + ); + const engine = new SherpaOnlineRecognizerEngine( + { + model: { + kind: "transducer", + encoder: `${sttModelDir}/encoder-epoch-99-avg-1.onnx`, + decoder: `${sttModelDir}/decoder-epoch-99-avg-1.onnx`, + joiner: `${sttModelDir}/joiner-epoch-99-avg-1.onnx`, + tokens: `${sttModelDir}/tokens.txt`, + modelType: "zipformer", + }, + numThreads: 1, + debug: 0, + }, + logger, + ); + const stt = new SherpaOnnxSTT({ engine }, logger); + const rt = await stt.transcribeAudio(combined, toAudioPcmFormat(ttsFormat)); + engine.free(); + expect(wordSimilarity(rt.text, ttsText)).toBeGreaterThan(0.25); + } + } finally { + await ctx.cleanup(); + } + }, + 15 * 60_000, +); diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index c776a2580a..a0b1acad48 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -7,6 +7,41 @@ import pino from "pino"; import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { OpenAITTS } from "./speech/providers/openai/tts.js"; +import type { SessionOutboundMessage } from "../shared/messages.js"; + +type SessionMessage = Extract< + SessionOutboundMessage, + { type: T } +>; + +function makeTranscriptionHandler(resolve: (value: string) => void) { + return (msg: SessionMessage<"transcription_result">) => { + if (msg.type !== "transcription_result") return; + const text = String(msg.payload.text ?? "").trim(); + if (!text) return; + resolve(text); + }; +} + +function makeErrorHandler(reject: (error: Error) => void) { + return (msg: SessionMessage<"activity_log">) => { + if (msg.type !== "activity_log") return; + if (msg.payload.type !== "error") return; + reject(new Error(String(msg.payload.content))); + }; +} + +function makeSpeakToolHandler(resolve: (value: string) => void) { + return (msg: SessionMessage<"agent_stream">) => { + if (msg.type !== "agent_stream") return; + if (msg.payload.event.type !== "timeline") return; + const item = msg.payload.event.item; + if (item.type !== "tool_call") return; + const name = String(item.name ?? ""); + if (!name.toLowerCase().includes("speak")) return; + resolve(name); + }; +} const openaiApiKey = process.env.OPENAI_API_KEY ?? null; const shouldRun = @@ -84,17 +119,11 @@ function waitForSignal( expect(voiceMode.accepted).toBe(true); const transcriptionPromise = waitForSignal(120000, (resolve, reject) => { - const offTranscript = ctx.client.on("transcription_result", (msg) => { - if (msg.type !== "transcription_result") return; - const text = String(msg.payload.text ?? "").trim(); - if (!text) return; - resolve(text); - }); - const offError = ctx.client.on("activity_log", (msg) => { - if (msg.type !== "activity_log") return; - if (msg.payload.type !== "error") return; - reject(new Error(String(msg.payload.content))); - }); + const offTranscript = ctx.client.on( + "transcription_result", + makeTranscriptionHandler(resolve), + ); + const offError = ctx.client.on("activity_log", makeErrorHandler(reject)); return () => { offTranscript(); offError(); @@ -102,20 +131,8 @@ function waitForSignal( }); const speakToolPromise = waitForSignal(120000, (resolve, reject) => { - const offStream = ctx.client.on("agent_stream", (msg) => { - if (msg.type !== "agent_stream") return; - if (msg.payload.event.type !== "timeline") return; - const item = msg.payload.event.item; - if (item.type !== "tool_call") return; - const name = String(item.name ?? ""); - if (!name.toLowerCase().includes("speak")) return; - resolve(name); - }); - const offError = ctx.client.on("activity_log", (msg) => { - if (msg.type !== "activity_log") return; - if (msg.payload.type !== "error") return; - reject(new Error(String(msg.payload.content))); - }); + const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve)); + const offError = ctx.client.on("activity_log", makeErrorHandler(reject)); return () => { offStream(); offError(); diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index 48d2aa1f93..cd731c956c 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -10,6 +10,82 @@ import { OpenAITTS } from "./speech/providers/openai/tts.js"; import { OpenAISTT } from "./speech/providers/openai/stt.js"; import { STTManager } from "./agent/stt-manager.js"; import { withTimeout } from "../utils/promise-timeout.js"; +import type { SessionOutboundMessage } from "../shared/messages.js"; + +type SessionMessage = Extract< + SessionOutboundMessage, + { type: T } +>; + +interface AudioOutputState { + targetGroupId: string | null; + chunks: Array<{ index: number; bytes: Buffer }>; + format: string; +} + +function makeTranscriptionHandler( + resolve: (value: { text: string; isLowConfidence: boolean }) => void, +) { + return (message: SessionMessage<"transcription_result">) => { + if (message.type !== "transcription_result") { + return; + } + resolve({ + text: String(message.payload.text ?? ""), + isLowConfidence: Boolean(message.payload.isLowConfidence), + }); + }; +} + +function byIndex(a: { index: number }, b: { index: number }): number { + return a.index - b.index; +} + +function chunkBytes(entry: { bytes: Buffer }): Buffer { + return entry.bytes; +} + +function makeAudioOutputHandler( + state: AudioOutputState, + resolve: (value: { format: string; chunks: Buffer[] }) => void, +) { + return (message: SessionMessage<"audio_output">) => { + if (message.type !== "audio_output") { + return; + } + const payload = message.payload; + if (!state.targetGroupId) { + state.targetGroupId = payload.groupId; + state.format = payload.format; + } + if (payload.groupId !== state.targetGroupId) { + return; + } + state.chunks.push({ + index: payload.chunkIndex, + bytes: Buffer.from(payload.audio, "base64"), + }); + if (payload.isLastChunk) { + state.chunks.sort(byIndex); + resolve({ + format: state.format, + chunks: state.chunks.map(chunkBytes), + }); + } + }; +} + +function makeActivityErrorHandler(reject: (error: Error) => void) { + return (message: SessionMessage<"activity_log">) => { + if (message.type !== "activity_log") { + return; + } + if (message.payload.type !== "error") { + return; + } + reject(new Error(String(message.payload.content))); + }; +} const openaiApiKey = process.env.OPENAI_API_KEY ?? null; const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey); @@ -80,243 +156,202 @@ async function streamToBuffer(stream: Readable): Promise { return Buffer.concat(chunks); } -describe("voice roundtrip e2e", () => { - let ctx: DaemonTestContext; +let ctx: DaemonTestContext; - beforeAll(async () => { - ctx = await createDaemonTestContext({ - agentClients: {}, - openai: { apiKey: openaiApiKey! }, - speech: { - providers: { - dictationStt: { provider: "openai", explicit: true }, - voiceStt: { provider: "openai", explicit: true }, - voiceTts: { provider: "openai", explicit: true }, +beforeAll(async () => { + ctx = await createDaemonTestContext({ + agentClients: {}, + openai: { apiKey: openaiApiKey! }, + speech: { + providers: { + dictationStt: { provider: "openai", explicit: true }, + voiceStt: { provider: "openai", explicit: true }, + voiceTts: { provider: "openai", explicit: true }, + }, + }, + }); +}, 60000); + +afterAll(async () => { + await ctx.cleanup(); +}, 60000); + +for (const targetProvider of [ + "claude", + "codex", + "opencode", +] as const satisfies VoiceRoundtripProvider[]) { + speechTest( + `full roundtrip (${targetProvider}): voice input audio -> voice agent -> output audio -> transcribed output`, + async () => { + const logger = pino({ level: "silent" }); + const ttsProvider = new OpenAITTS( + { + apiKey: openaiApiKey!, + responseFormat: "pcm", + voice: "alloy", }, - }, - }); - }, 60000); + logger, + ); + const sttProvider = new OpenAISTT( + { + apiKey: openaiApiKey!, + model: "gpt-4o-mini-transcribe", + }, + logger, + ); + const sttOutput = new STTManager("voice-roundtrip-e2e", logger, sttProvider); - afterAll(async () => { - await ctx.cleanup(); - }, 60000); - - for (const targetProvider of [ - "claude", - "codex", - "opencode", - ] as const satisfies VoiceRoundtripProvider[]) { - speechTest( - `full roundtrip (${targetProvider}): voice input audio -> voice agent -> output audio -> transcribed output`, - async () => { - const logger = pino({ level: "silent" }); - const ttsProvider = new OpenAITTS( - { - apiKey: openaiApiKey!, - responseFormat: "pcm", - voice: "alloy", + const voiceCwd = mkdtempSync(path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`)); + const voiceAgent = await withTimeout( + ctx.client.createAgent({ + config: { + ...getVoiceRoundtripConfig(targetProvider), + cwd: voiceCwd, }, - logger, - ); - const sttProvider = new OpenAISTT( - { - apiKey: openaiApiKey!, - model: "gpt-4o-mini-transcribe", - }, - logger, - ); - const sttOutput = new STTManager("voice-roundtrip-e2e", logger, sttProvider); + }), + 30000, + "Timed out during createVoiceTargetAgent after 30000ms", + ); + const voiceAgentId = voiceAgent.id; + const voiceMode = await withTimeout( + ctx.client.setVoiceMode(true, voiceAgentId), + 15000, + "Timed out during setVoiceMode after 15000ms", + ); + expect(voiceMode.accepted).toBe(true); + expect(voiceMode.enabled).toBe(true); + const timelineTools: string[] = []; + const timelineToolAgentIds = new Set(); + const activityErrors: string[] = []; - const voiceCwd = mkdtempSync( - path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`), - ); - const voiceAgent = await withTimeout( - ctx.client.createAgent({ - config: { - ...getVoiceRoundtripConfig(targetProvider), - cwd: voiceCwd, - }, - }), - 30000, - "Timed out during createVoiceTargetAgent after 30000ms", - ); - const voiceAgentId = voiceAgent.id; - const voiceMode = await withTimeout( - ctx.client.setVoiceMode(true, voiceAgentId), - 15000, - "Timed out during setVoiceMode after 15000ms", - ); - expect(voiceMode.accepted).toBe(true); - expect(voiceMode.enabled).toBe(true); - const timelineTools: string[] = []; - const timelineToolAgentIds = new Set(); - const activityErrors: string[] = []; - - const offStream = ctx.client.on("agent_stream", (message) => { - if (message.type !== "agent_stream") { - return; - } - if (message.payload.event.type !== "timeline") { - return; - } - const item = message.payload.event.item; - if (item.type !== "tool_call") { - return; - } - timelineToolAgentIds.add(message.payload.agentId); - timelineTools.push(String(item.name ?? "")); - }); - const offErrors = ctx.client.on("activity_log", (message) => { - if (message.type !== "activity_log") { - return; - } - if (message.payload.type !== "error") { - return; - } - activityErrors.push(String(message.payload.content ?? "")); - }); - - const inputSpeech = await withTimeout( - ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."), - 30000, - "Timed out during synthesizeInputAudio after 30000ms", - ); - const inputPcm = await withTimeout( - streamToBuffer(inputSpeech.stream), - 15000, - "Timed out during collectInputAudio after 15000ms", - ); - const outputAudio = await (async () => { - try { - const transcriptPromise = waitForSignal<{ text: string; isLowConfidence: boolean }>( - 30000, - (resolve) => { - const offTranscript = ctx.client.on("transcription_result", (message) => { - if (message.type !== "transcription_result") { - return; - } - resolve({ - text: String(message.payload.text ?? ""), - isLowConfidence: Boolean(message.payload.isLowConfidence), - }); - }); - return () => { - offTranscript(); - }; - }, - ); - - const outputAudioPromise = waitForSignal<{ - format: string; - chunks: Buffer[]; - }>(90000, (resolve, reject) => { - let targetGroupId: string | null = null; - const chunks: Array<{ index: number; bytes: Buffer }> = []; - let format = "pcm"; - - const offAudio = ctx.client.on("audio_output", (message) => { - if (message.type !== "audio_output") { - return; - } - const payload = message.payload; - if (!targetGroupId) { - targetGroupId = payload.groupId; - format = payload.format; - } - if (payload.groupId !== targetGroupId) { - return; - } - chunks.push({ - index: payload.chunkIndex, - bytes: Buffer.from(payload.audio, "base64"), - }); - if (payload.isLastChunk) { - chunks.sort((a, b) => a.index - b.index); - resolve({ - format, - chunks: chunks.map((entry) => entry.bytes), - }); - } - }); - - const offError = ctx.client.on("activity_log", (message) => { - if (message.type !== "activity_log") { - return; - } - if (message.payload.type !== "error") { - return; - } - reject(new Error(String(message.payload.content))); - }); - - return () => { - offAudio(); - offError(); - }; - }); - - const format = "audio/pcm;rate=24000;bits=16"; - const chunkBytes = 4800; // 100ms @ 24kHz mono PCM16 - for (let offset = 0; offset < inputPcm.length; offset += chunkBytes) { - const chunk = inputPcm.subarray( - offset, - Math.min(inputPcm.length, offset + chunkBytes), - ); - const isLast = offset + chunkBytes >= inputPcm.length; - await withTimeout( - ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast), - 5000, - "Timed out during sendVoiceAudioChunk after 5000ms", - ); - } - const transcript = await withTimeout( - transcriptPromise, - 35000, - "Timed out during waitForTranscription after 35000ms", - ); - if (transcript.text.trim().length === 0) { - throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`); - } - return await withTimeout( - outputAudioPromise, - 95000, - "Timed out during waitForAudioOutput after 95000ms", - ); - } catch (error) { - throw new Error( - `${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`, - { cause: error }, - ); - } finally { - offStream(); - offErrors(); - await ctx.client.setVoiceMode(false).catch(() => undefined); - rmSync(voiceCwd, { recursive: true, force: true }); - } - })(); - - const outputRaw = Buffer.concat(outputAudio.chunks); - let outputFormat: string; - if (outputAudio.format === "pcm") { - outputFormat = "audio/pcm;rate=24000;bits=16"; - } else if (outputAudio.format.includes("wav")) { - outputFormat = "audio/wav"; - } else { - outputFormat = `audio/${outputAudio.format}`; + const offStream = ctx.client.on("agent_stream", (message) => { + if (message.type !== "agent_stream") { + return; } - const transcription = await withTimeout( - sttOutput.transcribe(outputRaw, outputFormat, { - label: "voice-roundtrip-output", - }), - 60000, - "Timed out during transcribeOutputAudio after 60000ms", - ); - const normalized = transcription.text.trim().toLowerCase(); + if (message.payload.event.type !== "timeline") { + return; + } + const item = message.payload.event.item; + if (item.type !== "tool_call") { + return; + } + timelineToolAgentIds.add(message.payload.agentId); + timelineTools.push(String(item.name ?? "")); + }); + const offErrors = ctx.client.on("activity_log", (message) => { + if (message.type !== "activity_log") { + return; + } + if (message.payload.type !== "error") { + return; + } + activityErrors.push(String(message.payload.content ?? "")); + }); - expect(normalized.length).toBeGreaterThan(0); - expect(normalized).toMatch(/round|trip|successful/); - }, - 180000, - ); - } -}); + const inputSpeech = await withTimeout( + ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."), + 30000, + "Timed out during synthesizeInputAudio after 30000ms", + ); + const inputPcm = await withTimeout( + streamToBuffer(inputSpeech.stream), + 15000, + "Timed out during collectInputAudio after 15000ms", + ); + let outputAudio: { format: string; chunks: Buffer[] }; + try { + const transcriptPromise = waitForSignal<{ text: string; isLowConfidence: boolean }>( + 30000, + (resolve) => { + const offTranscript = ctx.client.on( + "transcription_result", + makeTranscriptionHandler(resolve), + ); + return () => { + offTranscript(); + }; + }, + ); + + const outputAudioPromise = waitForSignal<{ + format: string; + chunks: Buffer[]; + }>(90000, (resolve, reject) => { + const audioState: AudioOutputState = { + targetGroupId: null, + chunks: [], + format: "pcm", + }; + const offAudio = ctx.client.on( + "audio_output", + makeAudioOutputHandler(audioState, resolve), + ); + const offError = ctx.client.on("activity_log", makeActivityErrorHandler(reject)); + + return () => { + offAudio(); + offError(); + }; + }); + + const format = "audio/pcm;rate=24000;bits=16"; + const chunkBytes = 4800; // 100ms @ 24kHz mono PCM16 + for (let offset = 0; offset < inputPcm.length; offset += chunkBytes) { + const chunk = inputPcm.subarray(offset, Math.min(inputPcm.length, offset + chunkBytes)); + const isLast = offset + chunkBytes >= inputPcm.length; + await withTimeout( + ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast), + 5000, + "Timed out during sendVoiceAudioChunk after 5000ms", + ); + } + const transcript = await withTimeout( + transcriptPromise, + 35000, + "Timed out during waitForTranscription after 35000ms", + ); + if (transcript.text.trim().length === 0) { + throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`); + } + outputAudio = await withTimeout( + outputAudioPromise, + 95000, + "Timed out during waitForAudioOutput after 95000ms", + ); + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`, + { cause: error }, + ); + } finally { + offStream(); + offErrors(); + await ctx.client.setVoiceMode(false).catch(() => undefined); + rmSync(voiceCwd, { recursive: true, force: true }); + } + + const outputRaw = Buffer.concat(outputAudio.chunks); + let outputFormat: string; + if (outputAudio.format === "pcm") { + outputFormat = "audio/pcm;rate=24000;bits=16"; + } else if (outputAudio.format.includes("wav")) { + outputFormat = "audio/wav"; + } else { + outputFormat = `audio/${outputAudio.format}`; + } + const transcription = await withTimeout( + sttOutput.transcribe(outputRaw, outputFormat, { + label: "voice-roundtrip-output", + }), + 60000, + "Timed out during transcribeOutputAudio after 60000ms", + ); + const normalized = transcription.text.trim().toLowerCase(); + + expect(normalized.length).toBeGreaterThan(0); + expect(normalized).toMatch(/round|trip|successful/); + }, + 180000, + ); +} diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index e85fa56d09..9dab9b97fc 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -407,12 +407,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { const getCheckoutStatus = vi .fn<(cwd: string) => Promise>() .mockImplementationOnce(async (cwd: string) => createCheckoutStatus(cwd)) - .mockImplementationOnce(async () => - refreshStatus.promise.then((status) => ({ - ...status, - currentBranch: "feature", - })), - ); + .mockImplementationOnce(async () => { + const status = await refreshStatus.promise; + return { ...status, currentBranch: "feature" }; + }); const getCheckoutShortstat = vi.fn(async () => ({ additions: 4, deletions: 2 })); const service = createService({ getCheckoutStatus, diff --git a/packages/server/src/tasks/task-store.test.ts b/packages/server/src/tasks/task-store.test.ts index 51a8fdb19c..6c3a0b889e 100644 --- a/packages/server/src/tasks/task-store.test.ts +++ b/packages/server/src/tasks/task-store.test.ts @@ -4,1143 +4,1115 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { FileTaskStore } from "./task-store.js"; -describe("FileTaskStore", () => { - let tempDir: string; - let store: FileTaskStore; +let tempDir: string; +let store: FileTaskStore; - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "task-store-test-")); - store = new FileTaskStore(tempDir); +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "task-store-test-")); + store = new FileTaskStore(tempDir); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +describe("create", () => { + it("creates a task with default status open", async () => { + const task = await store.create("My first task"); + + expect(task.id).toMatch(/^[a-f0-9]{8}$/); + expect(task.title).toBe("My first task"); + expect(task.status).toBe("open"); + expect(task.deps).toEqual([]); + expect(task.parentId).toBeUndefined(); + expect(task.body).toBe(""); + expect(task.notes).toEqual([]); + expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(task.assignee).toBeUndefined(); + expect(task.priority).toBeUndefined(); }); - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); + it("creates a task with priority", async () => { + const task = await store.create("High priority task", { priority: 1 }); + + expect(task.priority).toBe(1); }); - describe("create", () => { - it("creates a task with default status open", async () => { - const task = await store.create("My first task"); + it("creates a task with custom status", async () => { + const task = await store.create("Draft task", { status: "draft" }); - expect(task.id).toMatch(/^[a-f0-9]{8}$/); - expect(task.title).toBe("My first task"); - expect(task.status).toBe("open"); - expect(task.deps).toEqual([]); - expect(task.parentId).toBeUndefined(); - expect(task.body).toBe(""); - expect(task.notes).toEqual([]); - expect(task.created).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(task.assignee).toBeUndefined(); - expect(task.priority).toBeUndefined(); - }); - - it("creates a task with priority", async () => { - const task = await store.create("High priority task", { priority: 1 }); - - expect(task.priority).toBe(1); - }); - - it("creates a task with custom status", async () => { - const task = await store.create("Draft task", { status: "draft" }); - - expect(task.status).toBe("draft"); - }); - - it("creates a task with dependencies", async () => { - const dep1 = await store.create("Dependency 1"); - const dep2 = await store.create("Dependency 2"); - const task = await store.create("Main task", { - deps: [dep1.id, dep2.id], - }); - - expect(task.deps).toEqual([dep1.id, dep2.id]); - }); - - it("creates a task with body", async () => { - const task = await store.create("Task with body", { - body: "This is a **long** body\n\nWith multiple lines.", - }); - - expect(task.body).toBe("This is a **long** body\n\nWith multiple lines."); - }); - - it("creates a task with parentId", async () => { - const parent = await store.create("Parent task"); - const child = await store.create("Child task", { parentId: parent.id }); - - expect(child.parentId).toBe(parent.id); - }); - - it("throws when creating task with non-existent parent", async () => { - await expect(store.create("Child", { parentId: "nonexistent" })).rejects.toThrow( - "Parent task not found", - ); - }); - - it("creates a task with assignee", async () => { - const task = await store.create("Task for Claude", { - assignee: "claude", - }); - - expect(task.assignee).toBe("claude"); - }); - - it("generates unique IDs for each task", async () => { - const task1 = await store.create("Task 1"); - const task2 = await store.create("Task 2"); - const task3 = await store.create("Task 3"); - - const ids = [task1.id, task2.id, task3.id]; - expect(new Set(ids).size).toBe(3); - }); - - it("sets created timestamp", async () => { - const before = new Date().toISOString(); - const task = await store.create("Task"); - const after = new Date().toISOString(); - - expect(task.created >= before).toBe(true); - expect(task.created <= after).toBe(true); - }); - - it("returns task with raw content", async () => { - const task = await store.create("Test task", { - body: "Task body here", - acceptanceCriteria: ["criterion 1", "criterion 2"], - }); - - expect(task.raw).toContain("---"); - expect(task.raw).toContain("title: Test task"); - expect(task.raw).toContain("Task body here"); - expect(task.raw).toContain("## Acceptance Criteria"); - expect(task.raw).toContain("criterion 1"); - expect(task.raw).toContain("criterion 2"); - }); + expect(task.status).toBe("draft"); }); - describe("get", () => { - it("returns task by id", async () => { - const created = await store.create("Test task"); - const retrieved = await store.get(created.id); - - expect(retrieved).toEqual(created); + it("creates a task with dependencies", async () => { + const dep1 = await store.create("Dependency 1"); + const dep2 = await store.create("Dependency 2"); + const task = await store.create("Main task", { + deps: [dep1.id, dep2.id], }); - it("returns null for non-existent task", async () => { - const result = await store.get("nonexistent"); - - expect(result).toBeNull(); - }); - - it("preserves assignee field", async () => { - const created = await store.create("Task", { assignee: "codex" }); - const retrieved = await store.get(created.id); - - expect(retrieved?.assignee).toBe("codex"); - }); - - it("returns raw content matching the file", async () => { - const created = await store.create("Task with body", { - body: "Some body content", - acceptanceCriteria: ["test passes", "lint passes"], - }); - await store.addNote(created.id, "A note was added"); - - const retrieved = await store.get(created.id); - - expect(retrieved?.raw).toContain("title: Task with body"); - expect(retrieved?.raw).toContain("Some body content"); - expect(retrieved?.raw).toContain("## Acceptance Criteria"); - expect(retrieved?.raw).toContain("test passes"); - expect(retrieved?.raw).toContain("lint passes"); - expect(retrieved?.raw).toContain("## Notes"); - expect(retrieved?.raw).toContain("A note was added"); - }); + expect(task.deps).toEqual([dep1.id, dep2.id]); }); - describe("list", () => { - it("returns empty array when no tasks", async () => { - const tasks = await store.list(); - - expect(tasks).toEqual([]); + it("creates a task with body", async () => { + const task = await store.create("Task with body", { + body: "This is a **long** body\n\nWith multiple lines.", }); - it("returns all tasks", async () => { - await store.create("Task 1"); - await store.create("Task 2"); - await store.create("Task 3"); - - const tasks = await store.list(); - - expect(tasks).toHaveLength(3); - expect(tasks.map((t) => t.title).sort()).toEqual(["Task 1", "Task 2", "Task 3"]); - }); + expect(task.body).toBe("This is a **long** body\n\nWith multiple lines."); }); - describe("update", () => { - it("updates task title", async () => { - const task = await store.create("Original title"); - const updated = await store.update(task.id, { title: "New title" }); + it("creates a task with parentId", async () => { + const parent = await store.create("Parent task"); + const child = await store.create("Child task", { parentId: parent.id }); - expect(updated.title).toBe("New title"); - expect(updated.id).toBe(task.id); - }); - - it("updates task body", async () => { - const task = await store.create("Task"); - const updated = await store.update(task.id, { - body: "New body", - }); - - expect(updated.body).toBe("New body"); - }); - - it("updates task assignee", async () => { - const task = await store.create("Task"); - const updated = await store.update(task.id, { assignee: "claude" }); - - expect(updated.assignee).toBe("claude"); - }); - - it("persists updates", async () => { - const task = await store.create("Task"); - await store.update(task.id, { title: "Updated" }); - - const retrieved = await store.get(task.id); - expect(retrieved?.title).toBe("Updated"); - }); - - it("preserves created timestamp on update", async () => { - const task = await store.create("Task"); - const originalCreated = task.created; - - await new Promise((r) => setTimeout(r, 10)); - await store.update(task.id, { title: "Updated" }); - - const retrieved = await store.get(task.id); - expect(retrieved?.created).toBe(originalCreated); - }); - - it("throws for non-existent task", async () => { - await expect(store.update("nonexistent", { title: "New" })).rejects.toThrow(); - }); - - it("replaces acceptance criteria", async () => { - const task = await store.create("Task", { - acceptanceCriteria: ["old criterion 1", "old criterion 2"], - }); - - const updated = await store.update(task.id, { - acceptanceCriteria: ["new criterion"], - }); - - expect(updated.acceptanceCriteria).toEqual(["new criterion"]); - }); - - it("clears acceptance criteria with empty array", async () => { - const task = await store.create("Task", { - acceptanceCriteria: ["criterion 1", "criterion 2"], - }); - - const updated = await store.update(task.id, { - acceptanceCriteria: [], - }); - - expect(updated.acceptanceCriteria).toEqual([]); - }); + expect(child.parentId).toBe(parent.id); }); - describe("delete", () => { - it("deletes a task", async () => { - const task = await store.create("Task to delete"); - await store.delete(task.id); - - const retrieved = await store.get(task.id); - expect(retrieved).toBeNull(); - }); - - it("removes task from list", async () => { - const task1 = await store.create("Task 1"); - const task2 = await store.create("Task 2"); - await store.delete(task1.id); - - const tasks = await store.list(); - expect(tasks).toHaveLength(1); - expect(tasks[0].id).toBe(task2.id); - }); - - it("throws for non-existent task", async () => { - await expect(store.delete("nonexistent")).rejects.toThrow(); - }); + it("throws when creating task with non-existent parent", async () => { + await expect(store.create("Child", { parentId: "nonexistent" })).rejects.toThrow( + "Parent task not found", + ); }); - describe("status transitions", () => { - describe("open", () => { - it("transitions draft to open", async () => { - const task = await store.create("Draft", { status: "draft" }); - await store.open(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("open"); - }); - - it("reopens a done task", async () => { - const task = await store.create("Task"); - await store.close(task.id); - await store.open(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("open"); - }); - - it("reopens a failed task", async () => { - const task = await store.create("Task"); - await store.fail(task.id); - await store.open(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("open"); - }); - - it("reopens an in_progress task", async () => { - const task = await store.create("Task"); - await store.start(task.id); - await store.open(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("open"); - }); - - it("is idempotent for already open task", async () => { - const task = await store.create("Task"); - await store.open(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("open"); - }); + it("creates a task with assignee", async () => { + const task = await store.create("Task for Claude", { + assignee: "claude", }); - describe("start", () => { - it("transitions open to in_progress", async () => { - const task = await store.create("Task"); - await store.start(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("in_progress"); - }); - - it("throws when task is draft", async () => { - const task = await store.create("Draft", { status: "draft" }); - - await expect(store.start(task.id)).rejects.toThrow(); - }); - - it("throws when task is already done", async () => { - const task = await store.create("Task"); - await store.close(task.id); - - await expect(store.start(task.id)).rejects.toThrow(); - }); - }); - - describe("close", () => { - it("transitions open to done", async () => { - const task = await store.create("Task"); - await store.close(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("done"); - }); - - it("transitions in_progress to done", async () => { - const task = await store.create("Task"); - await store.start(task.id); - await store.close(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("done"); - }); - - it("transitions draft to done", async () => { - const task = await store.create("Task", { status: "draft" }); - await store.close(task.id); - - const updated = await store.get(task.id); - expect(updated?.status).toBe("done"); - }); - }); + expect(task.assignee).toBe("claude"); }); - describe("dependencies", () => { - describe("addDep", () => { - it("adds a dependency", async () => { - const dep = await store.create("Dependency"); - const task = await store.create("Task"); + it("generates unique IDs for each task", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2"); + const task3 = await store.create("Task 3"); - await store.addDep(task.id, dep.id); - - const updated = await store.get(task.id); - expect(updated?.deps).toContain(dep.id); - }); - - it("does not duplicate dependencies", async () => { - const dep = await store.create("Dependency"); - const task = await store.create("Task"); - - await store.addDep(task.id, dep.id); - await store.addDep(task.id, dep.id); - - const updated = await store.get(task.id); - expect(updated?.deps).toEqual([dep.id]); - }); - - it("throws for non-existent task", async () => { - const dep = await store.create("Dependency"); - - await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow(); - }); - - it("throws for non-existent dependency", async () => { - const task = await store.create("Task"); - - await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow(); - }); - }); - - describe("removeDep", () => { - it("removes a dependency", async () => { - const dep = await store.create("Dependency"); - const task = await store.create("Task", { deps: [dep.id] }); - - await store.removeDep(task.id, dep.id); - - const updated = await store.get(task.id); - expect(updated?.deps).toEqual([]); - }); - - it("is idempotent for non-existent dep", async () => { - const task = await store.create("Task"); - - await store.removeDep(task.id, "nonexistent"); - - const updated = await store.get(task.id); - expect(updated?.deps).toEqual([]); - }); - }); + const ids = [task1.id, task2.id, task3.id]; + expect(new Set(ids).size).toBe(3); }); - describe("notes", () => { - it("adds a note with timestamp", async () => { - const task = await store.create("Task"); - const before = new Date().toISOString(); + it("sets created timestamp", async () => { + const before = new Date().toISOString(); + const task = await store.create("Task"); + const after = new Date().toISOString(); - await store.addNote(task.id, "This is a note"); - - const updated = await store.get(task.id); - expect(updated?.notes).toHaveLength(1); - expect(updated?.notes[0].content).toBe("This is a note"); - expect(updated?.notes[0].timestamp >= before).toBe(true); - }); - - it("appends multiple notes in order", async () => { - const task = await store.create("Task"); - - await store.addNote(task.id, "First note"); - await store.addNote(task.id, "Second note"); - await store.addNote(task.id, "Third note"); - - const updated = await store.get(task.id); - expect(updated?.notes).toHaveLength(3); - expect(updated?.notes.map((n) => n.content)).toEqual([ - "First note", - "Second note", - "Third note", - ]); - }); + expect(task.created >= before).toBe(true); + expect(task.created <= after).toBe(true); }); - describe("getReady", () => { - it("returns open tasks with no deps", async () => { - const task = await store.create("Ready task"); - - const ready = await store.getReady(); - - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(task.id); + it("returns task with raw content", async () => { + const task = await store.create("Test task", { + body: "Task body here", + acceptanceCriteria: ["criterion 1", "criterion 2"], }); - it("excludes draft tasks", async () => { - await store.create("Draft task", { status: "draft" }); + expect(task.raw).toContain("---"); + expect(task.raw).toContain("title: Test task"); + expect(task.raw).toContain("Task body here"); + expect(task.raw).toContain("## Acceptance Criteria"); + expect(task.raw).toContain("criterion 1"); + expect(task.raw).toContain("criterion 2"); + }); +}); + +describe("get", () => { + it("returns task by id", async () => { + const created = await store.create("Test task"); + const retrieved = await store.get(created.id); + + expect(retrieved).toEqual(created); + }); + + it("returns null for non-existent task", async () => { + const result = await store.get("nonexistent"); + + expect(result).toBeNull(); + }); + + it("preserves assignee field", async () => { + const created = await store.create("Task", { assignee: "codex" }); + const retrieved = await store.get(created.id); - const ready = await store.getReady(); + expect(retrieved?.assignee).toBe("codex"); + }); - expect(ready).toHaveLength(0); + it("returns raw content matching the file", async () => { + const created = await store.create("Task with body", { + body: "Some body content", + acceptanceCriteria: ["test passes", "lint passes"], }); + await store.addNote(created.id, "A note was added"); + + const retrieved = await store.get(created.id); + + expect(retrieved?.raw).toContain("title: Task with body"); + expect(retrieved?.raw).toContain("Some body content"); + expect(retrieved?.raw).toContain("## Acceptance Criteria"); + expect(retrieved?.raw).toContain("test passes"); + expect(retrieved?.raw).toContain("lint passes"); + expect(retrieved?.raw).toContain("## Notes"); + expect(retrieved?.raw).toContain("A note was added"); + }); +}); - it("excludes in_progress tasks", async () => { - const task = await store.create("Task"); - await store.start(task.id); +describe("list", () => { + it("returns empty array when no tasks", async () => { + const tasks = await store.list(); - const ready = await store.getReady(); + expect(tasks).toEqual([]); + }); + + it("returns all tasks", async () => { + await store.create("Task 1"); + await store.create("Task 2"); + await store.create("Task 3"); - expect(ready).toHaveLength(0); + const tasks = await store.list(); + + expect(tasks).toHaveLength(3); + expect(tasks.map((t) => t.title).sort()).toEqual(["Task 1", "Task 2", "Task 3"]); + }); +}); + +describe("update", () => { + it("updates task title", async () => { + const task = await store.create("Original title"); + const updated = await store.update(task.id, { title: "New title" }); + + expect(updated.title).toBe("New title"); + expect(updated.id).toBe(task.id); + }); + + it("updates task body", async () => { + const task = await store.create("Task"); + const updated = await store.update(task.id, { + body: "New body", }); - it("excludes done tasks", async () => { - const task = await store.create("Task"); - await store.close(task.id); + expect(updated.body).toBe("New body"); + }); + + it("updates task assignee", async () => { + const task = await store.create("Task"); + const updated = await store.update(task.id, { assignee: "claude" }); + + expect(updated.assignee).toBe("claude"); + }); - const ready = await store.getReady(); + it("persists updates", async () => { + const task = await store.create("Task"); + await store.update(task.id, { title: "Updated" }); - expect(ready).toHaveLength(0); + const retrieved = await store.get(task.id); + expect(retrieved?.title).toBe("Updated"); + }); + + it("preserves created timestamp on update", async () => { + const task = await store.create("Task"); + const originalCreated = task.created; + + await new Promise((r) => setTimeout(r, 10)); + await store.update(task.id, { title: "Updated" }); + + const retrieved = await store.get(task.id); + expect(retrieved?.created).toBe(originalCreated); + }); + + it("throws for non-existent task", async () => { + await expect(store.update("nonexistent", { title: "New" })).rejects.toThrow(); + }); + + it("replaces acceptance criteria", async () => { + const task = await store.create("Task", { + acceptanceCriteria: ["old criterion 1", "old criterion 2"], + }); + + const updated = await store.update(task.id, { + acceptanceCriteria: ["new criterion"], }); - it("excludes tasks with unresolved deps", async () => { - const dep = await store.create("Dependency"); - await store.create("Blocked task", { deps: [dep.id] }); + expect(updated.acceptanceCriteria).toEqual(["new criterion"]); + }); - const ready = await store.getReady(); + it("clears acceptance criteria with empty array", async () => { + const task = await store.create("Task", { + acceptanceCriteria: ["criterion 1", "criterion 2"], + }); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(dep.id); + const updated = await store.update(task.id, { + acceptanceCriteria: [], }); + + expect(updated.acceptanceCriteria).toEqual([]); + }); +}); + +describe("delete", () => { + it("deletes a task", async () => { + const task = await store.create("Task to delete"); + await store.delete(task.id); + + const retrieved = await store.get(task.id); + expect(retrieved).toBeNull(); + }); + + it("removes task from list", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2"); + await store.delete(task1.id); + + const tasks = await store.list(); + expect(tasks).toHaveLength(1); + expect(tasks[0].id).toBe(task2.id); + }); + + it("throws for non-existent task", async () => { + await expect(store.delete("nonexistent")).rejects.toThrow(); + }); +}); + +describe("status transitions", () => { + it("transitions draft to open", async () => { + const task = await store.create("Draft", { status: "draft" }); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("reopens a done task", async () => { + const task = await store.create("Task"); + await store.close(task.id); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("reopens a failed task", async () => { + const task = await store.create("Task"); + await store.fail(task.id); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("reopens an in_progress task", async () => { + const task = await store.create("Task"); + await store.start(task.id); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("is idempotent for already open task", async () => { + const task = await store.create("Task"); + await store.open(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("open"); + }); + + it("transitions open to in_progress", async () => { + const task = await store.create("Task"); + await store.start(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("in_progress"); + }); + + it("throws when task is draft", async () => { + const task = await store.create("Draft", { status: "draft" }); + + await expect(store.start(task.id)).rejects.toThrow(); + }); + + it("throws when task is already done", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + await expect(store.start(task.id)).rejects.toThrow(); + }); + + it("transitions open to done", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); + + it("transitions in_progress to done", async () => { + const task = await store.create("Task"); + await store.start(task.id); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); + + it("transitions draft to done", async () => { + const task = await store.create("Task", { status: "draft" }); + await store.close(task.id); + + const updated = await store.get(task.id); + expect(updated?.status).toBe("done"); + }); +}); + +describe("dependencies", () => { + it("adds a dependency", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task"); + + await store.addDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toContain(dep.id); + }); + + it("does not duplicate dependencies", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task"); + + await store.addDep(task.id, dep.id); + await store.addDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([dep.id]); + }); + + it("throws for non-existent task", async () => { + const dep = await store.create("Dependency"); + + await expect(store.addDep("nonexistent", dep.id)).rejects.toThrow(); + }); + + it("throws for non-existent dependency", async () => { + const task = await store.create("Task"); + + await expect(store.addDep(task.id, "nonexistent")).rejects.toThrow(); + }); + + it("removes a dependency", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task", { deps: [dep.id] }); + + await store.removeDep(task.id, dep.id); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([]); + }); + + it("is idempotent for non-existent dep", async () => { + const task = await store.create("Task"); + + await store.removeDep(task.id, "nonexistent"); + + const updated = await store.get(task.id); + expect(updated?.deps).toEqual([]); + }); +}); + +describe("notes", () => { + it("adds a note with timestamp", async () => { + const task = await store.create("Task"); + const before = new Date().toISOString(); + + await store.addNote(task.id, "This is a note"); + + const updated = await store.get(task.id); + expect(updated?.notes).toHaveLength(1); + expect(updated?.notes[0].content).toBe("This is a note"); + expect(updated?.notes[0].timestamp >= before).toBe(true); + }); + + it("appends multiple notes in order", async () => { + const task = await store.create("Task"); + + await store.addNote(task.id, "First note"); + await store.addNote(task.id, "Second note"); + await store.addNote(task.id, "Third note"); + + const updated = await store.get(task.id); + expect(updated?.notes).toHaveLength(3); + expect(updated?.notes.map((n) => n.content)).toEqual([ + "First note", + "Second note", + "Third note", + ]); + }); +}); + +describe("getReady", () => { + it("returns open tasks with no deps", async () => { + const task = await store.create("Ready task"); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); + + it("excludes draft tasks", async () => { + await store.create("Draft task", { status: "draft" }); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(0); + }); + + it("excludes in_progress tasks", async () => { + const task = await store.create("Task"); + await store.start(task.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(0); + }); - it("includes tasks when all deps are done", async () => { - const dep = await store.create("Dependency"); - const task = await store.create("Task", { deps: [dep.id] }); - await store.close(dep.id); + it("excludes done tasks", async () => { + const task = await store.create("Task"); + await store.close(task.id); - const ready = await store.getReady(); + const ready = await store.getReady(); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(task.id); - }); + expect(ready).toHaveLength(0); + }); + + it("excludes tasks with unresolved deps", async () => { + const dep = await store.create("Dependency"); + await store.create("Blocked task", { deps: [dep.id] }); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(dep.id); + }); + + it("includes tasks when all deps are done", async () => { + const dep = await store.create("Dependency"); + const task = await store.create("Task", { deps: [dep.id] }); + await store.close(dep.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); + + it("handles multiple deps correctly", async () => { + const dep1 = await store.create("Dep 1"); + const dep2 = await store.create("Dep 2"); + const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); + + // Only one dep done - task not ready + await store.close(dep1.id); + let ready = await store.getReady(); + expect(ready.map((t) => t.id)).not.toContain(task.id); + + // Both deps done - task ready + await store.close(dep2.id); + ready = await store.getReady(); + expect(ready.map((t) => t.id)).toContain(task.id); + }); + + it("sorts by created date (oldest first) when no priority", async () => { + const task1 = await store.create("Task 1"); + await new Promise((r) => setTimeout(r, 10)); + const task2 = await store.create("Task 2"); + await new Promise((r) => setTimeout(r, 10)); + const task3 = await store.create("Task 3"); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]); + }); + + it("sorts by priority first (lower number = higher priority)", async () => { + // Create in wrong order to prove priority wins over creation time + const low = await store.create("Low priority", { priority: 10 }); + const high = await store.create("High priority", { priority: 1 }); + const medium = await store.create("Medium priority", { priority: 5 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([high.id, medium.id, low.id]); + }); + + it("tasks with priority come before tasks without", async () => { + // Create without priority first to prove priority wins + const noPriority = await store.create("No priority"); + const withPriority = await store.create("With priority", { priority: 5 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([withPriority.id, noPriority.id]); + }); + + it("sorts by created date within same priority", async () => { + const first = await store.create("First", { priority: 1 }); + await new Promise((r) => setTimeout(r, 10)); + const second = await store.create("Second", { priority: 1 }); + await new Promise((r) => setTimeout(r, 10)); + const third = await store.create("Third", { priority: 1 }); + + const ready = await store.getReady(); + + expect(ready.map((t) => t.id)).toEqual([first.id, second.id, third.id]); + }); + + it("excludes parent task when it has open children", async () => { + const parent = await store.create("Parent task"); + await store.create("Child 1", { parentId: parent.id }); + await store.create("Child 2", { parentId: parent.id }); + + const ready = await store.getReady(); + + // Children are ready but parent is not (has open children) + expect(ready).toHaveLength(2); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + }); + + it("includes parent task when all children are done", async () => { + const parent = await store.create("Parent task"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + const child2 = await store.create("Child 2", { parentId: parent.id }); + + await store.close(child1.id); + await store.close(child2.id); + + const ready = await store.getReady(); + + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); - it("handles multiple deps correctly", async () => { - const dep1 = await store.create("Dep 1"); - const dep2 = await store.create("Dep 2"); - const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); + it("excludes parent when some children are not done", async () => { + const parent = await store.create("Parent task"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + await store.create("Child 2", { parentId: parent.id }); - // Only one dep done - task not ready - await store.close(dep1.id); - let ready = await store.getReady(); - expect(ready.map((t) => t.id)).not.toContain(task.id); + await store.close(child1.id); - // Both deps done - task ready - await store.close(dep2.id); - ready = await store.getReady(); - expect(ready.map((t) => t.id)).toContain(task.id); - }); + const ready = await store.getReady(); - it("sorts by created date (oldest first) when no priority", async () => { - const task1 = await store.create("Task 1"); - await new Promise((r) => setTimeout(r, 10)); - const task2 = await store.create("Task 2"); - await new Promise((r) => setTimeout(r, 10)); - const task3 = await store.create("Task 3"); + // Only child2 is ready, parent is blocked by child2 + expect(ready).toHaveLength(1); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + }); - const ready = await store.getReady(); + it("handles nested children - parent waits for all descendants", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + const grandchild = await store.create("Grandchild", { + parentId: child.id, + }); + + // Only grandchild is ready (no children) + let ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(grandchild.id); + + // After grandchild done, child becomes ready + await store.close(grandchild.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(child.id); + + // After child done, parent becomes ready + await store.close(child.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); - expect(ready.map((t) => t.id)).toEqual([task1.id, task2.id, task3.id]); - }); + it("combines deps and children blocking", async () => { + const dep = await store.create("Dependency"); + const parent = await store.create("Parent", { deps: [dep.id] }); + const child = await store.create("Child", { parentId: parent.id }); + + // Only dep and child are ready (parent blocked by both dep and child) + let ready = await store.getReady(); + expect(ready).toHaveLength(2); + expect(ready.map((t) => t.id)).toContain(dep.id); + expect(ready.map((t) => t.id)).toContain(child.id); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + + // Close child but not dep - parent still blocked + await store.close(child.id); + ready = await store.getReady(); + expect(ready.map((t) => t.id)).not.toContain(parent.id); + + // Close dep - now parent is ready + await store.close(dep.id); + ready = await store.getReady(); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(parent.id); + }); - it("sorts by priority first (lower number = higher priority)", async () => { - // Create in wrong order to prove priority wins over creation time - const low = await store.create("Low priority", { priority: 10 }); - const high = await store.create("High priority", { priority: 1 }); - const medium = await store.create("Medium priority", { priority: 5 }); + it("task with no children is ready (leaf task)", async () => { + const task = await store.create("Leaf task"); - const ready = await store.getReady(); + const ready = await store.getReady(); - expect(ready.map((t) => t.id)).toEqual([high.id, medium.id, low.id]); - }); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(task.id); + }); - it("tasks with priority come before tasks without", async () => { - // Create without priority first to prove priority wins - const noPriority = await store.create("No priority"); - const withPriority = await store.create("With priority", { priority: 5 }); + it("returns only ready tasks in epic children tree", async () => { + await store.create("Unrelated task"); + const epic = await store.create("Epic"); + const child = await store.create("Epic child", { parentId: epic.id }); - const ready = await store.getReady(); + const ready = await store.getReady(epic.id); - expect(ready.map((t) => t.id)).toEqual([withPriority.id, noPriority.id]); - }); + // Only child is ready (epic blocked by its open child) + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(child.id); - it("sorts by created date within same priority", async () => { - const first = await store.create("First", { priority: 1 }); - await new Promise((r) => setTimeout(r, 10)); - const second = await store.create("Second", { priority: 1 }); - await new Promise((r) => setTimeout(r, 10)); - const third = await store.create("Third", { priority: 1 }); + // After closing child, epic becomes ready + await store.close(child.id); + const readyAfter = await store.getReady(epic.id); + expect(readyAfter).toHaveLength(1); + expect(readyAfter[0].id).toBe(epic.id); + }); - const ready = await store.getReady(); + it("returns empty when epic and children are not ready", async () => { + const epic = await store.create("Epic", { status: "draft" }); + await store.create("Child", { parentId: epic.id, status: "draft" }); - expect(ready.map((t) => t.id)).toEqual([first.id, second.id, third.id]); - }); + const ready = await store.getReady(epic.id); - describe("parent blocked by children", () => { - it("excludes parent task when it has open children", async () => { - const parent = await store.create("Parent task"); - await store.create("Child 1", { parentId: parent.id }); - await store.create("Child 2", { parentId: parent.id }); - - const ready = await store.getReady(); - - // Children are ready but parent is not (has open children) - expect(ready).toHaveLength(2); - expect(ready.map((t) => t.id)).not.toContain(parent.id); - }); - - it("includes parent task when all children are done", async () => { - const parent = await store.create("Parent task"); - const child1 = await store.create("Child 1", { parentId: parent.id }); - const child2 = await store.create("Child 2", { parentId: parent.id }); - - await store.close(child1.id); - await store.close(child2.id); - - const ready = await store.getReady(); - - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(parent.id); - }); - - it("excludes parent when some children are not done", async () => { - const parent = await store.create("Parent task"); - const child1 = await store.create("Child 1", { parentId: parent.id }); - await store.create("Child 2", { parentId: parent.id }); - - await store.close(child1.id); - - const ready = await store.getReady(); - - // Only child2 is ready, parent is blocked by child2 - expect(ready).toHaveLength(1); - expect(ready.map((t) => t.id)).not.toContain(parent.id); - }); - - it("handles nested children - parent waits for all descendants", async () => { - const parent = await store.create("Parent"); - const child = await store.create("Child", { parentId: parent.id }); - const grandchild = await store.create("Grandchild", { - parentId: child.id, - }); - - // Only grandchild is ready (no children) - let ready = await store.getReady(); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(grandchild.id); - - // After grandchild done, child becomes ready - await store.close(grandchild.id); - ready = await store.getReady(); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(child.id); - - // After child done, parent becomes ready - await store.close(child.id); - ready = await store.getReady(); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(parent.id); - }); - - it("combines deps and children blocking", async () => { - const dep = await store.create("Dependency"); - const parent = await store.create("Parent", { deps: [dep.id] }); - const child = await store.create("Child", { parentId: parent.id }); - - // Only dep and child are ready (parent blocked by both dep and child) - let ready = await store.getReady(); - expect(ready).toHaveLength(2); - expect(ready.map((t) => t.id)).toContain(dep.id); - expect(ready.map((t) => t.id)).toContain(child.id); - expect(ready.map((t) => t.id)).not.toContain(parent.id); - - // Close child but not dep - parent still blocked - await store.close(child.id); - ready = await store.getReady(); - expect(ready.map((t) => t.id)).not.toContain(parent.id); - - // Close dep - now parent is ready - await store.close(dep.id); - ready = await store.getReady(); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(parent.id); - }); - - it("task with no children is ready (leaf task)", async () => { - const task = await store.create("Leaf task"); - - const ready = await store.getReady(); - - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(task.id); - }); - }); + expect(ready).toHaveLength(0); + }); - describe("scoped to epic", () => { - it("returns only ready tasks in epic children tree", async () => { - await store.create("Unrelated task"); - const epic = await store.create("Epic"); - const child = await store.create("Epic child", { parentId: epic.id }); - - const ready = await store.getReady(epic.id); - - // Only child is ready (epic blocked by its open child) - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(child.id); - - // After closing child, epic becomes ready - await store.close(child.id); - const readyAfter = await store.getReady(epic.id); - expect(readyAfter).toHaveLength(1); - expect(readyAfter[0].id).toBe(epic.id); - }); - - it("returns empty when epic and children are not ready", async () => { - const epic = await store.create("Epic", { status: "draft" }); - await store.create("Child", { parentId: epic.id, status: "draft" }); - - const ready = await store.getReady(epic.id); - - expect(ready).toHaveLength(0); - }); - - it("handles nested children", async () => { - const epic = await store.create("Epic"); - const child = await store.create("Child", { parentId: epic.id }); - const grandchild = await store.create("Grandchild", { - parentId: child.id, - }); - - // Only grandchild is ready (leaf node) - let ready = await store.getReady(epic.id); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(grandchild.id); - - // After closing grandchild, child becomes ready - await store.close(grandchild.id); - ready = await store.getReady(epic.id); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(child.id); - - // After closing child, epic becomes ready - await store.close(child.id); - ready = await store.getReady(epic.id); - expect(ready).toHaveLength(1); - expect(ready[0].id).toBe(epic.id); - }); - }); + it("handles nested children", async () => { + const epic = await store.create("Epic"); + const child = await store.create("Child", { parentId: epic.id }); + const grandchild = await store.create("Grandchild", { + parentId: child.id, + }); + + // Only grandchild is ready (leaf node) + let ready = await store.getReady(epic.id); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(grandchild.id); + + // After closing grandchild, child becomes ready + await store.close(grandchild.id); + ready = await store.getReady(epic.id); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(child.id); + + // After closing child, epic becomes ready + await store.close(child.id); + ready = await store.getReady(epic.id); + expect(ready).toHaveLength(1); + expect(ready[0].id).toBe(epic.id); }); +}); - describe("getBlocked", () => { - it("returns tasks with unresolved deps", async () => { - const dep = await store.create("Dependency"); - const blocked = await store.create("Blocked", { deps: [dep.id] }); +describe("getBlocked", () => { + it("returns tasks with unresolved deps", async () => { + const dep = await store.create("Dependency"); + const blocked = await store.create("Blocked", { deps: [dep.id] }); - const result = await store.getBlocked(); + const result = await store.getBlocked(); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(blocked.id); - }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(blocked.id); + }); - it("excludes tasks with no deps", async () => { - await store.create("No deps"); + it("excludes tasks with no deps", async () => { + await store.create("No deps"); - const result = await store.getBlocked(); + const result = await store.getBlocked(); - expect(result).toHaveLength(0); - }); + expect(result).toHaveLength(0); + }); - it("excludes tasks with all deps done", async () => { - const dep = await store.create("Dep"); - await store.create("Task", { deps: [dep.id] }); - await store.close(dep.id); + it("excludes tasks with all deps done", async () => { + const dep = await store.create("Dep"); + await store.create("Task", { deps: [dep.id] }); + await store.close(dep.id); - const result = await store.getBlocked(); + const result = await store.getBlocked(); - expect(result).toHaveLength(0); - }); + expect(result).toHaveLength(0); + }); + + it("excludes draft tasks", async () => { + const dep = await store.create("Dep"); + await store.create("Draft blocked", { status: "draft", deps: [dep.id] }); - it("excludes draft tasks", async () => { - const dep = await store.create("Dep"); - await store.create("Draft blocked", { status: "draft", deps: [dep.id] }); + const result = await store.getBlocked(); - const result = await store.getBlocked(); + expect(result).toHaveLength(0); + }); - expect(result).toHaveLength(0); - }); + it("includes in_progress tasks with unresolved deps", async () => { + const dep = await store.create("Dep"); + const task = await store.create("Task", { deps: [dep.id] }); + // Force start even with unresolved deps (edge case) + await store.update(task.id, { status: "in_progress" }); - it("includes in_progress tasks with unresolved deps", async () => { - const dep = await store.create("Dep"); - const task = await store.create("Task", { deps: [dep.id] }); - // Force start even with unresolved deps (edge case) - await store.update(task.id, { status: "in_progress" }); + const result = await store.getBlocked(); - const result = await store.getBlocked(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(task.id); + }); - expect(result).toHaveLength(1); - expect(result[0].id).toBe(task.id); + it("returns only blocked tasks in epic children tree", async () => { + const unrelatedDep = await store.create("Unrelated dep"); + await store.create("Unrelated blocked", { + deps: [unrelatedDep.id], }); - describe("scoped to epic", () => { - it("returns only blocked tasks in epic children tree", async () => { - const unrelatedDep = await store.create("Unrelated dep"); - await store.create("Unrelated blocked", { - deps: [unrelatedDep.id], - }); - - const epic = await store.create("Epic"); - const externalDep = await store.create("External dep"); - const blockedChild = await store.create("Blocked child", { - parentId: epic.id, - deps: [externalDep.id], - }); - - const blocked = await store.getBlocked(epic.id); - - expect(blocked).toHaveLength(1); - expect(blocked[0].id).toBe(blockedChild.id); - }); + const epic = await store.create("Epic"); + const externalDep = await store.create("External dep"); + const blockedChild = await store.create("Blocked child", { + parentId: epic.id, + deps: [externalDep.id], }); + + const blocked = await store.getBlocked(epic.id); + + expect(blocked).toHaveLength(1); + expect(blocked[0].id).toBe(blockedChild.id); + }); +}); + +describe("getClosed", () => { + it("returns done tasks", async () => { + const task = await store.create("Task"); + await store.close(task.id); + + const closed = await store.getClosed(); + + expect(closed).toHaveLength(1); + expect(closed[0].id).toBe(task.id); }); - describe("getClosed", () => { - it("returns done tasks", async () => { - const task = await store.create("Task"); - await store.close(task.id); + it("excludes non-done tasks", async () => { + await store.create("Open task"); + await store.create("Draft task", { status: "draft" }); + const inProgress = await store.create("In progress"); + await store.start(inProgress.id); - const closed = await store.getClosed(); + const closed = await store.getClosed(); - expect(closed).toHaveLength(1); - expect(closed[0].id).toBe(task.id); - }); + expect(closed).toHaveLength(0); + }); + + it("sorts by created date (most recent first)", async () => { + const task1 = await store.create("Task 1"); + await new Promise((r) => setTimeout(r, 10)); + const task2 = await store.create("Task 2"); + await new Promise((r) => setTimeout(r, 10)); + const task3 = await store.create("Task 3"); + + await store.close(task1.id); + await store.close(task2.id); + await store.close(task3.id); + + const closed = await store.getClosed(); - it("excludes non-done tasks", async () => { - await store.create("Open task"); - await store.create("Draft task", { status: "draft" }); - const inProgress = await store.create("In progress"); - await store.start(inProgress.id); + expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]); + }); - const closed = await store.getClosed(); + it("returns only closed tasks in epic children tree", async () => { + const unrelated = await store.create("Unrelated"); + await store.close(unrelated.id); - expect(closed).toHaveLength(0); - }); + const epic = await store.create("Epic"); + const child = await store.create("Epic child", { parentId: epic.id }); + await store.close(child.id); - it("sorts by created date (most recent first)", async () => { - const task1 = await store.create("Task 1"); - await new Promise((r) => setTimeout(r, 10)); - const task2 = await store.create("Task 2"); - await new Promise((r) => setTimeout(r, 10)); - const task3 = await store.create("Task 3"); + const closed = await store.getClosed(epic.id); - await store.close(task1.id); - await store.close(task2.id); - await store.close(task3.id); + // Only the closed child is returned (epic is still open) + expect(closed).toHaveLength(1); + expect(closed[0].id).toBe(child.id); + }); +}); - const closed = await store.getClosed(); +describe("getDepTree", () => { + it("returns empty for task with no deps", async () => { + const task = await store.create("Leaf task"); - expect(closed.map((t) => t.id)).toEqual([task3.id, task2.id, task1.id]); - }); + const tree = await store.getDepTree(task.id); - describe("scoped to epic", () => { - it("returns only closed tasks in epic children tree", async () => { - const unrelated = await store.create("Unrelated"); - await store.close(unrelated.id); + expect(tree).toEqual([]); + }); - const epic = await store.create("Epic"); - const child = await store.create("Epic child", { parentId: epic.id }); - await store.close(child.id); + it("returns direct deps", async () => { + const dep1 = await store.create("Dep 1"); + const dep2 = await store.create("Dep 2"); + const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); - const closed = await store.getClosed(epic.id); + const tree = await store.getDepTree(task.id); - // Only the closed child is returned (epic is still open) - expect(closed).toHaveLength(1); - expect(closed[0].id).toBe(child.id); - }); - }); + expect(tree).toHaveLength(2); + expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort()); }); - describe("getDepTree", () => { - it("returns empty for task with no deps", async () => { - const task = await store.create("Leaf task"); + it("returns nested deps recursively", async () => { + const leaf = await store.create("Leaf"); + const middle = await store.create("Middle", { deps: [leaf.id] }); + const root = await store.create("Root", { deps: [middle.id] }); - const tree = await store.getDepTree(task.id); + const tree = await store.getDepTree(root.id); - expect(tree).toEqual([]); - }); + expect(tree).toHaveLength(2); + expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort()); + }); + + it("handles diamond deps without duplicates", async () => { + const shared = await store.create("Shared"); + const left = await store.create("Left", { deps: [shared.id] }); + const right = await store.create("Right", { deps: [shared.id] }); + const root = await store.create("Root", { deps: [left.id, right.id] }); - it("returns direct deps", async () => { - const dep1 = await store.create("Dep 1"); - const dep2 = await store.create("Dep 2"); - const task = await store.create("Task", { deps: [dep1.id, dep2.id] }); + const tree = await store.getDepTree(root.id); - const tree = await store.getDepTree(task.id); + expect(tree).toHaveLength(3); + expect(tree.map((t) => t.id).sort()).toEqual([shared.id, left.id, right.id].sort()); + }); - expect(tree).toHaveLength(2); - expect(tree.map((t) => t.id).sort()).toEqual([dep1.id, dep2.id].sort()); - }); + it("handles circular deps gracefully", async () => { + const task1 = await store.create("Task 1"); + const task2 = await store.create("Task 2", { deps: [task1.id] }); + await store.addDep(task1.id, task2.id); // create cycle - it("returns nested deps recursively", async () => { - const leaf = await store.create("Leaf"); - const middle = await store.create("Middle", { deps: [leaf.id] }); - const root = await store.create("Root", { deps: [middle.id] }); + // Should not infinite loop + const tree = await store.getDepTree(task1.id); - const tree = await store.getDepTree(root.id); + expect(tree.map((t) => t.id)).toContain(task2.id); + }); - expect(tree).toHaveLength(2); - expect(tree.map((t) => t.id).sort()).toEqual([leaf.id, middle.id].sort()); - }); + it("throws for non-existent task", async () => { + await expect(store.getDepTree("nonexistent")).rejects.toThrow(); + }); +}); + +describe("file persistence", () => { + it("persists tasks across store instances", async () => { + const task = await store.create("Persistent task", { + body: "With body", + assignee: "claude", + }); + await store.addNote(task.id, "A note"); + + // Create new store instance pointing to same dir + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(task.id); + + expect(retrieved).not.toBeNull(); + expect(retrieved?.title).toBe("Persistent task"); + expect(retrieved?.body).toBe("With body"); + expect(retrieved?.assignee).toBe("claude"); + expect(retrieved?.created).toBe(task.created); + expect(retrieved?.notes).toHaveLength(1); + expect(retrieved?.notes[0].content).toBe("A note"); + }); - it("handles diamond deps without duplicates", async () => { - const shared = await store.create("Shared"); - const left = await store.create("Left", { deps: [shared.id] }); - const right = await store.create("Right", { deps: [shared.id] }); - const root = await store.create("Root", { deps: [left.id, right.id] }); + it("persists parentId across store instances", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); - const tree = await store.getDepTree(root.id); + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(child.id); - expect(tree).toHaveLength(3); - expect(tree.map((t) => t.id).sort()).toEqual([shared.id, left.id, right.id].sort()); - }); + expect(retrieved?.parentId).toBe(parent.id); + }); - it("handles circular deps gracefully", async () => { - const task1 = await store.create("Task 1"); - const task2 = await store.create("Task 2", { deps: [task1.id] }); - await store.addDep(task1.id, task2.id); // create cycle + it("persists priority across store instances", async () => { + const task = await store.create("Priority task", { priority: 3 }); - // Should not infinite loop - const tree = await store.getDepTree(task1.id); + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(task.id); - expect(tree.map((t) => t.id)).toContain(task2.id); - }); + expect(retrieved?.priority).toBe(3); + }); - it("throws for non-existent task", async () => { - await expect(store.getDepTree("nonexistent")).rejects.toThrow(); - }); + it("persists raw content across store instances", async () => { + const task = await store.create("Task with everything", { + body: "Detailed body", + acceptanceCriteria: ["tests pass", "build succeeds"], + assignee: "claude", + priority: 1, + }); + await store.addNote(task.id, "Implementation note"); + + const store2 = new FileTaskStore(tempDir); + const retrieved = await store2.get(task.id); + + expect(retrieved?.raw).toContain("title: Task with everything"); + expect(retrieved?.raw).toContain("Detailed body"); + expect(retrieved?.raw).toContain("## Acceptance Criteria"); + expect(retrieved?.raw).toContain("tests pass"); + expect(retrieved?.raw).toContain("build succeeds"); + expect(retrieved?.raw).toContain("## Notes"); + expect(retrieved?.raw).toContain("Implementation note"); + expect(retrieved?.raw).toContain("assignee: claude"); + expect(retrieved?.raw).toContain("priority: 1"); }); +}); - describe("file persistence", () => { - it("persists tasks across store instances", async () => { - const task = await store.create("Persistent task", { - body: "With body", - assignee: "claude", - }); - await store.addNote(task.id, "A note"); - - // Create new store instance pointing to same dir - const store2 = new FileTaskStore(tempDir); - const retrieved = await store2.get(task.id); - - expect(retrieved).not.toBeNull(); - expect(retrieved?.title).toBe("Persistent task"); - expect(retrieved?.body).toBe("With body"); - expect(retrieved?.assignee).toBe("claude"); - expect(retrieved?.created).toBe(task.created); - expect(retrieved?.notes).toHaveLength(1); - expect(retrieved?.notes[0].content).toBe("A note"); - }); +describe("parent-child hierarchy", () => { + it("returns empty array for task with no parent", async () => { + const task = await store.create("Root task"); - it("persists parentId across store instances", async () => { - const parent = await store.create("Parent"); - const child = await store.create("Child", { parentId: parent.id }); + const ancestors = await store.getAncestors(task.id); - const store2 = new FileTaskStore(tempDir); - const retrieved = await store2.get(child.id); + expect(ancestors).toEqual([]); + }); - expect(retrieved?.parentId).toBe(parent.id); - }); + it("returns parent for child task", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); - it("persists priority across store instances", async () => { - const task = await store.create("Priority task", { priority: 3 }); + const ancestors = await store.getAncestors(child.id); - const store2 = new FileTaskStore(tempDir); - const retrieved = await store2.get(task.id); + expect(ancestors).toHaveLength(1); + expect(ancestors[0].id).toBe(parent.id); + }); - expect(retrieved?.priority).toBe(3); + it("returns full ancestor chain in order", async () => { + const grandparent = await store.create("Grandparent"); + const parent = await store.create("Parent", { + parentId: grandparent.id, }); + const child = await store.create("Child", { parentId: parent.id }); + + const ancestors = await store.getAncestors(child.id); - it("persists raw content across store instances", async () => { - const task = await store.create("Task with everything", { - body: "Detailed body", - acceptanceCriteria: ["tests pass", "build succeeds"], - assignee: "claude", - priority: 1, - }); - await store.addNote(task.id, "Implementation note"); - - const store2 = new FileTaskStore(tempDir); - const retrieved = await store2.get(task.id); - - expect(retrieved?.raw).toContain("title: Task with everything"); - expect(retrieved?.raw).toContain("Detailed body"); - expect(retrieved?.raw).toContain("## Acceptance Criteria"); - expect(retrieved?.raw).toContain("tests pass"); - expect(retrieved?.raw).toContain("build succeeds"); - expect(retrieved?.raw).toContain("## Notes"); - expect(retrieved?.raw).toContain("Implementation note"); - expect(retrieved?.raw).toContain("assignee: claude"); - expect(retrieved?.raw).toContain("priority: 1"); - }); + expect(ancestors).toHaveLength(2); + expect(ancestors[0].id).toBe(parent.id); + expect(ancestors[1].id).toBe(grandparent.id); }); - describe("parent-child hierarchy", () => { - describe("getAncestors", () => { - it("returns empty array for task with no parent", async () => { - const task = await store.create("Root task"); + it("throws for non-existent task", async () => { + await expect(store.getAncestors("nonexistent")).rejects.toThrow(); + }); - const ancestors = await store.getAncestors(task.id); + it("returns empty array for task with no children", async () => { + const task = await store.create("Leaf task"); - expect(ancestors).toEqual([]); - }); + const children = await store.getChildren(task.id); - it("returns parent for child task", async () => { - const parent = await store.create("Parent"); - const child = await store.create("Child", { parentId: parent.id }); + expect(children).toEqual([]); + }); - const ancestors = await store.getAncestors(child.id); + it("returns direct children", async () => { + const parent = await store.create("Parent"); + const child1 = await store.create("Child 1", { parentId: parent.id }); + const child2 = await store.create("Child 2", { parentId: parent.id }); - expect(ancestors).toHaveLength(1); - expect(ancestors[0].id).toBe(parent.id); - }); + const children = await store.getChildren(parent.id); - it("returns full ancestor chain in order", async () => { - const grandparent = await store.create("Grandparent"); - const parent = await store.create("Parent", { - parentId: grandparent.id, - }); - const child = await store.create("Child", { parentId: parent.id }); + expect(children).toHaveLength(2); + expect(children.map((c) => c.id).sort()).toEqual([child1.id, child2.id].sort()); + }); - const ancestors = await store.getAncestors(child.id); + it("does not return grandchildren", async () => { + const grandparent = await store.create("Grandparent"); + const parent = await store.create("Parent", { + parentId: grandparent.id, + }); + await store.create("Grandchild", { parentId: parent.id }); + + const children = await store.getChildren(grandparent.id); - expect(ancestors).toHaveLength(2); - expect(ancestors[0].id).toBe(parent.id); - expect(ancestors[1].id).toBe(grandparent.id); - }); + expect(children).toHaveLength(1); + expect(children[0].id).toBe(parent.id); + }); - it("throws for non-existent task", async () => { - await expect(store.getAncestors("nonexistent")).rejects.toThrow(); - }); + it("sorts by priority first (lower number = higher priority)", async () => { + const parent = await store.create("Parent"); + // Create in wrong order to prove priority wins over creation time + const low = await store.create("Low priority child", { + parentId: parent.id, + priority: 10, }); + const high = await store.create("High priority child", { + parentId: parent.id, + priority: 1, + }); + const medium = await store.create("Medium priority child", { + parentId: parent.id, + priority: 5, + }); + + const children = await store.getChildren(parent.id); + + expect(children.map((c) => c.id)).toEqual([high.id, medium.id, low.id]); + }); - describe("getChildren", () => { - it("returns empty array for task with no children", async () => { - const task = await store.create("Leaf task"); - - const children = await store.getChildren(task.id); - - expect(children).toEqual([]); - }); - - it("returns direct children", async () => { - const parent = await store.create("Parent"); - const child1 = await store.create("Child 1", { parentId: parent.id }); - const child2 = await store.create("Child 2", { parentId: parent.id }); - - const children = await store.getChildren(parent.id); - - expect(children).toHaveLength(2); - expect(children.map((c) => c.id).sort()).toEqual([child1.id, child2.id].sort()); - }); - - it("does not return grandchildren", async () => { - const grandparent = await store.create("Grandparent"); - const parent = await store.create("Parent", { - parentId: grandparent.id, - }); - await store.create("Grandchild", { parentId: parent.id }); - - const children = await store.getChildren(grandparent.id); - - expect(children).toHaveLength(1); - expect(children[0].id).toBe(parent.id); - }); - - it("sorts by priority first (lower number = higher priority)", async () => { - const parent = await store.create("Parent"); - // Create in wrong order to prove priority wins over creation time - const low = await store.create("Low priority child", { - parentId: parent.id, - priority: 10, - }); - const high = await store.create("High priority child", { - parentId: parent.id, - priority: 1, - }); - const medium = await store.create("Medium priority child", { - parentId: parent.id, - priority: 5, - }); - - const children = await store.getChildren(parent.id); - - expect(children.map((c) => c.id)).toEqual([high.id, medium.id, low.id]); - }); - - it("children with priority come before children without", async () => { - const parent = await store.create("Parent"); - // Create without priority first to prove priority wins - const noPriority = await store.create("No priority", { - parentId: parent.id, - }); - const withPriority = await store.create("With priority", { - parentId: parent.id, - priority: 5, - }); - - const children = await store.getChildren(parent.id); - - expect(children.map((c) => c.id)).toEqual([withPriority.id, noPriority.id]); - }); + it("children with priority come before children without", async () => { + const parent = await store.create("Parent"); + // Create without priority first to prove priority wins + const noPriority = await store.create("No priority", { + parentId: parent.id, + }); + const withPriority = await store.create("With priority", { + parentId: parent.id, + priority: 5, }); - describe("setParent", () => { - it("sets parent on a task", async () => { - const parent = await store.create("Parent"); - const task = await store.create("Task"); + const children = await store.getChildren(parent.id); - await store.setParent(task.id, parent.id); + expect(children.map((c) => c.id)).toEqual([withPriority.id, noPriority.id]); + }); - const updated = await store.get(task.id); - expect(updated?.parentId).toBe(parent.id); - }); + it("sets parent on a task", async () => { + const parent = await store.create("Parent"); + const task = await store.create("Task"); - it("removes parent when set to null", async () => { - const parent = await store.create("Parent"); - const task = await store.create("Task", { parentId: parent.id }); + await store.setParent(task.id, parent.id); - await store.setParent(task.id, null); + const updated = await store.get(task.id); + expect(updated?.parentId).toBe(parent.id); + }); - const updated = await store.get(task.id); - expect(updated?.parentId).toBeUndefined(); - }); + it("removes parent when set to null", async () => { + const parent = await store.create("Parent"); + const task = await store.create("Task", { parentId: parent.id }); - it("throws for non-existent task", async () => { - const parent = await store.create("Parent"); + await store.setParent(task.id, null); - await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow("Task not found"); - }); + const updated = await store.get(task.id); + expect(updated?.parentId).toBeUndefined(); + }); - it("throws for non-existent parent", async () => { - const task = await store.create("Task"); + it("throws for non-existent task", async () => { + const parent = await store.create("Parent"); - await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow( - "Parent task not found", - ); - }); + await expect(store.setParent("nonexistent", parent.id)).rejects.toThrow("Task not found"); + }); - it("throws when setting task as its own parent", async () => { - const task = await store.create("Task"); + it("throws for non-existent parent", async () => { + const task = await store.create("Task"); - await expect(store.setParent(task.id, task.id)).rejects.toThrow( - "Task cannot be its own parent", - ); - }); + await expect(store.setParent(task.id, "nonexistent")).rejects.toThrow("Parent task not found"); + }); - it("throws when creating circular reference", async () => { - const parent = await store.create("Parent"); - const child = await store.create("Child", { parentId: parent.id }); + it("throws when setting task as its own parent", async () => { + const task = await store.create("Task"); - await expect(store.setParent(parent.id, child.id)).rejects.toThrow("circular reference"); - }); - }); + await expect(store.setParent(task.id, task.id)).rejects.toThrow( + "Task cannot be its own parent", + ); + }); + + it("throws when creating circular reference", async () => { + const parent = await store.create("Parent"); + const child = await store.create("Child", { parentId: parent.id }); + + await expect(store.setParent(parent.id, child.id)).rejects.toThrow("circular reference"); }); }); diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 51b86fcb83..e3e1ba2583 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -33,348 +33,336 @@ async function withShell(shell: string, run: () => Promise): Promise { } } -describe("TerminalManager", () => { - let manager: TerminalManager; - const temporaryDirs: string[] = []; +let manager: TerminalManager; +const temporaryDirs: string[] = []; - afterEach(() => { - if (manager) { - manager.killAll(); +afterEach(() => { + if (manager) { + manager.killAll(); + } + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); } - while (temporaryDirs.length > 0) { - const dir = temporaryDirs.pop(); - if (dir) { - rmSync(dir, { recursive: true, force: true }); - } + } +}); + +it("returns empty list for new cwd", async () => { + manager = createTerminalManager(); + const terminals = await manager.getTerminals("/tmp"); + + expect(terminals).toHaveLength(0); +}); + +it("returns existing terminals on subsequent calls", async () => { + manager = createTerminalManager(); + const created = await manager.createTerminal({ cwd: "/tmp" }); + const first = await manager.getTerminals("/tmp"); + const second = await manager.getTerminals("/tmp"); + + expect(first.length).toBe(1); + expect(first[0].id).toBe(created.id); + expect(second.length).toBe(1); +}); + +it("throws for relative paths", async () => { + manager = createTerminalManager(); + await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path"); +}); + +it("accepts Windows absolute paths", async () => { + manager = createTerminalManager(); + await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow(); + await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow(); +}); + +it("creates separate terminals for different cwds", async () => { + manager = createTerminalManager(); + const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; + const homeTerminals = [await manager.createTerminal({ cwd: "/home" })]; + + expect(tmpTerminals.length).toBe(1); + expect(homeTerminals.length).toBe(1); + expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id); +}); + +it("creates additional terminal with auto-incrementing name", async () => { + manager = createTerminalManager(); + await manager.createTerminal({ cwd: "/tmp" }); + const second = await manager.createTerminal({ cwd: "/tmp" }); + + expect(second.name).toBe("Terminal 2"); + + const terminals = await manager.getTerminals("/tmp"); + expect(terminals.length).toBe(2); +}); + +it("uses custom name when provided", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + + expect(session.name).toBe("Dev Server"); +}); + +it("creates first terminal if none exist", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: "/tmp" }); + + expect(session.name).toBe("Terminal 1"); + + const terminals = await manager.getTerminals("/tmp"); + expect(terminals.length).toBe(1); + expect(terminals[0].id).toBe(session.id); +}); + +it("throws for relative paths", async () => { + manager = createTerminalManager(); + await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow("cwd must be absolute path"); +}); + +it("does not reject Windows absolute paths as relative", async () => { + manager = createTerminalManager(); + // Should pass path validation (not throw "cwd must be absolute path"). + // The terminal may or may not spawn successfully on non-Windows hosts, + // so we only assert the validation error is absent. + try { + await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" }); + } catch (error) { + expect((error as Error).message).not.toBe("cwd must be absolute path"); + } +}); + +it("inherits registered env for the worktree root cwd", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); + temporaryDirs.push(cwd); + const markerPath = join(cwd, "root-port.txt"); + + manager.registerCwdEnv({ + cwd, + env: { PASEO_WORKTREE_PORT: "45678" }, + }); + const session = await manager.createTerminal({ cwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); } - }); - describe("getTerminals", () => { - it("returns empty list for new cwd", async () => { - manager = createTerminalManager(); - const terminals = await manager.getTerminals("/tmp"); - - expect(terminals).toHaveLength(0); - }); - - it("returns existing terminals on subsequent calls", async () => { - manager = createTerminalManager(); - const created = await manager.createTerminal({ cwd: "/tmp" }); - const first = await manager.getTerminals("/tmp"); - const second = await manager.getTerminals("/tmp"); - - expect(first.length).toBe(1); - expect(first[0].id).toBe(created.id); - expect(second.length).toBe(1); - }); - - it("throws for relative paths", async () => { - manager = createTerminalManager(); - await expect(manager.getTerminals("tmp")).rejects.toThrow("cwd must be absolute path"); - }); - - it("accepts Windows absolute paths", async () => { - manager = createTerminalManager(); - await expect(manager.getTerminals("C:\\Users\\foo\\project")).resolves.not.toThrow(); - await expect(manager.getTerminals("D:\\MyProject")).resolves.not.toThrow(); - }); - - it("creates separate terminals for different cwds", async () => { - manager = createTerminalManager(); - const tmpTerminals = [await manager.createTerminal({ cwd: "/tmp" })]; - const homeTerminals = [await manager.createTerminal({ cwd: "/home" })]; - - expect(tmpTerminals.length).toBe(1); - expect(homeTerminals.length).toBe(1); - expect(tmpTerminals[0].id).not.toBe(homeTerminals[0].id); - }); - }); - - describe("createTerminal", () => { - it("creates additional terminal with auto-incrementing name", async () => { - manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - const second = await manager.createTerminal({ cwd: "/tmp" }); - - expect(second.name).toBe("Terminal 2"); - - const terminals = await manager.getTerminals("/tmp"); - expect(terminals.length).toBe(2); - }); - - it("uses custom name when provided", async () => { - manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); - - expect(session.name).toBe("Dev Server"); - }); - - it("creates first terminal if none exist", async () => { - manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); - - expect(session.name).toBe("Terminal 1"); - - const terminals = await manager.getTerminals("/tmp"); - expect(terminals.length).toBe(1); - expect(terminals[0].id).toBe(session.id); - }); - - it("throws for relative paths", async () => { - manager = createTerminalManager(); - await expect(manager.createTerminal({ cwd: "tmp" })).rejects.toThrow( - "cwd must be absolute path", - ); - }); - - it("does not reject Windows absolute paths as relative", async () => { - manager = createTerminalManager(); - // Should pass path validation (not throw "cwd must be absolute path"). - // The terminal may or may not spawn successfully on non-Windows hosts, - // so we only assert the validation error is absent. - try { - await manager.createTerminal({ cwd: "C:\\Users\\foo\\project" }); - } catch (error) { - expect((error as Error).message).not.toBe("cwd must be absolute path"); - } - }); - - it("inherits registered env for the worktree root cwd", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const cwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-root-")); - temporaryDirs.push(cwd); - const markerPath = join(cwd, "root-port.txt"); - - manager.registerCwdEnv({ - cwd, - env: { PASEO_WORKTREE_PORT: "45678" }, - }); - const session = await manager.createTerminal({ cwd }); - for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { - session.send({ - type: "input", - data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - await waitForCondition(() => existsSync(markerPath), 10000); - expect(readFileSync(markerPath, "utf8")).toBe("45678"); - }); - }); - - it("inherits registered env for subdirectories within the worktree", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); - const subdirCwd = join(rootCwd, "packages", "app"); - mkdirSync(subdirCwd, { recursive: true }); - temporaryDirs.push(rootCwd); - const markerPath = join(subdirCwd, "subdir-port.txt"); - - manager.registerCwdEnv({ - cwd: rootCwd, - env: { PASEO_WORKTREE_PORT: "45679" }, - }); - const session = await manager.createTerminal({ cwd: subdirCwd }); - for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { - session.send({ - type: "input", - data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, - }); - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - await waitForCondition(() => existsSync(markerPath), 10000); - expect(readFileSync(markerPath, "utf8")).toBe("45679"); - }); - }); - }); - - describe("getTerminal", () => { - it("returns terminal by id", async () => { - manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); - const found = manager.getTerminal(session.id); - - expect(found).toBe(session); - }); - - it("returns undefined for unknown id", () => { - manager = createTerminalManager(); - const found = manager.getTerminal("unknown-id"); - - expect(found).toBeUndefined(); - }); - }); - - describe("killTerminal", () => { - it("removes terminal from manager", async () => { - manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); - const id = session.id; - - manager.killTerminal(id); - - expect(manager.getTerminal(id)).toBeUndefined(); - }); - - it("removes cwd entry when last terminal is killed", async () => { - manager = createTerminalManager(); - const created = await manager.createTerminal({ cwd: "/tmp" }); - manager.killTerminal(created.id); - - const remaining = await manager.getTerminals("/tmp"); - expect(remaining).toHaveLength(0); - expect(manager.listDirectories()).not.toContain("/tmp"); - }); - - it("keeps cwd entry when other terminals remain", async () => { - manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - const second = await manager.createTerminal({ cwd: "/tmp" }); - - const terminals = await manager.getTerminals("/tmp"); - manager.killTerminal(terminals[0].id); - - expect(manager.listDirectories()).toContain("/tmp"); - const remaining = await manager.getTerminals("/tmp"); - expect(remaining.length).toBe(1); - expect(remaining[0].id).toBe(second.id); - }); - - it("is no-op for unknown id", () => { - manager = createTerminalManager(); - expect(() => manager.killTerminal("unknown-id")).not.toThrow(); - }); - - it("auto-removes terminal when shell exits", async () => { - manager = createTerminalManager(); - const session = await manager.createTerminal({ cwd: "/tmp" }); - const exitedId = session.id; - session.kill(); - - await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000); - - expect(manager.getTerminal(exitedId)).toBeUndefined(); - - const remaining = await manager.getTerminals("/tmp"); - expect(remaining).toHaveLength(0); - }); - }); - - describe("listDirectories", () => { - it("returns empty array initially", () => { - manager = createTerminalManager(); - expect(manager.listDirectories()).toEqual([]); - }); - - it("returns all cwds with active terminals", async () => { - manager = createTerminalManager(); - await manager.createTerminal({ cwd: "/tmp" }); - await manager.createTerminal({ cwd: "/home" }); - - const dirs = manager.listDirectories(); - expect(dirs).toContain("/tmp"); - expect(dirs).toContain("/home"); - expect(dirs.length).toBe(2); - }); - }); - - describe("killAll", () => { - it("kills all terminals and clears state", async () => { - manager = createTerminalManager(); - const tmpSession = await manager.createTerminal({ cwd: "/tmp" }); - const homeSession = await manager.createTerminal({ cwd: "/home" }); - const tmpId = tmpSession.id; - const homeId = homeSession.id; - - manager.killAll(); - - expect(manager.listDirectories()).toEqual([]); - expect(manager.getTerminal(tmpId)).toBeUndefined(); - expect(manager.getTerminal(homeId)).toBeUndefined(); - }); - }); - - describe("subscribeTerminalsChanged", () => { - it("emits cwd snapshots when terminals are created", async () => { - manager = createTerminalManager(); - const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = - []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push({ - cwd: input.cwd, - terminals: input.terminals.map((terminal) => ({ - name: terminal.name, - ...(terminal.title ? { title: terminal.title } : {}), - })), - }); - }); - - await manager.createTerminal({ cwd: "/tmp" }); - await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); - - expect(snapshots).toContainEqual({ - cwd: "/tmp", - terminals: [{ name: "Terminal 1" }], - }); - expect(snapshots).toContainEqual({ - cwd: "/tmp", - terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }], - }); - - unsubscribe(); - }); - - it("emits updated terminal titles after debounced title changes", async () => { - await withShell("/bin/sh", async () => { - manager = createTerminalManager(); - const snapshots: Array> = []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push( - input.terminals.map((terminal) => ({ - id: terminal.id, - ...(terminal.title ? { title: terminal.title } : {}), - })), - ); - }); - - const session = await manager.createTerminal({ cwd: "/tmp" }); - session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); - - await waitForCondition( - () => - snapshots.some((snapshot) => - snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"), - ), - 10000, - ); - - unsubscribe(); - }); - }, 10000); - - it("emits empty snapshot when last terminal is removed", async () => { - manager = createTerminalManager(); - const snapshots: Array<{ cwd: string; terminalCount: number }> = []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push({ - cwd: input.cwd, - terminalCount: input.terminals.length, - }); - }); - - const session = await manager.createTerminal({ cwd: "/tmp" }); - manager.killTerminal(session.id); - - expect(snapshots).toContainEqual({ - cwd: "/tmp", - terminalCount: 0, - }); - - unsubscribe(); - }); + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45678"); }); }); + +it("inherits registered env for subdirectories within the worktree", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const rootCwd = mkdtempSync(join(tmpdir(), "terminal-manager-env-subdir-")); + const subdirCwd = join(rootCwd, "packages", "app"); + mkdirSync(subdirCwd, { recursive: true }); + temporaryDirs.push(rootCwd); + const markerPath = join(subdirCwd, "subdir-port.txt"); + + manager.registerCwdEnv({ + cwd: rootCwd, + env: { PASEO_WORKTREE_PORT: "45679" }, + }); + const session = await manager.createTerminal({ cwd: subdirCwd }); + for (let attempt = 0; attempt < 10 && !existsSync(markerPath); attempt++) { + session.send({ + type: "input", + data: `printf '%s' "$PASEO_WORKTREE_PORT" > ${JSON.stringify(markerPath)}\r`, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await waitForCondition(() => existsSync(markerPath), 10000); + expect(readFileSync(markerPath, "utf8")).toBe("45679"); + }); +}); + +it("returns terminal by id", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: "/tmp" }); + const found = manager.getTerminal(session.id); + + expect(found).toBe(session); +}); + +it("returns undefined for unknown id", () => { + manager = createTerminalManager(); + const found = manager.getTerminal("unknown-id"); + + expect(found).toBeUndefined(); +}); + +it("removes terminal from manager", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: "/tmp" }); + const id = session.id; + + manager.killTerminal(id); + + expect(manager.getTerminal(id)).toBeUndefined(); +}); + +it("removes cwd entry when last terminal is killed", async () => { + manager = createTerminalManager(); + const created = await manager.createTerminal({ cwd: "/tmp" }); + manager.killTerminal(created.id); + + const remaining = await manager.getTerminals("/tmp"); + expect(remaining).toHaveLength(0); + expect(manager.listDirectories()).not.toContain("/tmp"); +}); + +it("keeps cwd entry when other terminals remain", async () => { + manager = createTerminalManager(); + await manager.createTerminal({ cwd: "/tmp" }); + const second = await manager.createTerminal({ cwd: "/tmp" }); + + const terminals = await manager.getTerminals("/tmp"); + manager.killTerminal(terminals[0].id); + + expect(manager.listDirectories()).toContain("/tmp"); + const remaining = await manager.getTerminals("/tmp"); + expect(remaining.length).toBe(1); + expect(remaining[0].id).toBe(second.id); +}); + +it("is no-op for unknown id", () => { + manager = createTerminalManager(); + expect(() => manager.killTerminal("unknown-id")).not.toThrow(); +}); + +it("auto-removes terminal when shell exits", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: "/tmp" }); + const exitedId = session.id; + session.kill(); + + await waitForCondition(() => manager.getTerminal(exitedId) === undefined, 10000); + + expect(manager.getTerminal(exitedId)).toBeUndefined(); + + const remaining = await manager.getTerminals("/tmp"); + expect(remaining).toHaveLength(0); +}); + +it("returns empty array initially", () => { + manager = createTerminalManager(); + expect(manager.listDirectories()).toEqual([]); +}); + +it("returns all cwds with active terminals", async () => { + manager = createTerminalManager(); + await manager.createTerminal({ cwd: "/tmp" }); + await manager.createTerminal({ cwd: "/home" }); + + const dirs = manager.listDirectories(); + expect(dirs).toContain("/tmp"); + expect(dirs).toContain("/home"); + expect(dirs.length).toBe(2); +}); + +it("kills all terminals and clears state", async () => { + manager = createTerminalManager(); + const tmpSession = await manager.createTerminal({ cwd: "/tmp" }); + const homeSession = await manager.createTerminal({ cwd: "/home" }); + const tmpId = tmpSession.id; + const homeId = homeSession.id; + + manager.killAll(); + + expect(manager.listDirectories()).toEqual([]); + expect(manager.getTerminal(tmpId)).toBeUndefined(); + expect(manager.getTerminal(homeId)).toBeUndefined(); +}); + +it("emits cwd snapshots when terminals are created", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminals: input.terminals.map((terminal) => ({ + name: terminal.name, + ...(terminal.title ? { title: terminal.title } : {}), + })), + }); + }); + + await manager.createTerminal({ cwd: "/tmp" }); + await manager.createTerminal({ cwd: "/tmp", name: "Dev Server" }); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminals: [{ name: "Terminal 1" }], + }); + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }], + }); + + unsubscribe(); +}); + +interface TerminalTitleEntry { + id: string; + title?: string; +} + +function toTitleEntry(terminal: { id: string; title?: string }): TerminalTitleEntry { + return { + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + }; +} + +function hasLogsTitle(sessionId: string) { + const matches = (terminal: TerminalTitleEntry) => + terminal.id === sessionId && terminal.title === "Logs"; + return (snapshot: TerminalTitleEntry[]) => snapshot.some(matches); +} + +it("emits updated terminal titles after debounced title changes", async () => { + await withShell("/bin/sh", async () => { + manager = createTerminalManager(); + const snapshots: TerminalTitleEntry[][] = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push(input.terminals.map(toTitleEntry)); + }); + + const session = await manager.createTerminal({ cwd: "/tmp" }); + session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" }); + + await waitForCondition(() => snapshots.some(hasLogsTitle(session.id)), 10000); + + unsubscribe(); + }); +}, 10000); + +it("emits empty snapshot when last terminal is removed", async () => { + manager = createTerminalManager(); + const snapshots: Array<{ cwd: string; terminalCount: number }> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push({ + cwd: input.cwd, + terminalCount: input.terminals.length, + }); + }); + + const session = await manager.createTerminal({ cwd: "/tmp" }); + manager.killTerminal(session.id); + + expect(snapshots).toContainEqual({ + cwd: "/tmp", + terminalCount: 0, + }); + + unsubscribe(); +}); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 5c5570fc5a..6c94ff0327 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -23,22 +23,23 @@ import { tmpdir } from "node:os"; const hasZsh = existsSync("/bin/zsh"); -// Extract text from a single row -function getRowText(state: ReturnType, rowIndex: number): string { - return state.grid[rowIndex] +type TerminalRow = ReturnType["grid"][number]; + +function rowToText(row: TerminalRow): string { + return row .map((cell) => cell.char) .join("") .trimEnd(); } +// Extract text from a single row +function getRowText(state: ReturnType, rowIndex: number): string { + return rowToText(state.grid[rowIndex]); +} + // Extract all visible lines as array (trimmed, empty lines included) function getLines(state: ReturnType): string[] { - return state.grid.map((row) => - row - .map((cell) => cell.char) - .join("") - .trimEnd(), - ); + return state.grid.map(rowToText); } // Wait for terminal state to match expected lines @@ -102,1067 +103,1051 @@ async function waitForTitle( throw new Error("Timeout waiting for terminal title predicate to match"); } -describe("Terminal", () => { - const sessions: TerminalSession[] = []; - const temporaryDirs: string[] = []; +const sessions: TerminalSession[] = []; +const temporaryDirs: string[] = []; - afterEach(async () => { - for (const session of sessions) { - session.kill(); - } - sessions.length = 0; - while (temporaryDirs.length > 0) { - const dir = temporaryDirs.pop(); - if (dir) { - rmSync(dir, { recursive: true, force: true }); - } - } - }); - - function trackSession(session: TerminalSession): TerminalSession { - sessions.push(session); - return session; +afterEach(async () => { + for (const session of sessions) { + session.kill(); } + sessions.length = 0; + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); - describe("createTerminal", () => { - it("keeps full process titles while stripping path prefixes", () => { - expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); - expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe( - "node npm-cli.js run dev", - ); - expect(normalizeProcessTitle("")).toBeUndefined(); - }); +function trackSession(session: TerminalSession): TerminalSession { + sessions.push(session); + return session; +} - it("humanizes interpreter-backed package manager commands", () => { - expect( - humanizeProcessTitle( - "/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev", - ), - ).toBe("npm run dev"); - expect( - humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"), - ).toBe("npm test"); - }); - - it("drops common interpreter prefixes for direct scripts", () => { - expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe( - "server.py --port 3000", - ); - expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh"); - }); - - it("ensures darwin prebuild spawn-helper is executable", () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); - temporaryDirs.push(packageRoot); - const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`); - mkdirSync(prebuildDir, { recursive: true }); - const helperPath = join(prebuildDir, "spawn-helper"); - writeFileSync(helperPath, "#!/bin/sh\necho helper\n"); - chmodSync(helperPath, 0o644); - - ensureNodePtySpawnHelperExecutableForCurrentPlatform({ - packageRoot, - platform: "darwin", - force: true, - }); - - expect(statSync(helperPath).mode & 0o111).toBe(0o111); - }); - - it("uses cmd.exe-compatible default shell on Windows", () => { - expect(resolveDefaultTerminalShell({ platform: "win32", env: {} })).toBe( - "C:\\Windows\\System32\\cmd.exe", - ); - expect( - resolveDefaultTerminalShell({ - platform: "win32", - env: { ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }, - }), - ).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); - }); - - it("creates a terminal session with an id, name, and cwd", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - expect(session.id).toBeDefined(); - expect(typeof session.id).toBe("string"); - expect(session.id.length).toBeGreaterThan(0); - expect(session.name).toBe("Terminal"); - expect(session.cwd).toBe("/tmp"); - }); - - it("sets zsh wrapper env when spawning zsh", () => { - const resolvedEnv = buildTerminalEnvironment({ - shell: "/bin/zsh", - env: { - HOME: "/tmp/paseo-home", - ZDOTDIR: "/tmp/paseo-zdotdir", - }, - }); - - expect(resolvedEnv.TERM).toBe("xterm-256color"); - expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); - expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir()); - }); - - it("uses custom name when provided", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - name: "Dev Server", - }), - ); - - expect(session.name).toBe("Dev Server"); - }); - - it("uses default shell if not specified", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - }), - ); - - expect(session.id).toBeDefined(); - }); - - it("uses default rows and cols", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - const state = session.getState(); - expect(state.rows).toBe(24); - expect(state.cols).toBe(80); - }); - - it("respects custom rows and cols", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 40, - cols: 120, - }), - ); - - const state = session.getState(); - expect(state.rows).toBe(40); - expect(state.cols).toBe(120); - }); - - it("captures exit diagnostics from the terminal buffer", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - command: "/bin/sh", - args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"], - }), - ); - - const exitInfo = await new Promise>>( - (resolve) => { - session.onExit((info) => resolve(info)); - }, - ); - - expect(exitInfo.exitCode).toBe(127); - expect(exitInfo.signal).toBeNull(); - // lastOutputLines may be empty if the process exits before xterm processes the data write - expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true); - expect(session.getExitInfo()).toEqual(exitInfo); - }); +describe("createTerminal", () => { + it("keeps full process titles while stripping path prefixes", () => { + expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); + expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe( + "node npm-cli.js run dev", + ); + expect(normalizeProcessTitle("")).toBeUndefined(); }); - describe("send input", () => { - it("executes a simple echo command", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - // Wait for initial prompt, then send command - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo hello\r" }); - - // After running "echo hello", terminal should show: - // Line 0: "$ echo hello" - // Line 1: "hello" - // Line 2: "$" - await waitForLines(session, ["$ echo hello", "hello", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ echo hello"); - expect(getRowText(state, 1)).toBe("hello"); - expect(getRowText(state, 2)).toBe("$"); - }); - - it("executes multiple commands sequentially", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo first\r" }); - await waitForLines(session, ["$ echo first", "first", "$"]); - - session.send({ type: "input", data: "echo second\r" }); - await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ echo first"); - expect(getRowText(state, 1)).toBe("first"); - expect(getRowText(state, 2)).toBe("$ echo second"); - expect(getRowText(state, 3)).toBe("second"); - expect(getRowText(state, 4)).toBe("$"); - }); - - it("captures output from pwd in specified cwd", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "pwd\r" }); - - await waitForLines(session, ["$ pwd", "/tmp", "$"]); - - const state = session.getState(); - expect(getRowText(state, 0)).toBe("$ pwd"); - expect(getRowText(state, 1)).toBe("/tmp"); - expect(getRowText(state, 2)).toBe("$"); - }); + it("humanizes interpreter-backed package manager commands", () => { + expect( + humanizeProcessTitle( + "/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev", + ), + ).toBe("npm run dev"); + expect( + humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"), + ).toBe("npm test"); }); - describe("terminal title", () => { - it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - mkdirSync(realZdotdir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); - - const session = trackSession( - await createTerminal({ - cwd: homeDir, - command: "/bin/zsh", - args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - - const exitInfo = await new Promise>>( - (resolve) => { - session.onExit((info) => resolve(info)); - }, - ); - - expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); - }); - - it("emits the initial title from command args to title listeners", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "npm-cli.js"); - writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - command: process.execPath, - args: [scriptPath, "run", "dev"], - }), - ); - const seenTitles: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - - await waitForTitle(session, (title) => title === "npm run dev"); - await waitForState(session, (state) => state.title === "npm run dev"); - - expect(seenTitles).toContain("npm run dev"); - expect(session.getTitle()).toBe("npm run dev"); - expect(session.getState().title).toBe("npm run dev"); - - unsubscribeTitle(); - }); - - it("emits OSC title updates to title listeners", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const seenTitles: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); - - await waitForTitle(session, (title) => title === "Build Log"); - - expect(seenTitles).toContain("Build Log"); - expect(session.getTitle()).toBe("Build Log"); - expect(session.getState().title).toBe("Build Log"); - - unsubscribeTitle(); - }); - - it("keeps preset titles instead of applying OSC title updates", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - title: "typecheck", - }), - ); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); - await new Promise((resolve) => setTimeout(resolve, 150)); - - expect(session.getTitle()).toBe("typecheck"); - expect(session.getState().title).toBe("typecheck"); - }); - - it("emits command completion from VS Code OSC 633 without visible output", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "emit-command-finished.sh"); - writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); - chmodSync(scriptPath, 0o755); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "./emit-command-finished.sh\r" }); - - await waitForState(session, () => commandCompletions.length === 1); - - expect(commandCompletions).toEqual([7]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); - - unsubscribeCommandFinished(); - }); - - it("ignores malformed VS Code OSC 633 command completion payloads", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); - writeFileSync( - scriptPath, - "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", - ); - chmodSync(scriptPath, 0o755); - - const session = trackSession( - await createTerminal({ - cwd: packageRoot, - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); - - await waitForState(session, () => commandCompletions.length === 1); - - expect(commandCompletions).toEqual([3]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); - - unsubscribeCommandFinished(); - }); - - it("debounces rapid title changes and emits only the final title", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - const seenTitles: Array = []; - const seenMessages: Array = []; - const unsubscribeTitle = session.onTitleChange((title) => { - seenTitles.push(title); - }); - const unsubscribeMessages = session.subscribe((message) => { - if (message.type === "titleChange") { - seenMessages.push(message.title); - } - }); - - await waitForLines(session, ["$"]); - session.send({ - type: "input", - data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", - }); - - await waitForTitle(session, (title) => title === "Final"); - - expect(seenTitles).toEqual(["Final"]); - expect(seenMessages).toEqual(["Final"]); - - unsubscribeMessages(); - unsubscribeTitle(); - }); - - it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - const workingDir = join(homeDir, "dev", "faro"); - mkdirSync(realZdotdir, { recursive: true }); - mkdirSync(workingDir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), ""); - writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); - - const session = trackSession( - await createTerminal({ - cwd: workingDir, - shell: "/bin/zsh", - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - - await waitForLines(session, ["$"]); - await waitForTitle(session, (title) => title === "~/dev/faro"); - - session.send({ type: "input", data: "sleep 1\r" }); - - await waitForTitle(session, (title) => title === "sleep 1"); - await waitForTitle(session, (title) => title === "~/dev/faro", 4000); - }); - - it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { - const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); - temporaryDirs.push(homeDir); - const realZdotdir = join(homeDir, ".config", "zsh"); - const workingDir = join(homeDir, "dev", "faro"); - mkdirSync(realZdotdir, { recursive: true }); - mkdirSync(workingDir, { recursive: true }); - writeFileSync(join(realZdotdir, ".zshenv"), ""); - writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); - - const session = trackSession( - await createTerminal({ - cwd: workingDir, - shell: "/bin/zsh", - env: { - HOME: homeDir, - ZDOTDIR: realZdotdir, - }, - }), - ); - const commandCompletions: Array = []; - const unsubscribeCommandFinished = session.onCommandFinished((info) => { - commandCompletions.push(info.exitCode); - }); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "false\r" }); - - await waitForState(session, () => commandCompletions.includes(1)); - - expect(commandCompletions).toEqual([1]); - expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); - - unsubscribeCommandFinished(); - }); + it("drops common interpreter prefixes for direct scripts", () => { + expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe( + "server.py --port 3000", + ); + expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh"); }); - describe("colors", () => { - it("captures ANSI 16 color codes (mode 1)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); + it("ensures darwin prebuild spawn-helper is executable", () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-")); + temporaryDirs.push(packageRoot); + const prebuildDir = join(packageRoot, "prebuilds", `darwin-${process.arch}`); + mkdirSync(prebuildDir, { recursive: true }); + const helperPath = join(prebuildDir, "spawn-helper"); + writeFileSync(helperPath, "#!/bin/sh\necho helper\n"); + chmodSync(helperPath, 0o644); - await waitForLines(session, ["$"]); - - // \033[31m = ANSI red (color 1) - session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - expect(outputRow[0].char).toBe("R"); - expect(outputRow[0].fg).toBe(1); // ANSI red = 1 - expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors - - // The "$" after RED should have default color - expect(outputRow[3].char).toBe("$"); - expect(outputRow[3].fg).toBe(undefined); - expect(outputRow[3].fgMode).toBe(undefined); + ensureNodePtySpawnHelperExecutableForCurrentPlatform({ + packageRoot, + platform: "darwin", + force: true, }); - it("captures 256 color codes (mode 2)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[38;5;208m = 256-color orange (color 208) - session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - // Check O cell - expect(outputRow[0].char).toBe("O"); - expect(outputRow[0].fg).toBe(208); // 256-color index - expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors - }); - - it("captures true color RGB (mode 3)", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[38;2;255;128;64m = true color RGB(255, 128, 64) - session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - // Check R cell - expect(outputRow[0].char).toBe("R"); - expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color - - // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 - const expectedPacked = (255 << 16) | (128 << 8) | 64; - expect(outputRow[0].fg).toBe(expectedPacked); - }); - - it("captures background colors", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ ", TERM: "xterm-256color" }, - }), - ); - - await waitForLines(session, ["$"]); - - // \033[41m = ANSI red background - session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); - - await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); - - const state = session.getState(); - const outputRow = state.grid[1]; - - expect(outputRow[0].char).toBe("B"); - expect(outputRow[0].bg).toBe(1); // ANSI red = 1 - expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors - }); + expect(statSync(helperPath).mode & 0o111).toBe(0o111); }); - describe("resize", () => { - it("updates terminal dimensions on resize", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - session.send({ type: "resize", rows: 40, cols: 120 }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - const state = session.getState(); - expect(state.rows).toBe(40); - expect(state.cols).toBe(120); - }); - - it("grid reflects new dimensions after resize", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - session.send({ type: "resize", rows: 10, cols: 40 }); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const state = session.getState(); - expect(state.grid.length).toBe(10); - expect(state.grid[0].length).toBe(40); - }); - - it("exposes the current size without extracting full state", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - expect(session.getSize()).toEqual({ rows: 24, cols: 80 }); - - session.send({ type: "resize", rows: 10, cols: 40 }); - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(session.getSize()).toEqual({ rows: 10, cols: 40 }); - }); + it("uses cmd.exe-compatible default shell on Windows", () => { + expect(resolveDefaultTerminalShell({ platform: "win32", env: {} })).toBe( + "C:\\Windows\\System32\\cmd.exe", + ); + expect( + resolveDefaultTerminalShell({ + platform: "win32", + env: { ComSpec: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" }, + }), + ).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"); }); - describe("subscribe", () => { - it("receives a snapshot on initial subscription", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); + it("creates a terminal session with an id, name, and cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(messages.length).toBeGreaterThan(0); - expect(messages[0].type).toBe("snapshot"); - - unsubscribe(); - }); - - it("receives output messages on updates without replay snapshots", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - messages.length = 0; - - session.send({ type: "input", data: "echo test\r" }); - - await waitForLines(session, ["$ echo test", "test", "$"]); - - expect(messages.some((message) => message.type === "output")).toBe(true); - expect(messages.some((message) => message.type === "snapshot")).toBe(false); - - unsubscribe(); - }); - - it("does not emit snapshot messages for resize-only updates", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - messages.length = 0; - - session.send({ type: "resize", rows: 30, cols: 100 }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(messages.some((message) => message.type === "snapshot")).toBe(false); - expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); - - unsubscribe(); - }); - - it("emits output only after getState reflects the new data", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - const outputSeenInState = new Promise((resolve) => { - const unsubscribe = session.subscribe((message) => { - if (message.type !== "output" || !message.data.includes("state-after-output")) { - return; - } - unsubscribe(); - const stateText = getLines(session.getState()).join("\n"); - resolve(stateText.includes("state-after-output")); - }); - }); - - session.send({ type: "input", data: "echo state-after-output\r" }); - expect(await outputSeenInState).toBe(true); - }); - - it("unsubscribe stops receiving messages", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const messages: Array<{ type: string }> = []; - const unsubscribe = session.subscribe((msg) => { - messages.push(msg); - }); - - unsubscribe(); - messages.length = 0; - - session.send({ type: "input", data: "echo after\r" }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - expect(messages.length).toBe(0); - }); + expect(session.id).toBeDefined(); + expect(typeof session.id).toBe("string"); + expect(session.id.length).toBeGreaterThan(0); + expect(session.name).toBe("Terminal"); + expect(session.cwd).toBe("/tmp"); }); - describe("stream snapshots", () => { - it("streams raw output messages without replay metadata", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const outputMessages: string[] = []; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "output") { - return; - } - outputMessages.push(message.data); - }); - - session.send({ type: "input", data: "echo raw-stream\r" }); - await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]); - - expect(outputMessages.join("")).toContain("raw-stream"); - - unsubscribe(); + it("sets zsh wrapper env when spawning zsh", () => { + const resolvedEnv = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: "/tmp/paseo-home", + ZDOTDIR: "/tmp/paseo-zdotdir", + }, }); - it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - session.send({ type: "input", data: "echo before-detach\r" }); - await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]); - - session.send({ type: "input", data: "echo after-detach\r" }); - await waitForLines(session, [ - "$ echo before-detach", - "before-detach", - "$ echo after-detach", - "after-detach", - "$", - ]); - - let snapshotText = ""; - const unsubscribe = session.subscribe((message) => { - if (message.type !== "snapshot") { - return; - } - snapshotText = [...message.state.scrollback, ...message.state.grid] - .map((row) => - row - .map((cell) => cell.char) - .join("") - .trimEnd(), - ) - .join("\n"); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(snapshotText).toContain("before-detach"); - expect(snapshotText).toContain("after-detach"); - unsubscribe(); - }); + expect(resolvedEnv.TERM).toBe("xterm-256color"); + expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir"); + expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir()); }); - describe("getState", () => { - it("returns current terminal state with grid", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); + it("uses custom name when provided", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + name: "Dev Server", + }), + ); - const state = session.getState(); - - expect(state.rows).toBe(24); - expect(state.cols).toBe(80); - expect(state.grid).toBeDefined(); - expect(state.grid.length).toBe(24); - expect(state.grid[0].length).toBe(80); - expect(state.cursor).toBeDefined(); - expect(typeof state.cursor.row).toBe("number"); - expect(typeof state.cursor.col).toBe("number"); - }); - - it("captures cursor presentation modes emitted by terminal apps", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 24, - cols: 80, - }), - ); - - await waitForLines(session, ["$"]); - session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); - - const state = await waitForState( - session, - (current) => - current.cursor.style === "block" && - current.cursor.blink === false && - current.cursor.hidden === true, - ); - - expect(state.cursor).toMatchObject({ - style: "block", - blink: false, - hidden: true, - }); - }); - - it("grid cells have char and color attributes", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - await waitForLines(session, ["$"]); - - const state = session.getState(); - // First cell should be "$" - expect(state.grid[0][0].char).toBe("$"); - expect(state.grid[0][0]).toHaveProperty("fg"); - expect(state.grid[0][0]).toHaveProperty("bg"); - }); + expect(session.name).toBe("Dev Server"); }); - describe("scrollback", () => { - it("preserves scrollback buffer", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - rows: 5, - cols: 80, - }), - ); + it("uses default shell if not specified", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + }), + ); - await waitForLines(session, ["$"]); - - // seq 1 20 produces 20 lines of output - // With 5 rows, we expect lines to scroll into scrollback - session.send({ type: "input", data: "seq 1 20\r" }); - - // Wait for command to finish - final prompt appears after "20" - // In a 5-row terminal, we'll see the last lines plus prompt - // The visible area will show something like: 17, 18, 19, 20, $ - await waitForLines(session, ["17", "18", "19", "20", "$"]); - - const state = session.getState(); - - // Scrollback should contain the earlier output - expect(state.scrollback.length).toBeGreaterThan(0); - - const scrollbackText = state.scrollback - .map((row) => - row - .map((cell) => cell.char) - .join("") - .trimEnd(), - ) - .filter((line) => line.length > 0); - - // The scrollback should contain the command and early numbers - expect(scrollbackText).toContain("$ seq 1 20"); - expect(scrollbackText).toContain("1"); - expect(scrollbackText).toContain("2"); - expect(scrollbackText).toContain("3"); - }); + expect(session.id).toBeDefined(); }); - describe("kill", () => { - it("terminates the shell process", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); + it("uses default rows and cols", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); - await waitForLines(session, ["$"]); - - session.kill(); - - // Should not throw when trying to get state after kill - const state = session.getState(); - expect(state).toBeDefined(); - }); - - it("send after kill is a no-op", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); - - session.kill(); - - // Should not throw - session.send({ type: "input", data: "echo test\r" }); - }); + const state = session.getState(); + expect(state.rows).toBe(24); + expect(state.cols).toBe(80); }); - describe("mouse events", () => { - it("accepts mouse events without throwing", async () => { - const session = trackSession( - await createTerminal({ - cwd: "/tmp", - shell: "/bin/sh", - env: { PS1: "$ " }, - }), - ); + it("respects custom rows and cols", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 40, + cols: 120, + }), + ); - // Should not throw - session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" }); - session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" }); + const state = session.getState(); + expect(state.rows).toBe(40); + expect(state.cols).toBe(120); + }); + + it("captures exit diagnostics from the terminal buffer", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + command: "/bin/sh", + args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"], + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.exitCode).toBe(127); + expect(exitInfo.signal).toBeNull(); + // lastOutputLines may be empty if the process exits before xterm processes the data write + expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true); + expect(session.getExitInfo()).toEqual(exitInfo); + }); +}); + +describe("send input", () => { + it("executes a simple echo command", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Wait for initial prompt, then send command + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo hello\r" }); + + // After running "echo hello", terminal should show: + // Line 0: "$ echo hello" + // Line 1: "hello" + // Line 2: "$" + await waitForLines(session, ["$ echo hello", "hello", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo hello"); + expect(getRowText(state, 1)).toBe("hello"); + expect(getRowText(state, 2)).toBe("$"); + }); + + it("executes multiple commands sequentially", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo first\r" }); + await waitForLines(session, ["$ echo first", "first", "$"]); + + session.send({ type: "input", data: "echo second\r" }); + await waitForLines(session, ["$ echo first", "first", "$ echo second", "second", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo first"); + expect(getRowText(state, 1)).toBe("first"); + expect(getRowText(state, 2)).toBe("$ echo second"); + expect(getRowText(state, 3)).toBe("second"); + expect(getRowText(state, 4)).toBe("$"); + }); + + it("captures output from pwd in specified cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "pwd\r" }); + + await waitForLines(session, ["$ pwd", "/tmp", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ pwd"); + expect(getRowText(state, 1)).toBe("/tmp"); + expect(getRowText(state, 2)).toBe("$"); + }); +}); + +describe("terminal title", () => { + it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); +}); + +describe("colors", () => { + it("captures ANSI 16 color codes (mode 1)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[31m = ANSI red (color 1) + session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fg).toBe(1); // ANSI red = 1 + expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors + + // The "$" after RED should have default color + expect(outputRow[3].char).toBe("$"); + expect(outputRow[3].fg).toBe(undefined); + expect(outputRow[3].fgMode).toBe(undefined); + }); + + it("captures 256 color codes (mode 2)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;5;208m = 256-color orange (color 208) + session.send({ type: "input", data: "printf '\\033[38;5;208mORG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;5;208mORG\\033[0m'", "ORG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check O cell + expect(outputRow[0].char).toBe("O"); + expect(outputRow[0].fg).toBe(208); // 256-color index + expect(outputRow[0].fgMode).toBe(2); // Mode 2 = 256 colors + }); + + it("captures true color RGB (mode 3)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;2;255;128;64m = true color RGB(255, 128, 64) + session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check R cell + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color + + // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 + const expectedPacked = (255 << 16) | (128 << 8) | 64; + expect(outputRow[0].fg).toBe(expectedPacked); + }); + + it("captures background colors", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[41m = ANSI red background + session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("B"); + expect(outputRow[0].bg).toBe(1); // ANSI red = 1 + expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors + }); +}); + +describe("resize", () => { + it("updates terminal dimensions on resize", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + session.send({ type: "resize", rows: 40, cols: 120 }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + const state = session.getState(); + expect(state.rows).toBe(40); + expect(state.cols).toBe(120); + }); + + it("grid reflects new dimensions after resize", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + session.send({ type: "resize", rows: 10, cols: 40 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const state = session.getState(); + expect(state.grid.length).toBe(10); + expect(state.grid[0].length).toBe(40); + }); + + it("exposes the current size without extracting full state", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + expect(session.getSize()).toEqual({ rows: 24, cols: 80 }); + + session.send({ type: "resize", rows: 10, cols: 40 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(session.getSize()).toEqual({ rows: 10, cols: 40 }); + }); +}); + +describe("subscribe", () => { + it("receives a snapshot on initial subscription", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(messages.length).toBeGreaterThan(0); + expect(messages[0].type).toBe("snapshot"); + + unsubscribe(); + }); + + it("receives output messages on updates without replay snapshots", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + messages.length = 0; + + session.send({ type: "input", data: "echo test\r" }); + + await waitForLines(session, ["$ echo test", "test", "$"]); + + expect(messages.some((message) => message.type === "output")).toBe(true); + expect(messages.some((message) => message.type === "snapshot")).toBe(false); + + unsubscribe(); + }); + + it("does not emit snapshot messages for resize-only updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + messages.length = 0; + + session.send({ type: "resize", rows: 30, cols: 100 }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(messages.some((message) => message.type === "snapshot")).toBe(false); + expect(session.getSize()).toEqual({ rows: 30, cols: 100 }); + + unsubscribe(); + }); + + it("emits output only after getState reflects the new data", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + const outputSeenInState = new Promise((resolve) => { + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output" || !message.data.includes("state-after-output")) { + return; + } + unsubscribe(); + const stateText = getLines(session.getState()).join("\n"); + resolve(stateText.includes("state-after-output")); + }); + }); + + session.send({ type: "input", data: "echo state-after-output\r" }); + expect(await outputSeenInState).toBe(true); + }); + + it("unsubscribe stops receiving messages", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const messages: Array<{ type: string }> = []; + const unsubscribe = session.subscribe((msg) => { + messages.push(msg); + }); + + unsubscribe(); + messages.length = 0; + + session.send({ type: "input", data: "echo after\r" }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(messages.length).toBe(0); + }); +}); + +describe("stream snapshots", () => { + it("streams raw output messages without replay metadata", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const outputMessages: string[] = []; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "output") { + return; + } + outputMessages.push(message.data); + }); + + session.send({ type: "input", data: "echo raw-stream\r" }); + await waitForLines(session, ["$ echo raw-stream", "raw-stream", "$"]); + + expect(outputMessages.join("")).toContain("raw-stream"); + + unsubscribe(); + }); + + it("sends the current snapshot to a new subscriber instead of replaying raw output", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo before-detach\r" }); + await waitForLines(session, ["$ echo before-detach", "before-detach", "$"]); + + session.send({ type: "input", data: "echo after-detach\r" }); + await waitForLines(session, [ + "$ echo before-detach", + "before-detach", + "$ echo after-detach", + "after-detach", + "$", + ]); + + let snapshotText = ""; + const unsubscribe = session.subscribe((message) => { + if (message.type !== "snapshot") { + return; + } + snapshotText = [...message.state.scrollback, ...message.state.grid].map(rowToText).join("\n"); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(snapshotText).toContain("before-detach"); + expect(snapshotText).toContain("after-detach"); + unsubscribe(); + }); +}); + +describe("getState", () => { + it("returns current terminal state with grid", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + const state = session.getState(); + + expect(state.rows).toBe(24); + expect(state.cols).toBe(80); + expect(state.grid).toBeDefined(); + expect(state.grid.length).toBe(24); + expect(state.grid[0].length).toBe(80); + expect(state.cursor).toBeDefined(); + expect(typeof state.cursor.row).toBe("number"); + expect(typeof state.cursor.col).toBe("number"); + }); + + it("captures cursor presentation modes emitted by terminal apps", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 24, + cols: 80, + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033[2 q\\033[?25l'\r" }); + + const state = await waitForState( + session, + (current) => + current.cursor.style === "block" && + current.cursor.blink === false && + current.cursor.hidden === true, + ); + + expect(state.cursor).toMatchObject({ + style: "block", + blink: false, + hidden: true, + }); + }); + + it("grid cells have char and color attributes", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + const state = session.getState(); + // First cell should be "$" + expect(state.grid[0][0].char).toBe("$"); + expect(state.grid[0][0]).toHaveProperty("fg"); + expect(state.grid[0][0]).toHaveProperty("bg"); + }); +}); + +describe("scrollback", () => { + it("preserves scrollback buffer", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + rows: 5, + cols: 80, + }), + ); + + await waitForLines(session, ["$"]); + + // seq 1 20 produces 20 lines of output + // With 5 rows, we expect lines to scroll into scrollback + session.send({ type: "input", data: "seq 1 20\r" }); + + // Wait for command to finish - final prompt appears after "20" + // In a 5-row terminal, we'll see the last lines plus prompt + // The visible area will show something like: 17, 18, 19, 20, $ + await waitForLines(session, ["17", "18", "19", "20", "$"]); + + const state = session.getState(); + + // Scrollback should contain the earlier output + expect(state.scrollback.length).toBeGreaterThan(0); + + const scrollbackText = state.scrollback.map(rowToText).filter((line) => line.length > 0); + + // The scrollback should contain the command and early numbers + expect(scrollbackText).toContain("$ seq 1 20"); + expect(scrollbackText).toContain("1"); + expect(scrollbackText).toContain("2"); + expect(scrollbackText).toContain("3"); + }); +}); + +describe("kill", () => { + it("terminates the shell process", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.kill(); + + // Should not throw when trying to get state after kill + const state = session.getState(); + expect(state).toBeDefined(); + }); + + it("send after kill is a no-op", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + session.kill(); + + // Should not throw + session.send({ type: "input", data: "echo test\r" }); + }); +}); + +describe("mouse events", () => { + it("accepts mouse events without throwing", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Should not throw + session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "down" }); + session.send({ type: "mouse", row: 0, col: 0, button: 0, action: "up" }); }); });