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 <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-31 20:52:07 +00:00
committed by GitHub
co-authored by luke
parent f0dfb9134c
commit caf9300358
4 changed files with 199 additions and 2 deletions
+1 -1
View File
@@ -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",
@@ -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);
+4 -1
View File
@@ -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(
{
@@ -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<SessionOutboundMessage, { type: "agent_update" }>["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 = {