Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/server/handlers/request/agent-stream.handler.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
}
});
});
21 changes: 21 additions & 0 deletions src/server/handlers/request/agent-stream.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
[
Expand Down Expand Up @@ -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,
Comment thread
kwakayama marked this conversation as resolved.
slug: error.slug,
category: error.category,
detail: error.detail,
error: error.message,
cause: describeErrorCause(error.cause),
});
}
return this.respond(applyBuilderHeaders(response, builder.headers));
Comment thread
kwakayama marked this conversation as resolved.
}

Expand Down