Repository navigation
feat(observability): serve session traces to the IDE extension #1415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,32 +94,56 @@ export namespace Server { | |
| if (!CoreFlag.ALTIMATE_WORKSPACE) { | ||
| return { status: 409, body: { ok: false, error: "Workspace mode is not enabled for this server." } } | ||
| } | ||
| return browserOriginRefusal("Workspace actions", origin, host, password, fetchSite) | ||
| } | ||
| /** Why a browser-originated call to a local-only `/altimate/*` route must be refused, or undefined | ||
| * when it may run. `subject` names the routes in the error ("Workspace actions", "Traces"). */ | ||
| export function browserOriginRefusal( | ||
| subject: string, | ||
| origin: string | undefined, | ||
| host: string | undefined, | ||
| password: string | undefined = Flag.OPENCODE_SERVER_PASSWORD, | ||
| fetchSite?: string, | ||
| ): { status: 403; body: { ok: false; error: string } } | undefined { | ||
| // A browser labels every request it sends, including Origin-less ones such as an `<img>` GET | ||
| // from another site. Native clients send no such header, so only a browser's cross-site request | ||
| // is refused here; the Origin rules below handle the rest. | ||
| if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") { | ||
| log.warn("refused cross-site workspace action", { fetchSite }) | ||
| return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another site." } } | ||
| log.warn("refused cross-site request", { subject, fetchSite }) | ||
| return { status: 403, body: { ok: false, error: `${subject} cannot be run from another site.` } } | ||
| } | ||
| if (!origin) return undefined | ||
| if (!password) { | ||
| log.warn("refused browser-originated workspace action on an unsecured server", { origin }) | ||
| log.warn("refused browser-originated request on an unsecured server", { subject, origin }) | ||
| return { | ||
| status: 403, | ||
| body: { | ||
| ok: false, | ||
| error: "Workspace actions cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.", | ||
| error: `${subject} cannot be run from a browser origin on an unsecured server. Set OPENCODE_SERVER_PASSWORD.`, | ||
| }, | ||
| } | ||
| } | ||
| // With a password set, basicAuth has vetted the credentials — but a browser replays cached | ||
| // Basic credentials on a cross-site form POST too, so only this server's own pages may call. | ||
| // Basic credentials on a cross-site request too, so only this server's own pages may call. | ||
| if (!sameOrigin(origin, host)) { | ||
| log.warn("refused cross-origin workspace action", { origin }) | ||
| return { status: 403, body: { ok: false, error: "Workspace actions cannot be run from another origin." } } | ||
| log.warn("refused cross-origin request", { subject, origin }) | ||
| return { status: 403, body: { ok: false, error: `${subject} cannot be run from another origin.` } } | ||
| } | ||
| return undefined | ||
| } | ||
| const TRACE_PAGE_SIZE = 50 | ||
| const TRACE_PAGE_MAX = 200 | ||
| const TRACE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/ | ||
| /** The traces directory, honoring `tracing.dir` like the CLI and TUI; a config that fails to load | ||
| * falls back to the default rather than hiding every trace. */ | ||
| async function tracesDir(): Promise<string | undefined> { | ||
| try { | ||
| const { Config } = await import("../config/config") | ||
| return (await Config.get()).tracing?.dir | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
| /** The skill registry this instance serves, reloaded so a skill written since it loaded is found | ||
| * — also one in a skills directory that did not exist at boot. Same in-context path as | ||
| * `refreshSkillRegistry` in session/prompt.ts: the facade's invalidate keeps the stale root list | ||
|
|
@@ -1249,6 +1273,78 @@ export namespace Server { | |
| } | ||
| }) | ||
| // altimate_change end | ||
| // altimate_change start — GET /altimate/trace, GET /altimate/trace/:sessionID/view | ||
| // The TUI's `/traces` for the IDE extension, which runs this CLI headless: a page of the | ||
| // session traces and one trace's self-contained viewer page. Trace content carries prompts and | ||
| // tool output, so a browser origin is refused on the same terms as the workspace routes. | ||
| .get("/altimate/trace", async (c) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Require credentials or restrict both trace routes to trusted local callers; Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See the reply on the kilo thread above: on an unsecured server these routes expose nothing that |
||
| const refused = browserOriginRefusal( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CRITICAL: Protect trace contents when the server has no password
Reply with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not changing this one; these routes add no new exposure. |
||
| "Traces", | ||
| c.req.header("origin"), | ||
| c.req.header("host"), | ||
| undefined, | ||
| c.req.header("sec-fetch-site"), | ||
| ) | ||
| if (refused) return c.json(refused.body, refused.status) | ||
| try { | ||
| const { Trace } = await import("../altimate/observability/tracing") | ||
| const page = await Trace.listTracesPaginated(await tracesDir(), { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Pagination still parses every trace file on every list request
Reply with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Partly fixed in 85656db: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same as the thread above: the page size is capped at 200 in 85656db. A summary-only reader needs the tracer to write an index, which is a separate change. With |
||
| offset: Number(c.req.query("offset") ?? 0), | ||
| // Capped so one request cannot serialize the whole archive's titles and prompts. | ||
| limit: Math.min(Number(c.req.query("limit") ?? TRACE_PAGE_SIZE), TRACE_PAGE_MAX), | ||
| }) | ||
| return c.json({ | ||
| ok: true as const, | ||
| total: page.total, | ||
| offset: page.offset, | ||
| limit: page.limit, | ||
| traces: page.traces.map(({ sessionId, trace }) => ({ | ||
| sessionID: sessionId, | ||
| title: trace.metadata.title || trace.metadata.prompt || sessionId, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MAJOR · Bug — one parseable non-trace
Reproduced against this branch with one valid trace plus The TUI list already guards this case ( Suggestion: filter out entries that aren't trace-shaped instead of optional-chaining them, so a foreign file isn't listed as a trace: traces: page.traces
.filter(({ trace }) => trace && typeof trace.metadata === "object" && typeof trace.summary === "object")
.map(({ sessionId, trace }) => ({ /* … */ })),
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 85656db. The guard went into |
||
| startedAt: trace.startedAt, | ||
| status: trace.summary.status, | ||
| duration: trace.summary.duration, | ||
| totalTokens: trace.summary.totalTokens, | ||
| totalCost: trace.summary.totalCost, | ||
| totalToolCalls: trace.summary.totalToolCalls, | ||
| })), | ||
| }) | ||
| } catch (err) { | ||
| const error = err instanceof Error ? err.message : String(err) | ||
| log.error("trace list: failed", { error }) | ||
| return c.json({ ok: false, error }, 500) | ||
| } | ||
| }) | ||
| .get("/altimate/trace/:sessionID/view", async (c) => { | ||
| const refused = browserOriginRefusal( | ||
| "Traces", | ||
| c.req.header("origin"), | ||
| c.req.header("host"), | ||
| undefined, | ||
| c.req.header("sec-fetch-site"), | ||
| ) | ||
| if (refused) return c.json(refused.body, refused.status) | ||
| const sessionID = c.req.param("sessionID") | ||
| // Trace files are `<sessionID>.json` in the traces dir; anything outside this alphabet | ||
| // could name a path elsewhere. | ||
| if (!TRACE_SESSION_ID.test(sessionID)) { | ||
| return c.json({ ok: false, error: `Invalid sessionID: ${sessionID}` }, 400) | ||
| } | ||
| try { | ||
| const [{ Trace }, { renderTraceViewer }] = await Promise.all([ | ||
| import("../altimate/observability/tracing"), | ||
| import("../altimate/observability/viewer"), | ||
| ]) | ||
| const trace = await Trace.loadTrace(sessionID, await tracesDir()) | ||
| if (!trace) return c.json({ ok: false, error: `Trace not found: ${sessionID}` }, 404) | ||
| return c.html(renderTraceViewer(trace, { embedded: true })) | ||
| } catch (err) { | ||
| const error = err instanceof Error ? err.message : String(err) | ||
| log.error("trace view: failed", { error }) | ||
| return c.json({ ok: false, error }, 500) | ||
| } | ||
| }) | ||
| // altimate_change end | ||
| .all("/*", async (c) => { | ||
| const path = c.req.path | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,154 @@ | ||||||||||||||||||||||||||||||||
| // altimate_change - new file | ||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||
| // `GET /altimate/trace` and `GET /altimate/trace/:sessionID/view`: the TUI's `/traces` for the IDE | ||||||||||||||||||||||||||||||||
| // extension. Traces are real files in a temp `tracing.dir`, so these also cover that the routes | ||||||||||||||||||||||||||||||||
| // honor the configured directory. | ||||||||||||||||||||||||||||||||
| import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" | ||||||||||||||||||||||||||||||||
| import fs from "fs/promises" | ||||||||||||||||||||||||||||||||
| import os from "os" | ||||||||||||||||||||||||||||||||
| import path from "path" | ||||||||||||||||||||||||||||||||
| import { Server } from "../../src/server/server" | ||||||||||||||||||||||||||||||||
| import { Config } from "../../src/config/config" | ||||||||||||||||||||||||||||||||
| import { resetDatabase } from "./db" | ||||||||||||||||||||||||||||||||
| import { disposeAllInstances } from "../fixture/fixture" | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| let dir: string | ||||||||||||||||||||||||||||||||
| let spies: Array<{ mockRestore: () => void }> = [] | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| function get(urlPath: string, headers: Record<string, string> = {}) { | ||||||||||||||||||||||||||||||||
| return Server.Default().request(urlPath, { method: "GET", headers }) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| async function writeTrace(sessionId: string, startedAt: string, metadata: Record<string, unknown> = {}) { | ||||||||||||||||||||||||||||||||
| const trace = { | ||||||||||||||||||||||||||||||||
| version: 2, | ||||||||||||||||||||||||||||||||
| traceId: `trace-${sessionId}`, | ||||||||||||||||||||||||||||||||
| sessionId, | ||||||||||||||||||||||||||||||||
| startedAt, | ||||||||||||||||||||||||||||||||
| metadata, | ||||||||||||||||||||||||||||||||
| spans: [], | ||||||||||||||||||||||||||||||||
| summary: { | ||||||||||||||||||||||||||||||||
| totalTokens: 1200, | ||||||||||||||||||||||||||||||||
| totalCost: 0.0123, | ||||||||||||||||||||||||||||||||
| totalToolCalls: 3, | ||||||||||||||||||||||||||||||||
| totalGenerations: 2, | ||||||||||||||||||||||||||||||||
| duration: 4500, | ||||||||||||||||||||||||||||||||
| status: "completed", | ||||||||||||||||||||||||||||||||
| tokens: { input: 1000, output: 200, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| await fs.writeFile(path.join(dir, `${sessionId}.json`), JSON.stringify(trace)) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| beforeEach(async () => { | ||||||||||||||||||||||||||||||||
| dir = await fs.mkdtemp(path.join(os.tmpdir(), "altimate-trace-routes-")) | ||||||||||||||||||||||||||||||||
| spies.push(spyOn(Config, "get").mockResolvedValue({ tracing: { dir } } as never)) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| afterEach(async () => { | ||||||||||||||||||||||||||||||||
| for (const spy of spies) spy.mockRestore() | ||||||||||||||||||||||||||||||||
| spies = [] | ||||||||||||||||||||||||||||||||
| await fs.rm(dir, { recursive: true, force: true }) | ||||||||||||||||||||||||||||||||
| await disposeAllInstances() | ||||||||||||||||||||||||||||||||
| await resetDatabase() | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| describe("GET /altimate/trace", () => { | ||||||||||||||||||||||||||||||||
| test("lists traces newest first with their summary", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_old", "2026-10-01T10:00:00.000Z", { title: "Old session" }) | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_new", "2026-10-02T10:00:00.000Z", { prompt: "Explain the orders model" }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace") | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(200) | ||||||||||||||||||||||||||||||||
| const body = (await response.json()) as Record<string, any> | ||||||||||||||||||||||||||||||||
| expect(body.ok).toBe(true) | ||||||||||||||||||||||||||||||||
| expect(body.total).toBe(2) | ||||||||||||||||||||||||||||||||
| expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_new", "ses_old"]) | ||||||||||||||||||||||||||||||||
| // Title falls back to the prompt when the session has no title. | ||||||||||||||||||||||||||||||||
| expect(body.traces[0]).toEqual({ | ||||||||||||||||||||||||||||||||
| sessionID: "ses_new", | ||||||||||||||||||||||||||||||||
| title: "Explain the orders model", | ||||||||||||||||||||||||||||||||
| startedAt: "2026-10-02T10:00:00.000Z", | ||||||||||||||||||||||||||||||||
| status: "completed", | ||||||||||||||||||||||||||||||||
| duration: 4500, | ||||||||||||||||||||||||||||||||
| totalTokens: 1200, | ||||||||||||||||||||||||||||||||
| totalCost: 0.0123, | ||||||||||||||||||||||||||||||||
| totalToolCalls: 3, | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
| expect(body.traces[1].title).toBe("Old session") | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("pages with offset and limit", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_a", "2026-10-01T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_b", "2026-10-02T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_c", "2026-10-03T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const body = (await (await get("/altimate/trace?offset=1&limit=1")).json()) as Record<string, any> | ||||||||||||||||||||||||||||||||
| expect(body.total).toBe(3) | ||||||||||||||||||||||||||||||||
| expect(body.offset).toBe(1) | ||||||||||||||||||||||||||||||||
| expect(body.limit).toBe(1) | ||||||||||||||||||||||||||||||||
| expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_b"]) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("skips a parseable .json that is not a trace instead of failing the list", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_real", "2026-10-01T10:00:00.000Z", { title: "Real session" }) | ||||||||||||||||||||||||||||||||
| await fs.writeFile(path.join(dir, "notes.json"), JSON.stringify({ hello: 1 })) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace") | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(200) | ||||||||||||||||||||||||||||||||
| const body = (await response.json()) as Record<string, any> | ||||||||||||||||||||||||||||||||
| expect(body.total).toBe(1) | ||||||||||||||||||||||||||||||||
| expect(body.traces.map((t: { sessionID: string }) => t.sessionID)).toEqual(["ses_real"]) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("caps the page size", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_a", "2026-10-01T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
| const body = (await (await get("/altimate/trace?limit=100000")).json()) as Record<string, any> | ||||||||||||||||||||||||||||||||
| expect(body.limit).toBe(200) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
Comment on lines
+104
to
+108
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This test does not exercise the cap: it writes only one trace and checks the echoed Prompt for AI agents
Suggested change
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("returns an empty page when there are no traces", async () => { | ||||||||||||||||||||||||||||||||
| const body = (await (await get("/altimate/trace")).json()) as Record<string, any> | ||||||||||||||||||||||||||||||||
| expect(body).toEqual({ ok: true, total: 0, offset: 0, limit: 50, traces: [] }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("refuses a cross-site browser request", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_a", "2026-10-01T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace", { "sec-fetch-site": "cross-site" }) | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(403) | ||||||||||||||||||||||||||||||||
| expect(await response.json()).toEqual({ ok: false, error: "Traces cannot be run from another site." }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| describe("GET /altimate/trace/:sessionID/view", () => { | ||||||||||||||||||||||||||||||||
| test("serves the viewer page for the trace", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_view", "2026-10-01T10:00:00.000Z", { title: "Viewer session" }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace/ses_view/view") | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(200) | ||||||||||||||||||||||||||||||||
| expect(response.headers.get("content-type")).toContain("text/html") | ||||||||||||||||||||||||||||||||
| const html = await response.text() | ||||||||||||||||||||||||||||||||
| expect(html).toContain("<title>Altimate Trace</title>") | ||||||||||||||||||||||||||||||||
| expect(html).toContain("Viewer session") | ||||||||||||||||||||||||||||||||
| // The IDE's editor tab has no shareable URL, so the viewer leaves Copy Link out. | ||||||||||||||||||||||||||||||||
| expect(html).not.toContain('id="btn-copy-link"') | ||||||||||||||||||||||||||||||||
| expect(html).toContain('id="btn-share"') | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("returns 404 for an unknown trace", async () => { | ||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace/ses_missing/view") | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(404) | ||||||||||||||||||||||||||||||||
| expect(await response.json()).toEqual({ ok: false, error: "Trace not found: ses_missing" }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("rejects a session ID that could name a path outside the traces dir", async () => { | ||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace/..%2Fsecrets/view") | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(400) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| test("refuses a browser origin on an unsecured server", async () => { | ||||||||||||||||||||||||||||||||
| await writeTrace("ses_view", "2026-10-01T10:00:00.000Z") | ||||||||||||||||||||||||||||||||
| const response = await get("/altimate/trace/ses_view/view", { origin: "https://evil.example" }) | ||||||||||||||||||||||||||||||||
| expect(response.status).toBe(403) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -147,6 +147,8 @@ describe("v0.5.20 release: listTracesPaginated adversarial", () => { | |||||||||||||||||||||
| endedAt: new Date(Date.now() - i * 60_000 + 30_000).toISOString(), | ||||||||||||||||||||||
| spans: [], | ||||||||||||||||||||||
| metadata: { provider: "test", model: "test-model", directory: "/tmp" }, | ||||||||||||||||||||||
| // Every trace the writer produces has a summary; listTraces skips files without one. | ||||||||||||||||||||||
| summary: { totalTokens: 0, totalCost: 0, totalToolCalls: 0, totalGenerations: 0, duration: 30_000, status: "completed" }, | ||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: This fixture omits the required Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
| await fs.writeFile(path.join(tmpDir, `${sessionId}.json`), JSON.stringify(trace)) | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2:
summary: {}passes this check, buttrace listthen callstotalTokens.toLocaleString()and formatstotalCostwith.toFixed(), crashing the command. Validate the summary fields consumed by the listing before accepting the file as aTraceFile.Prompt for AI agents