diff --git a/apps/server/src/routes/templates.ts b/apps/server/src/routes/templates.ts index 5fd38f02..2bda494d 100644 --- a/apps/server/src/routes/templates.ts +++ b/apps/server/src/routes/templates.ts @@ -13,6 +13,7 @@ import { createStartupPins, parseOptionalStringArrayField, } from "./agent-startup.js"; +import type { AgentRecord } from "../agents/manager.js"; import type { TemplateService } from "../templates/service.js"; import { parseTemplateArgs } from "../templates/store.js"; import { @@ -88,6 +89,9 @@ function classifyErrorCode(message: string): number { type TemplateRouteDeps = { templateService: TemplateService; publishUiEvent: (event: unknown) => void; + withStreamFlag: ( + agent: T + ) => T & { hasStream: boolean }; }; export async function registerTemplateRoutes( @@ -265,7 +269,7 @@ export async function registerTemplateRoutes( }); deps.publishUiEvent({ type: "agent.upsert", - agent: result.agent, + agent: deps.withStreamFlag(result.agent), }); return { agent: result.agent }; } catch (error) { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f1097967..8ae71d15 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -617,6 +617,7 @@ async function registerRoutes() { await registerTemplateRoutes(app, { templateService, publishUiEvent: (event) => uiEventBroker.publish(event as UiEvent), + withStreamFlag, }); await registerMcpRoutes(app, { diff --git a/apps/server/test/template-launch-publish.test.ts b/apps/server/test/template-launch-publish.test.ts new file mode 100644 index 00000000..f3a9d7f2 --- /dev/null +++ b/apps/server/test/template-launch-publish.test.ts @@ -0,0 +1,87 @@ +/** + * The launch route's `agent.upsert` publish must carry the `hasStream` flag, + * like every other `agent.upsert` publish site. `applyAgentUpsert` on the web + * side replaces the cached agent wholesale, so an unflagged payload drops the + * field from the cached row. + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import fastifyMultipart from "@fastify/multipart"; + +import { registerTemplateRoutes } from "../src/routes/templates.js"; + +const LAUNCHED_AGENT = { + id: "agt_launched", + name: "launch-tmpl", + cwd: "/tmp", + status: "running", +}; + +let app: FastifyInstance; +let publishUiEvent: ReturnType; +let launchTemplate: ReturnType; + +beforeAll(async () => { + publishUiEvent = vi.fn(); + launchTemplate = vi.fn(async () => ({ + agent: LAUNCHED_AGENT, + templateId: "tmpl-1", + templateName: "launch-tmpl", + })); + + app = Fastify(); + await app.register(fastifyMultipart); + await registerTemplateRoutes(app, { + templateService: { launchTemplate } as never, + publishUiEvent, + withStreamFlag: ((agent: object) => ({ + ...agent, + hasStream: true, + })) as never, + }); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); +}); + +beforeEach(() => { + publishUiEvent.mockClear(); +}); + +describe("POST /api/v1/templates/:id/launch", () => { + it("publishes agent.upsert with the stream flag applied", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/templates/tmpl-1/launch", + headers: { "content-type": "application/json" }, + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(publishUiEvent).toHaveBeenCalledWith({ + type: "agent.upsert", + agent: { ...LAUNCHED_AGENT, hasStream: true }, + }); + }); + + it("still returns the unflagged agent in the HTTP response", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/templates/tmpl-1/launch", + headers: { "content-type": "application/json" }, + payload: {}, + }); + + expect(res.json()).toEqual({ agent: LAUNCHED_AGENT }); + }); +});