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
48 changes: 48 additions & 0 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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({
Expand Down
175 changes: 175 additions & 0 deletions packages/opencode/test/server/session-transcript.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>) {
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<typeof Session.updatePart>[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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concern: this flag test only sends =true and asserts status 200 + # prefix, so it can't catch the coercion bug above and doesn't prove the flags change output. formatTranscript emits _Thinking:_ / **Input:** / **Output:** — build a session with reasoning + tool parts and assert =true includes those markers while =false/default exclude them. That also closes the PR's own unchecked "verify flags work" item.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7c1757. Replaced the smoke test with a session that has a real reasoning part. Now asserts ?thinking=true includes _Thinking:_, ?thinking=false excludes it (catches the coerce bug), and default also excludes it. 17 assertions, all pass.


// ?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)
},
}),
)
})
})
Loading