diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 02c4980aa..c9d16491d 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.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) => { + 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({ 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..3ac4df66e --- /dev/null +++ b/packages/opencode/test/server/session-transcript.test.ts @@ -0,0 +1,175 @@ +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 +} + +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 }) + 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("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: "Reasoning Session" }) + const userMsgId = await addUserMessage(session.id, "think about this") + await addAssistantMessageWithReasoning(session.id, userMsgId, "inner thoughts here") + const app = Server.Default() + + // ?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) + }, + }), + ) + }) +})