Skip to content

Commit ae69109

Browse files
altimate-harness-bot[bot]saravmajestic
andauthored
feat: [AI-6949] add session transcript REST endpoint for datamates extension (#941)
* feat: [AI-6949] add GET /session/:id/transcript endpoint for markdown export * test: [AI-6949] add server test for GET /session/:id/transcript endpoint * 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 --------- Co-authored-by: saravmajestic <saravmajestic@altimate.ai>
1 parent faf0b61 commit ae69109

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

packages/opencode/src/server/routes/session.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Hono } from "hono"
2+
import { formatTranscript } from "@/cli/cmd/tui/util/transcript"
23
import { stream } from "hono/streaming"
34
import { describeRoute, validator, resolver } from "hono-openapi"
45
import { SessionID, MessageID, PartID } from "@/session/schema"
@@ -462,6 +463,53 @@ export const SessionRoutes = lazy(() =>
462463
return c.json(result)
463464
},
464465
)
466+
.get(
467+
"/:sessionID/transcript",
468+
describeRoute({
469+
summary: "Get session transcript",
470+
description: "Retrieve the full conversation transcript for a session as formatted markdown.",
471+
tags: ["Session"],
472+
operationId: "session.transcript",
473+
responses: {
474+
200: {
475+
description: "Markdown transcript of the session",
476+
content: {
477+
"text/plain": {
478+
schema: resolver(z.string()),
479+
},
480+
},
481+
},
482+
...errors(400, 404),
483+
},
484+
}),
485+
validator(
486+
"param",
487+
z.object({
488+
sessionID: SessionID.zod,
489+
}),
490+
),
491+
validator(
492+
"query",
493+
z.object({
494+
thinking: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include reasoning/thinking parts" }),
495+
toolDetails: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include tool input/output details" }),
496+
assistantMetadata: z.preprocess(v => v === "false" ? false : v, z.coerce.boolean()).optional().default(false).meta({ description: "Include assistant model/timing metadata" }),
497+
}),
498+
),
499+
async (c) => {
500+
const sessionID = c.req.valid("param").sessionID
501+
const query = c.req.valid("query")
502+
const session = await Session.get(sessionID)
503+
const messages = await Session.messages({ sessionID })
504+
const transcript = formatTranscript(session, messages, {
505+
thinking: query.thinking,
506+
toolDetails: query.toolDetails,
507+
assistantMetadata: query.assistantMetadata,
508+
})
509+
c.header("Content-Type", "text/plain; charset=utf-8")
510+
return c.text(transcript)
511+
},
512+
)
465513
.delete(
466514
"/:sessionID/share",
467515
describeRoute({
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Instance } from "../../src/project/instance"
3+
import { Server } from "../../src/server/server"
4+
import { Session } from "../../src/session"
5+
import { MessageV2 } from "../../src/session/message-v2"
6+
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
7+
import { Log } from "../../src/util/log"
8+
import { tmpdir } from "../fixture/fixture"
9+
10+
Log.init({ print: false })
11+
12+
afterEach(async () => {
13+
await Instance.disposeAll()
14+
})
15+
16+
async function withoutWatcher<T>(fn: () => Promise<T>) {
17+
if (process.platform !== "win32") return fn()
18+
const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
19+
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
20+
try {
21+
return await fn()
22+
} finally {
23+
if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
24+
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev
25+
}
26+
}
27+
28+
async function addUserMessage(sessionID: SessionID, text: string) {
29+
const id = MessageID.ascending()
30+
await Session.updateMessage({
31+
id,
32+
sessionID,
33+
role: "user",
34+
time: { created: Date.now() },
35+
agent: "test",
36+
model: { providerID: "test", modelID: "test" },
37+
tools: {},
38+
mode: "",
39+
} as unknown as MessageV2.Info)
40+
await Session.updatePart({
41+
id: PartID.ascending(),
42+
sessionID,
43+
messageID: id,
44+
type: "text",
45+
text,
46+
})
47+
return id
48+
}
49+
50+
async function addAssistantMessageWithReasoning(sessionID: SessionID, parentID: MessageID, reasoningText: string) {
51+
const id = MessageID.ascending()
52+
await Session.updateMessage({
53+
id,
54+
sessionID,
55+
role: "assistant",
56+
agent: "build",
57+
modelID: "claude-sonnet",
58+
providerID: "anthropic",
59+
mode: "",
60+
parentID,
61+
path: { cwd: "/test", root: "/test" },
62+
cost: 0,
63+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
64+
time: { created: Date.now() },
65+
} as unknown as MessageV2.Info)
66+
await Session.updatePart({
67+
id: PartID.ascending(),
68+
sessionID,
69+
messageID: id,
70+
type: "reasoning",
71+
text: reasoningText,
72+
time: { start: Date.now() },
73+
} as unknown as Parameters<typeof Session.updatePart>[0])
74+
return id
75+
}
76+
77+
describe("session transcript endpoint", () => {
78+
test("returns 200 with text/plain markdown for a session", async () => {
79+
await using tmp = await tmpdir({ git: true })
80+
await withoutWatcher(() =>
81+
Instance.provide({
82+
directory: tmp.path,
83+
fn: async () => {
84+
const session = await Session.create({ title: "Test Session" })
85+
await addUserMessage(session.id, "Hello world")
86+
const app = Server.Default()
87+
88+
const res = await app.request(`/session/${session.id}/transcript`)
89+
expect(res.status).toBe(200)
90+
expect(res.headers.get("content-type")).toContain("text/plain")
91+
92+
const body = await res.text()
93+
expect(body).toStartWith("# ")
94+
expect(body).toContain("Test Session")
95+
expect(body).toContain("## User")
96+
expect(body).toContain("Hello world")
97+
98+
await Session.remove(session.id)
99+
},
100+
}),
101+
)
102+
})
103+
104+
test("returns 404 for a missing session", async () => {
105+
await using tmp = await tmpdir({ git: true })
106+
await withoutWatcher(() =>
107+
Instance.provide({
108+
directory: tmp.path,
109+
fn: async () => {
110+
const app = Server.Default()
111+
const res = await app.request(`/session/ses_nonexistent/transcript`)
112+
expect(res.status).toBe(404)
113+
},
114+
}),
115+
)
116+
})
117+
118+
test("returns markdown for an empty session (no messages)", async () => {
119+
await using tmp = await tmpdir({ git: true })
120+
await withoutWatcher(() =>
121+
Instance.provide({
122+
directory: tmp.path,
123+
fn: async () => {
124+
const session = await Session.create({ title: "Empty Session" })
125+
const app = Server.Default()
126+
127+
const res = await app.request(`/session/${session.id}/transcript`)
128+
expect(res.status).toBe(200)
129+
const body = await res.text()
130+
expect(body).toStartWith("# Empty Session")
131+
expect(body).toContain("Session ID:")
132+
133+
await Session.remove(session.id)
134+
},
135+
}),
136+
)
137+
})
138+
139+
test("thinking=true includes reasoning, thinking=false and default exclude it", async () => {
140+
await using tmp = await tmpdir({ git: true })
141+
await withoutWatcher(() =>
142+
Instance.provide({
143+
directory: tmp.path,
144+
fn: async () => {
145+
const session = await Session.create({ title: "Reasoning Session" })
146+
const userMsgId = await addUserMessage(session.id, "think about this")
147+
await addAssistantMessageWithReasoning(session.id, userMsgId, "inner thoughts here")
148+
const app = Server.Default()
149+
150+
// ?thinking=true must include the reasoning block
151+
const withThinking = await app.request(`/session/${session.id}/transcript?thinking=true`)
152+
expect(withThinking.status).toBe(200)
153+
const bodyWithThinking = await withThinking.text()
154+
expect(bodyWithThinking).toContain("_Thinking:_")
155+
expect(bodyWithThinking).toContain("inner thoughts here")
156+
157+
// ?thinking=false must NOT include it (validates the z.preprocess fix:
158+
// old z.coerce.boolean() coerced "false" string → Boolean("false") = true)
159+
const withFalse = await app.request(`/session/${session.id}/transcript?thinking=false`)
160+
expect(withFalse.status).toBe(200)
161+
const bodyWithFalse = await withFalse.text()
162+
expect(bodyWithFalse).not.toContain("_Thinking:_")
163+
164+
// default (no param) also excludes reasoning
165+
const withDefault = await app.request(`/session/${session.id}/transcript`)
166+
expect(withDefault.status).toBe(200)
167+
const bodyWithDefault = await withDefault.text()
168+
expect(bodyWithDefault).not.toContain("_Thinking:_")
169+
170+
await Session.remove(session.id)
171+
},
172+
}),
173+
)
174+
})
175+
})

0 commit comments

Comments
 (0)