diff --git a/src/server/handlers/request/agent-stream.handler.test.ts b/src/server/handlers/request/agent-stream.handler.test.ts index 6141a926cc..5dc70198ee 100644 --- a/src/server/handlers/request/agent-stream.handler.test.ts +++ b/src/server/handlers/request/agent-stream.handler.test.ts @@ -1,4 +1,11 @@ import "#veryfront/schemas/_test-setup.ts"; +import { SERVICE_OVERLOADED } from "#veryfront/errors"; +import { + __registerLogRecordEmitter, + __resetLogRecordEmitterForTests, + type LogEntry, + refreshLoggerConfig, +} from "#veryfront/utils/logger/index.ts"; import { createEmptyDiscoveryResult } from "#veryfront/discovery"; import type { AgentMessage } from "#veryfront/agent"; import { AgentRunSessionManager } from "#veryfront/internal-agents/session-manager.ts"; @@ -3396,3 +3403,72 @@ describe("server/handlers/request/agent-stream.handler", () => { } }); }); + +describe("agent stream handler 5xx logging", () => { + it("logs slug, category, detail and cause for a 5xx VeryfrontError", async () => { + const entries: LogEntry[] = []; + const previousLogLevel = Deno.env.get("LOG_LEVEL"); + const detail = "Agent service context has not been initialized."; + const thrown = SERVICE_OVERLOADED.create({ detail, cause: "adapter boot failed" }); + + try { + Deno.env.set("LOG_LEVEL", "DEBUG"); + refreshLoggerConfig(); + __registerLogRecordEmitter((entry) => entries.push(entry)); + + const handler = createTestAgentStreamHandler({ + ensureProjectDiscovery: () => { + throw thrown; + }, + getAgent: () => undefined, + getAllAgentIds: () => [], + sessionManager: new AgentRunSessionManager(), + }); + + const body = createAgentStreamRequestBody({ + agentSource: { type: "branch", branch: "main" }, + }); + const { jws, publicKeyPem } = await createControlPlaneSignature(body, { + requestId: "run_1", + }); + + const result = await handler.handle( + new Request("https://example.com/api/control-plane/runs/run_1/stream", { + method: "POST", + headers: { + "content-type": "application/json", + "x-veryfront-control-plane-jws": jws, + }, + body, + }), + createCtx(publicKeyPem), + ); + + assertExists(result.response); + assertEquals(result.response.status >= 500, true, `got ${result.response.status}`); + + // The caller still must not see the detail. + const payload = await result.response.json(); + assertEquals(payload.detail, undefined); + + const logged = entries.find( + (entry) => entry.message === "Internal agent stream request failed", + ); + assertExists( + logged, + `handler did not log; saw: ${entries.map((e) => e.message).join(" | ")}`, + ); + const serialized = JSON.stringify(logged); + assertStringIncludes(serialized, detail); + // cause is typed `unknown` and is frequently a string, not an Error. + assertStringIncludes(serialized, "adapter boot failed"); + assertStringIncludes(serialized, thrown.slug); + assertStringIncludes(serialized, thrown.category); + } finally { + __resetLogRecordEmitterForTests(); + if (previousLogLevel === undefined) Deno.env.delete("LOG_LEVEL"); + else Deno.env.set("LOG_LEVEL", previousLogLevel); + refreshLoggerConfig(); + } + }); +}); diff --git a/src/server/handlers/request/agent-stream.handler.ts b/src/server/handlers/request/agent-stream.handler.ts index f1c21b5076..319623d6a4 100644 --- a/src/server/handlers/request/agent-stream.handler.ts +++ b/src/server/handlers/request/agent-stream.handler.ts @@ -109,6 +109,13 @@ const defaultDeps: AgentStreamHandlerDeps = { getDiscoveredHostTools({ agentId }) as RuntimeAgentStreamExecutionDeps["localTools"], }; const logger = serverLogger.component("agent-stream-handler"); + +/** VeryfrontError.cause is `unknown` and is often a plain string. */ +function describeErrorCause(cause: unknown): string | undefined { + if (cause === undefined || cause === null) return undefined; + if (cause instanceof Error) return cause.message; + return typeof cause === "string" ? cause : String(cause); +} const RUN_STREAM_PATH_REGEX = /^\/api\/control-plane\/runs\/([^/]+)\/stream$/; const STUDIO_RUNTIME_REMOTE_TOOL_NAMES = new Set( [ @@ -942,6 +949,20 @@ export class AgentStreamHandler extends BaseHandler { if (isVeryfrontError(error)) { const response = errorToResponse(error, new URL(req.url).pathname); + // errorToResponse strips `detail` from 5xx bodies, and this branch is + // otherwise silent, so the detail would be lost entirely. + if (response.status >= 500) { + logger.error("Internal agent stream request failed", { + projectId: ctx.projectId, + projectSlug: ctx.projectSlug, + status: response.status, + slug: error.slug, + category: error.category, + detail: error.detail, + error: error.message, + cause: describeErrorCause(error.cause), + }); + } return this.respond(applyBuilderHeaders(response, builder.headers)); }