From 0032cb4d3f66cacc1f0be65ecdebc2eb56810d1d Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 07:05:06 +0000 Subject: [PATCH 1/3] feat: [AI-6949] add GET /session/:id/transcript endpoint for markdown export --- .../opencode/src/server/routes/session.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 02c4980aa..03bd9f2e0 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import { formatTranscript } from "../../cli/cmd/tui/util/transcript" import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" import { SessionID, MessageID, PartID } from "@/session/schema" @@ -462,6 +463,53 @@ export const SessionRoutes = lazy(() => return c.json(result) }, ) + .get( + "/:sessionID/transcript", + describeRoute({ + summary: "Get session transcript", + description: "Retrieve the full conversation transcript for a session as formatted markdown.", + tags: ["Session"], + operationId: "session.transcript", + responses: { + 200: { + description: "Markdown transcript of the session", + content: { + "text/plain": { + schema: resolver(z.string()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: SessionID.zod, + }), + ), + validator( + "query", + z.object({ + thinking: z.coerce.boolean().optional().default(false).meta({ description: "Include reasoning/thinking parts" }), + toolDetails: z.coerce.boolean().optional().default(false).meta({ description: "Include tool input/output details" }), + assistantMetadata: z.coerce.boolean().optional().default(false).meta({ description: "Include assistant model/timing metadata" }), + }), + ), + async (c) => { + const sessionID = c.req.valid("param").sessionID + const query = c.req.valid("query") + const session = await Session.get(sessionID) + const messages = await Session.messages({ sessionID }) + const transcript = formatTranscript(session, messages, { + thinking: query.thinking, + toolDetails: query.toolDetails, + assistantMetadata: query.assistantMetadata, + }) + c.header("Content-Type", "text/plain; charset=utf-8") + return c.text(transcript) + }, + ) .delete( "/:sessionID/share", describeRoute({ From ce6058fe17b8761789c45b2c1b9234d4426dcac5 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Mon, 15 Jun 2026 07:12:41 +0000 Subject: [PATCH 2/3] test: [AI-6949] add server test for GET /session/:id/transcript endpoint --- .../test/server/session-transcript.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/opencode/test/server/session-transcript.test.ts diff --git a/packages/opencode/test/server/session-transcript.test.ts b/packages/opencode/test/server/session-transcript.test.ts new file mode 100644 index 000000000..5997b5627 --- /dev/null +++ b/packages/opencode/test/server/session-transcript.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, type SessionID } from "../../src/session/schema" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +async function withoutWatcher(fn: () => Promise) { + if (process.platform !== "win32") return fn() + const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev + } +} + +async function addUserMessage(sessionID: SessionID, text: string) { + const id = MessageID.ascending() + await Session.updateMessage({ + id, + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "test", + model: { providerID: "test", modelID: "test" }, + tools: {}, + mode: "", + } as unknown as MessageV2.Info) + await Session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "text", + text, + }) + return id +} + +describe("session transcript endpoint", () => { + test("returns 200 with text/plain markdown for a session", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Test Session" }) + await addUserMessage(session.id, "Hello world") + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/transcript`) + expect(res.status).toBe(200) + expect(res.headers.get("content-type")).toContain("text/plain") + + const body = await res.text() + expect(body).toStartWith("# ") + expect(body).toContain("Test Session") + expect(body).toContain("## User") + expect(body).toContain("Hello world") + + await Session.remove(session.id) + }, + }), + ) + }) + + test("returns 404 for a missing session", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const app = Server.Default() + const res = await app.request(`/session/ses_nonexistent/transcript`) + expect(res.status).toBe(404) + }, + }), + ) + }) + + test("returns markdown for an empty session (no messages)", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Empty Session" }) + const app = Server.Default() + + const res = await app.request(`/session/${session.id}/transcript`) + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toStartWith("# Empty Session") + expect(body).toContain("Session ID:") + + await Session.remove(session.id) + }, + }), + ) + }) + + test("accepts thinking, toolDetails, and assistantMetadata query params", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "Options Session" }) + await addUserMessage(session.id, "test message") + const app = Server.Default() + + const res = await app.request( + `/session/${session.id}/transcript?thinking=true&toolDetails=true&assistantMetadata=true`, + ) + expect(res.status).toBe(200) + const body = await res.text() + expect(body).toStartWith("# ") + + await Session.remove(session.id) + }, + }), + ) + }) +}) From b7c17571d9079d06999ab42e225098164c635226 Mon Sep 17 00:00:00 2001 From: saravmajestic Date: Tue, 16 Jun 2026 06:47:55 +0000 Subject: [PATCH 3/3] fix: [AI-6949] fix boolean coercion for transcript query params and strengthen flag tests - Use z.preprocess to handle explicit "false" string correctly: z.coerce.boolean() coerces Boolean("false") = true, so ?thinking=false was silently treated as true - Fix import to use @/cli alias consistent with other server routes - Update flag test to build a session with real reasoning parts and assert both ?thinking=true includes _Thinking:_ and ?thinking=false excludes it --- .../opencode/src/server/routes/session.ts | 8 +-- .../test/server/session-transcript.test.ts | 59 ++++++++++++++++--- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 03bd9f2e0..c9d16491d 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -1,5 +1,5 @@ import { Hono } from "hono" -import { formatTranscript } from "../../cli/cmd/tui/util/transcript" +import { formatTranscript } from "@/cli/cmd/tui/util/transcript" import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" import { SessionID, MessageID, PartID } from "@/session/schema" @@ -491,9 +491,9 @@ export const SessionRoutes = lazy(() => validator( "query", z.object({ - thinking: z.coerce.boolean().optional().default(false).meta({ description: "Include reasoning/thinking parts" }), - toolDetails: z.coerce.boolean().optional().default(false).meta({ description: "Include tool input/output details" }), - assistantMetadata: z.coerce.boolean().optional().default(false).meta({ description: "Include assistant model/timing metadata" }), + thinking: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include reasoning/thinking parts" }), + toolDetails: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include tool input/output details" }), + assistantMetadata: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include assistant model/timing metadata" }), }), ), async (c) => { diff --git a/packages/opencode/test/server/session-transcript.test.ts b/packages/opencode/test/server/session-transcript.test.ts index 5997b5627..3ac4df66e 100644 --- a/packages/opencode/test/server/session-transcript.test.ts +++ b/packages/opencode/test/server/session-transcript.test.ts @@ -47,6 +47,33 @@ async function addUserMessage(sessionID: SessionID, text: string) { return id } +async function addAssistantMessageWithReasoning(sessionID: SessionID, parentID: MessageID, reasoningText: string) { + const id = MessageID.ascending() + await Session.updateMessage({ + id, + sessionID, + role: "assistant", + agent: "build", + modelID: "claude-sonnet", + providerID: "anthropic", + mode: "", + parentID, + path: { cwd: "/test", root: "/test" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + } as unknown as MessageV2.Info) + await Session.updatePart({ + id: PartID.ascending(), + sessionID, + messageID: id, + type: "reasoning", + text: reasoningText, + time: { start: Date.now() }, + } as unknown as Parameters[0]) + return id +} + describe("session transcript endpoint", () => { test("returns 200 with text/plain markdown for a session", async () => { await using tmp = await tmpdir({ git: true }) @@ -109,22 +136,36 @@ describe("session transcript endpoint", () => { ) }) - test("accepts thinking, toolDetails, and assistantMetadata query params", async () => { + test("thinking=true includes reasoning, thinking=false and default exclude it", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => Instance.provide({ directory: tmp.path, fn: async () => { - const session = await Session.create({ title: "Options Session" }) - await addUserMessage(session.id, "test message") + const session = await Session.create({ title: "Reasoning Session" }) + const userMsgId = await addUserMessage(session.id, "think about this") + await addAssistantMessageWithReasoning(session.id, userMsgId, "inner thoughts here") const app = Server.Default() - const res = await app.request( - `/session/${session.id}/transcript?thinking=true&toolDetails=true&assistantMetadata=true`, - ) - expect(res.status).toBe(200) - const body = await res.text() - expect(body).toStartWith("# ") + // ?thinking=true must include the reasoning block + const withThinking = await app.request(`/session/${session.id}/transcript?thinking=true`) + expect(withThinking.status).toBe(200) + const bodyWithThinking = await withThinking.text() + expect(bodyWithThinking).toContain("_Thinking:_") + expect(bodyWithThinking).toContain("inner thoughts here") + + // ?thinking=false must NOT include it (validates the z.preprocess fix: + // old z.coerce.boolean() coerced "false" string → Boolean("false") = true) + const withFalse = await app.request(`/session/${session.id}/transcript?thinking=false`) + expect(withFalse.status).toBe(200) + const bodyWithFalse = await withFalse.text() + expect(bodyWithFalse).not.toContain("_Thinking:_") + + // default (no param) also excludes reasoning + const withDefault = await app.request(`/session/${session.id}/transcript`) + expect(withDefault.status).toBe(200) + const bodyWithDefault = await withDefault.text() + expect(bodyWithDefault).not.toContain("_Thinking:_") await Session.remove(session.id) },