Skip to content

Commit 8737289

Browse files
committed
fix(core): omit execute media from history pages
1 parent a2b18aa commit 8737289

3 files changed

Lines changed: 165 additions & 84 deletions

File tree

‎packages/core/src/session/store.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,18 @@ export interface Interface {
8787

8888
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStore") {}
8989

90-
function stripReadMedia(row: typeof SessionMessageTable.$inferSelect): typeof SessionMessageTable.$inferSelect {
90+
function stripToolMedia(row: typeof SessionMessageTable.$inferSelect): typeof SessionMessageTable.$inferSelect {
9191
if (row.type !== "assistant" || !("content" in row.data)) return row
9292
let changed = false
9393
const content = row.data.content.map((part) => {
94-
if (part.type !== "tool" || part.name !== "read") return part
94+
if (part.type !== "tool" || (part.name !== "read" && part.name !== "execute")) return part
9595
if (part.state.status !== "completed" && part.state.status !== "error") return part
9696
if (!part.state.content) return part
9797
const filtered = part.state.content.filter((item) => !(item.type === "file" && item.uri.startsWith("data:")))
9898
if (filtered.length === part.state.content.length) return part
9999
changed = true
100-
const first = filtered[0] ?? ({ type: "text", text: "Image read successfully" } as const)
100+
// Content must remain nonempty, without inventing output for file-only execute results.
101+
const first = filtered[0] ?? ({ type: "text", text: part.name === "read" ? "Image read successfully" : "" } as const)
101102
return {
102103
...part,
103104
state: {
@@ -204,7 +205,7 @@ const layer = Layer.effect(
204205
Effect.orDie,
205206
)
206207
const ordered = direction === "previous" ? rows.toReversed() : rows
207-
return yield* Effect.forEach(ordered, (row) => SessionHistory.decodeMessageRow(stripReadMedia(row)))
208+
return yield* Effect.forEach(ordered, (row) => SessionHistory.decodeMessageRow(stripToolMedia(row)))
208209
}),
209210
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID, "latest")),
210211
message: Effect.fn("SessionStore.message")(function* (messageID) {

‎packages/core/test/session-store.test.ts‎

Lines changed: 82 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -192,95 +192,77 @@ describe("SessionStore", () => {
192192
}),
193193
)
194194

195-
it.effect("omits inline data URIs on read tool parts in messages list while retaining them on message and context", () =>
196-
Effect.gen(function* () {
197-
const sessionID = Session.ID.make("ses_read_media")
198-
yield* seedSessions([{ id: sessionID, updated: 0 }])
199-
const database = yield* Database.Service
200-
const store = yield* SessionStore.Service
201-
const assistantMessageID = SessionMessage.ID.make("msg_step_start")
202-
const inlineUri = `data:image/png;base64,${"A".repeat(4096)}`
203-
const created = DateTime.makeUnsafe(100)
204-
const encoded = Schema.encodeSync(SessionMessage.Info)(
205-
SessionMessage.Assistant.make({
206-
id: assistantMessageID,
207-
type: "assistant",
208-
agent: Agent.ID.make("build"),
209-
model: { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-4-5") },
210-
time: { created, completed: DateTime.makeUnsafe(120) },
211-
content: [
212-
{
213-
type: "tool",
214-
id: "call_read_1",
215-
name: "read",
216-
time: { created, completed: DateTime.makeUnsafe(120) },
217-
state: {
218-
status: "completed",
219-
input: { path: "screenshot.png" },
220-
content: [
221-
{ type: "text", text: "Image read successfully" },
222-
{ type: "file", uri: inlineUri, mime: "image/png", name: "screenshot.png" },
223-
],
195+
describe.each(["read", "execute"])("%s media", (name) => {
196+
it.effect("omits inline data URIs in messages list while retaining them on message and context", () =>
197+
Effect.gen(function* () {
198+
const sessionID = Session.ID.make("ses_read_media")
199+
yield* seedSessions([{ id: sessionID, updated: 0 }])
200+
const database = yield* Database.Service
201+
const store = yield* SessionStore.Service
202+
const assistantMessageID = SessionMessage.ID.make("msg_step_start")
203+
const inlineUri = `data:image/png;base64,${"A".repeat(4096)}`
204+
const created = DateTime.makeUnsafe(100)
205+
const encoded = Schema.encodeSync(SessionMessage.Info)(
206+
SessionMessage.Assistant.make({
207+
id: assistantMessageID,
208+
type: "assistant",
209+
agent: Agent.ID.make("build"),
210+
model: { providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-sonnet-4-5") },
211+
time: { created, completed: DateTime.makeUnsafe(120) },
212+
content: [
213+
{
214+
type: "tool",
215+
id: "call_read_1",
216+
name,
217+
time: { created, completed: DateTime.makeUnsafe(120) },
218+
state: {
219+
status: "completed",
220+
input: { path: "screenshot.png" },
221+
content: [
222+
{ type: "text", text: "Image read successfully" },
223+
{ type: "file", uri: inlineUri, mime: "image/png", name: "screenshot.png" },
224+
],
225+
},
224226
},
225-
},
226-
],
227-
}),
228-
)
229-
const { id, type, ...data } = encoded
230-
yield* database.db
231-
.insert(SessionMessageTable)
232-
.values({
233-
id: SessionMessage.ID.make(id),
234-
session_id: sessionID,
235-
type,
236-
seq: 2,
237-
time_created: 100,
238-
data,
239-
})
240-
.run()
241-
242-
const listed = yield* store.messages({ sessionID })
243-
expect(listed).toHaveLength(1)
244-
const assistant = listed[0]!
245-
expect(assistant.type).toBe("assistant")
246-
if (assistant.type !== "assistant") return
247-
expect(assistant.content).toMatchObject([
248-
{
249-
type: "tool",
250-
name: "read",
251-
state: {
252-
status: "completed",
253-
content: [{ type: "text", text: "Image read successfully" }],
254-
},
255-
},
256-
])
227+
],
228+
}),
229+
)
230+
const { id, type, ...data } = encoded
231+
yield* database.db
232+
.insert(SessionMessageTable)
233+
.values({
234+
id: SessionMessage.ID.make(id),
235+
session_id: sessionID,
236+
type,
237+
seq: 2,
238+
time_created: 100,
239+
data,
240+
})
241+
.run()
257242

258-
const single = yield* store.message(assistant.id)
259-
expect(single?.message).toMatchObject({
260-
type: "assistant",
261-
content: [
243+
const listed = yield* store.messages({ sessionID })
244+
expect(listed).toHaveLength(1)
245+
const assistant = listed[0]!
246+
expect(assistant.type).toBe("assistant")
247+
if (assistant.type !== "assistant") return
248+
expect(assistant.content).toMatchObject([
262249
{
263250
type: "tool",
264-
name: "read",
251+
name,
265252
state: {
266253
status: "completed",
267-
content: [
268-
{ type: "text", text: "Image read successfully" },
269-
{ type: "file", uri: inlineUri, mime: "image/png", name: "screenshot.png" },
270-
],
254+
content: [{ type: "text", text: "Image read successfully" }],
271255
},
272256
},
273-
],
274-
})
257+
])
275258

276-
const context = yield* store.context(sessionID)
277-
expect(context).toMatchObject([
278-
{
259+
const single = yield* store.message(assistant.id)
260+
expect(single?.message).toMatchObject({
279261
type: "assistant",
280262
content: [
281263
{
282264
type: "tool",
283-
name: "read",
265+
name,
284266
state: {
285267
status: "completed",
286268
content: [
@@ -290,8 +272,28 @@ describe("SessionStore", () => {
290272
},
291273
},
292274
],
293-
},
294-
])
295-
}),
296-
)
275+
})
276+
277+
const context = yield* store.context(sessionID)
278+
expect(context).toMatchObject([
279+
{
280+
type: "assistant",
281+
content: [
282+
{
283+
type: "tool",
284+
name,
285+
state: {
286+
status: "completed",
287+
content: [
288+
{ type: "text", text: "Image read successfully" },
289+
{ type: "file", uri: inlineUri, mime: "image/png", name: "screenshot.png" },
290+
],
291+
},
292+
},
293+
],
294+
},
295+
])
296+
}),
297+
)
298+
})
297299
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Run from packages/server; isolated synthetic HTTP fixture, no listening server.
2+
import { OpenCode, type SessionMessageInfo } from "@opencode/client"
3+
import { Session } from "@opencode/schema/session"
4+
import { Effect } from "effect"
5+
import assert from "node:assert/strict"
6+
import { ServerFetch } from "../src/fetch"
7+
import { tempGlobalLayer } from "../../core/test/fixture/global"
8+
9+
await Effect.runPromise(
10+
Effect.gen(function* () {
11+
const handler = yield* ServerFetch.make({
12+
app: { version: "benchmark" },
13+
database: { path: ":memory:" },
14+
config: { project: false },
15+
models: { fetch: false },
16+
fs: { filewatcher: false },
17+
})
18+
const measured = { bytes: 0 }
19+
const api = OpenCode.make({
20+
baseUrl: "http://fixture.invalid",
21+
fetch: Object.assign(
22+
async (input: string | URL | Request, init?: RequestInit) => {
23+
const response = await handler(new Request(input, init))
24+
const body = await response.arrayBuffer()
25+
measured.bytes += body.byteLength
26+
return new Response(body, { status: response.status, headers: response.headers })
27+
},
28+
{ preconnect: fetch.preconnect },
29+
),
30+
})
31+
yield* Effect.promise(async () => {
32+
const template = await api.session.create({ title: "Execute media page" })
33+
const messages: SessionMessageInfo[] = Array.from({ length: 40 }, (_, index) => ({
34+
id: `msg_fixture_${index}`,
35+
type: "assistant",
36+
agent: "build",
37+
model: { providerID: "test", id: "test" },
38+
content: index === 20 ? [{
39+
type: "tool",
40+
id: "call_execute",
41+
name: "execute",
42+
time: { created: index, completed: index + 1 },
43+
state: {
44+
status: "completed",
45+
input: { code: "return await tools.screenshot()" },
46+
content: [
47+
{ type: "text", text: "Captured screenshot" },
48+
{ type: "file", mime: "image/png", uri: `data:image/png;base64,${"A".repeat(2_550_000)}` },
49+
],
50+
},
51+
}] : [{ type: "text", text: "Synthetic response. ".repeat(20) }],
52+
finish: "stop",
53+
time: { created: index, completed: index + 1 },
54+
}))
55+
const session = await api.session.import({ info: { ...template, id: Session.ID.create() }, messages })
56+
const samples: { iteration: number; ms: number; bytes: number }[] = []
57+
for (let iteration = 0; iteration < 8; iteration++) {
58+
measured.bytes = 0
59+
const start = performance.now()
60+
const page = await api.message.list({ sessionID: session.id, limit: 40 })
61+
const sample = { iteration, ms: performance.now() - start, bytes: measured.bytes }
62+
assert.equal(page.data.length, 40)
63+
assert.deepEqual(page.data.map((message) => message.id), messages.toReversed().map((message) => message.id))
64+
const message = page.data.find((message) => message.id === "msg_fixture_20")
65+
assert(message?.type === "assistant")
66+
const tool = message.content[0]
67+
assert(tool?.type === "tool" && tool.state.status === "completed")
68+
assert.deepEqual(tool.state.content[0], { type: "text", text: "Captured screenshot" })
69+
samples.push(sample)
70+
}
71+
const times = samples.slice(1).map((sample) => sample.ms).sort((a, b) => a - b)
72+
console.log(JSON.stringify({
73+
fixture: "40 synthetic messages, one execute result with 2.55 MB base64; not HOME replay",
74+
median: times[3], min: times[0], max: times.at(-1), samples,
75+
}, null, 2))
76+
})
77+
}).pipe(Effect.provide(tempGlobalLayer), Effect.scoped),
78+
)

0 commit comments

Comments
 (0)