From caf9300358ea40a7adf5c888d1b2f5a3cc8bc4c9 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:52:07 +0000 Subject: [PATCH] fix(server): preserve running status after agent creation (#8396) * fix(server): forward a created agent's current state, not its pre-prompt copy create_agent_request forwarded the snapshot createAgent returned when registration finished, after the initial prompt had already published the agent as running. Subscribers received that idle copy last, with an older updatedAt, and providers that report nothing until the turn ends left the agent listed as idle for the whole turn. Forward the agent manager's current state instead. Closes #8022 * test(server): drain agent persistence before fixture cleanup --------- Co-authored-by: luke Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../daemon-e2e/creation-status.e2e.test.ts | 64 +++++++++ packages/server/src/server/session.ts | 5 +- .../src/server/session.workspaces.test.ts | 130 ++++++++++++++++++ 4 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 0fcbacaf24..0feadfe9cd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts new file mode 100644 index 0000000000..c2293a2bc7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/creation-status.e2e.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; +import { MockLoadTestAgentClient } from "../agent/providers/mock-load-test-agent.js"; +import type { AgentSnapshotPayload } from "../messages.js"; + +test("creation never regresses a running subscription to the pre-prompt idle snapshot", async () => { + const ctx = await createDaemonTestContext({ + agentClients: { mock: new MockLoadTestAgentClient() }, + mcpEnabled: false, + isDev: true, + }); + try { + const workspace = await ctx.client.createWorkspace({ + source: { kind: "directory", path: ctx.daemon.staticDir }, + }); + if (!workspace.workspace) throw new Error(workspace.error ?? "Missing workspace"); + const observations: { + list: string; + kind: string; + agent?: AgentSnapshotPayload; + agentId?: string; + }[] = []; + for (const [list, statuses] of [ + ["running", ["running", "initializing", "error"]], + ["done", ["idle", "closed"]], + ] as const) { + const result = await ctx.client.fetchAgents({ + filter: { statuses: [...statuses] }, + subscribe: {}, + }); + result.subscription.subscribe({ + snapshot() {}, + update(message) { + observations.push({ list, ...message.payload }); + }, + }); + } + const agent = await ctx.client.createAgent({ + workspaceId: workspace.workspace.id, + provider: "mock", + cwd: ctx.daemon.staticDir, + initialPrompt: "Work on this task", + }); + await expect + .poll(() => observations.filter((row) => row.agent?.id === agent.id).length) + .toBeGreaterThan(1); + // Fetching provides a round trip after creation so its queued update reaches the client. + expect((await ctx.client.fetchAgent({ agentId: agent.id }))?.agent.status).toBe("running"); + const relevant = observations.filter( + (row) => row.agent?.id === agent.id || row.agentId === agent.id, + ); + const firstRunning = relevant.findIndex((row) => row.agent?.status === "running"); + expect(firstRunning).toBeGreaterThanOrEqual(0); + expect( + relevant.slice(firstRunning).filter((row) => row.list === "done" && row.kind === "upsert"), + ).toEqual([]); + const timestamps = relevant.flatMap((row) => + row.agent ? [Date.parse(row.agent.updatedAt)] : [], + ); + expect(timestamps).toEqual([...timestamps].sort((a, b) => a - b)); + } finally { + await ctx.cleanup(); + } +}, 60_000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 42c676b50b..00c8529482 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4512,7 +4512,10 @@ export class Session { }, ); createdAgentId = snapshot.id; - await this.agentUpdates.forwardLiveAgent(snapshot); + // `snapshot` predates the initial prompt; subscribers keep the last state they receive. + await this.agentUpdates.forwardLiveAgent( + this.agentManager.getAgent(snapshot.id) ?? liveSnapshot, + ); if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) { this.workspaceAutoName.scheduleForDirectory( { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9e73c9cad4..343c0a8843 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1393,6 +1393,136 @@ test("create_agent_request does not title an existing workspace from the agent p } }); +test("create_agent_request does not publish the pre-prompt snapshot after the prompt starts", async () => { + vi.useFakeTimers(); + const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-live-state-")); + const logger = createTestLogger(); + const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger)); + // The test session's turn starts and then reports nothing, like a provider + // that stays quiet until its turn ends. + const agentManager = new AgentManager({ + clients: { codex: new CreateAgentTestClient() }, + registry: agentStorage, + logger: asSessionLogger(logger), + idFactory: () => "00000000-0000-4000-8000-000000000553", + }); + try { + const cwd = path.join(workdir, "repo"); + mkdirSync(cwd, { recursive: true }); + + const projectRegistry = new FileBackedProjectRegistry( + path.join(workdir, "projects.json"), + asSessionLogger(logger), + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(workdir, "workspaces.json"), + asSessionLogger(logger), + ); + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: "proj-live-state", + rootPath: cwd, + kind: "git", + displayName: "repo", + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "ws-live-state", + projectId: "proj-live-state", + cwd, + kind: "local_checkout", + displayName: "repo", + title: null, + createdAt: "2026-05-07T00:00:00.000Z", + updatedAt: "2026-05-07T00:00:00.000Z", + }), + ); + + const agentUpdates: Extract["payload"][] = []; + const session = asTestSession( + new Session({ + workspaceRuntimeStore: createWorkspaceRuntimeStore(), + messageReceipts: createMessageReceiptsStub(), + agentMessages: createAgentMessageDeliveryStub(), + creationService: createTestCreationService(), + clientId: "test-client", + permissions: OWNER_PERMISSIONS, + appVersion: null, + onMessage: (message) => { + if (message.type === "agent_update") agentUpdates.push(message.payload); + }, + logger: asSessionLogger(logger), + downloadTokenStore: asDownloadTokenStore(), + pushNotifications: asPushNotifications(), + paseoHome: path.join(workdir, "paseo-home"), + agentManager, + agentStorage, + projectRegistry, + workspaceRegistry, + scheduleService: asScheduleService(), + checkoutDiffManager: asCheckoutDiffManager({ + subscribe: async () => ({ + initial: { cwd, files: [], error: null }, + unsubscribe: () => {}, + }), + scheduleRefreshForCwd: () => {}, + onWorkspaceStateMayHaveChanged: () => {}, + invalidateForge: () => {}, + getMetrics: () => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + }), + dispose: () => {}, + }), + workspaceGitService: createNoopWorkspaceGitService(), + daemonConfigStore: asDaemonConfigStore({ + get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }), + onChange: () => () => {}, + }), + mcpBaseUrl: null, + stt: null, + tts: null, + generateWorkspaceName: async () => null, + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + terminalManager: null, + }), + ); + await activateAgentUpdatesSubscription(session, "sub-live-state"); + + await session.handleMessage({ + type: "create_agent_request", + requestId: "req-create-live-state", + workspaceId: "ws-live-state", + config: { provider: "codex", cwd }, + initialPrompt: "Print the report", + attachments: [], + }); + await vi.runAllTimersAsync(); + + const [createdAgent] = agentManager.listAgents(); + expect(createdAgent?.lifecycle).toBe("running"); + const upserts = agentUpdates.flatMap((update) => + update.kind === "upsert" && update.agent.id === createdAgent?.id ? [update.agent] : [], + ); + expect(upserts.length).toBeGreaterThan(0); + // A client keeps the last update it hears, so the latest one must be the running agent. + expect(upserts.at(-1)).toMatchObject({ status: "running" }); + const updatedAt = upserts.map((agent) => Date.parse(agent.updatedAt)); + expect(updatedAt).toEqual([...updatedAt].sort((left, right) => left - right)); + } finally { + // Drain snapshot writes before removing their storage directory. + await agentManager.flush(); + vi.useRealTimers(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unsupported persisted agents are excluded from active lists but preserved in history payloads", async () => { const session = createSessionForWorkspaceTests({ appVersion: "0.1.45" }); const storedRecord = {