diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8ae271c08a..6a61573b6b 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -486,6 +486,137 @@ describe("PluginAgentClientRegistry", () => { } }); + test.each(["completed", "canceled", "failed"] as const)( + "reports a reused plugin child as running until its next turn is %s", + async (outcome) => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { pluginProviders: plugins.definitions() }); + const manager = new AgentManager({ + logger, + clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, + providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, + }); + try { + const agent = await manager.createAgent( + { provider: "plugin-direct", cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const open = harness.inputs.find((input) => input.type === "session.open")!; + harness.emit({ + type: "session.opened", + sessionId: "reusable-child", + parentSessionId: open.sessionId, + capabilities: [], + restoration: "parent", + title: "Plugin child", + cwd: tmpdir(), + }); + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "first-turn", + state: "started", + }); + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "first-turn", + state: "completed", + }); + await nextTurn(); + const [child] = manager.listProviderSubagents(agent.id); + expect(child).toMatchObject({ + id: "reusable-child", + title: "Plugin child", + status: "completed", + }); + + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state: "started", + }); + await nextTurn(); + expect(manager.listProviderSubagents(agent.id)).toMatchObject([ + { id: child.id, title: child.title, status: "running" }, + ]); + + harness.emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state: outcome, + }); + await nextTurn(); + expect(manager.listProviderSubagents(agent.id)).toMatchObject([ + { id: child.id, title: child.title, status: outcome }, + ]); + } finally { + await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id))); + await plugins.shutdown(); + } + }, + ); + + test.each(["started", "completed", "canceled", "failed"] as const)( + "replays a reused plugin child's latest turn state: %s", + async (state) => { + const harness = createProviderHarness({ + openChildren(rootSessionId, emit) { + emit({ + type: "session.opened", + sessionId: "reusable-child", + parentSessionId: rootSessionId, + capabilities: [], + restoration: "parent", + cwd: "/workspace", + }); + for (const [turnId, turnState] of [ + ["first-turn", "started"], + ["first-turn", "completed"], + ["second-turn", "started"], + ] as const) { + emit({ type: "session.turn", sessionId: "reusable-child", turnId, state: turnState }); + } + if (state !== "started") { + emit({ + type: "session.turn", + sessionId: "reusable-child", + turnId: "second-turn", + state, + }); + } + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + try { + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const statuses = []; + for await (const event of session.streamHistory()) { + if ( + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "reusable-child" + ) { + statuses.push(event.event.status); + } + } + expect(statuses.at(-1)).toBe(state === "started" ? "running" : state); + } finally { + await registry.shutdown(); + } + }, + ); + test("gates persistence operations on negotiated provider capabilities", async () => { const harness = createProviderHarness({ capabilities: ["session.persistence"] }); const registry = new PluginAgentClientRegistry(createTestLogger()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 570e339203..3970715a55 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1581,11 +1581,15 @@ class PluginAgentSession implements AgentSession { } return; } - if (event.type === "session.turn" && event.state !== "started") { + if (event.type === "session.turn") { this.publish({ type: "provider_subagent", provider: this.provider, - event: { type: "upsert", id: childId, status: event.state }, + event: { + type: "upsert", + id: childId, + status: event.state === "started" ? "running" : event.state, + }, }); return; } diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 4b0b13b7a2..1d4d365d95 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -1105,12 +1105,23 @@ describe("WorkspaceGitServiceImpl", () => { { cwd: join(REPO_CWD, "packages", "server") }, vi.fn(), ); - await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); - }); + await vi.waitFor( + () => { + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); + expect(service.getMetrics()).toMatchObject({ + repositoryWorkspaceLinkCount: 2, + workspaceObservationSetupInFlightCount: 0, + workspaceRefreshInFlightCount: 0, + fetchInFlightCount: 1, + }); + }, + { timeout: 10_000 }, + ); releaseFetch.resolve(); - await flushPromises(); + await vi.waitFor(() => { + expect(service.getMetrics().fetchInFlightCount).toBe(0); + }); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(4);