fix(client): let clients drop tool-result image payloads from the store (#59101)

This commit is contained in:
Luke Parker
2026-10-26 09:48:44 +10:00
committed by GitHub
Unverified
parent 7b5bd8fdde
commit e943bb62da
2 changed files with 123 additions and 4 deletions
+32 -4
View File
@@ -34,6 +34,7 @@ import type {
SessionInboxCompaction,
ShellInfo,
SkillInfo,
ToolContent,
UsageLimitsInfo,
VcsInfo,
OpenCodeEvent,
@@ -79,6 +80,11 @@ export type CreateDataInput = {
}
/** Receives failed event-driven reads. Explicit reads still reject to their caller. */
readonly onError?: (error: unknown) => void
/**
* Drops the payload of inline `data:` files in tool results before they enter the store. For clients that never
* render tool-result files: a session full of screenshots otherwise keeps hundreds of MB of base64 alive.
*/
readonly omitToolFileData?: boolean
}
const messagePageLimit = 20
@@ -165,6 +171,26 @@ function optimisticFiles(files: SessionPromptInput["files"]) {
return items?.length ? items : undefined
}
// Keeps an empty `data:<mime>;base64,` URI so the item still says what it was. It is built from `mime` because any
// slice of the original URI is a V8 sliced string that keeps the whole payload alive.
function omitFileData(content: [ToolContent, ...ToolContent[]]): [ToolContent, ...ToolContent[]] {
const omit = (item: ToolContent): ToolContent =>
item.type === "file" && item.uri.startsWith("data:") ? { ...item, uri: `data:${item.mime};base64,` } : item
return [omit(content[0]), ...content.slice(1).map(omit)]
}
function omitMessageFileData(message: SessionMessageInfo): SessionMessageInfo {
if (message.type !== "assistant") return message
return {
...message,
content: message.content.map((part) =>
part.type === "tool" && "content" in part.state && part.state.content
? { ...part, state: { ...part.state, content: omitFileData(part.state.content) } }
: part,
),
}
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
if (sessionID !== "global" || !ref) return undefined
return {
@@ -283,6 +309,7 @@ export function createData(config: CreateDataInput) {
Object.values(store.session.info).toSorted((a, b) => b.time.updated - a.time.updated),
)
const messageIndex = new Map<string, Map<string, number>>()
const stored = (message: SessionMessageInfo) => (config.omitToolFileData ? omitMessageFileData(message) : message)
const sync = createSync()
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
const pendingUpdates = new Map<string, Map<string, SessionInboxInfo | SessionInbox.Delivery | undefined>>()
@@ -1024,7 +1051,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
input: tool.state.input,
metadata: event.data.metadata,
content: [...event.data.content],
content: config.omitToolFileData ? omitFileData(event.data.content) : [...event.data.content],
}
tool.executed = event.data.executed || tool.executed === true
if (event.data.native) tool.native = { ...tool.native, ...event.data.native }
@@ -1039,7 +1066,8 @@ export function createData(config: CreateDataInput) {
error: event.data.error,
input: typeof tool.state.input === "string" ? {} : tool.state.input,
metadata: event.data.metadata,
content: event.data.content,
content:
config.omitToolFileData && event.data.content ? omitFileData(event.data.content) : event.data.content,
}
tool.executed = event.data.executed || tool.executed === true
if (event.data.native) tool.native = { ...tool.native, ...event.data.native }
@@ -1734,7 +1762,7 @@ export function createData(config: CreateDataInput) {
limit: config.initialMessageLimit?.() ?? messagePageLimit,
order: "desc",
})
const fetched = response.data.toReversed()
const fetched = response.data.toReversed().map(stored)
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
const ids = new Set(fetched.map((item) => item.id))
@@ -1802,7 +1830,7 @@ export function createData(config: CreateDataInput) {
{ signal },
)
if (signal?.aborted) return
fetched.push(...response.data)
fetched.push(...response.data.map(stored))
next = response.cursor.next ?? undefined
if (!options?.all) break
} while (next)
+91
View File
@@ -232,6 +232,97 @@ test("reconciles a stale running tool when execution settles", async () => {
}
})
test("omits inline tool-result file payloads from read and live tool results when asked", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const screenshot = `data:image/png;base64,${"A".repeat(4096)}`
const tool = (id: string, state: object) => ({
type: "tool",
id,
name: "execute",
time: { created: 1, ran: 1 },
state,
})
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () =>
Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "provider", id: "model" },
time: { created: 1 },
content: [
tool("call_read", {
status: "completed",
input: {},
content: [
{ type: "text", text: "Took a screenshot" },
{ type: "file", uri: screenshot, mime: "image/png", name: "page.png" },
{ type: "file", uri: "file:///project/notes.md", mime: "text/markdown" },
],
}),
tool("call_live", { status: "running", input: {}, metadata: {} }),
],
},
],
cursor: {},
}),
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
omitToolFileData: true,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
const success: OpenCodeEvent = {
id: "evt_success",
created: 2,
type: "session.tool.success",
durable: { aggregateID: "ses_refresh", seq: 1, version: 2 },
data: {
sessionID: "ses_refresh",
assistantMessageID: "msg_assistant",
id: "call_live",
executed: true,
content: [{ type: "file", uri: screenshot, mime: "image/png" }],
},
}
listeners.forEach((listener) => listener({ name: success.type, details: success }))
expect(setup.data.session.message.get("ses_refresh", "msg_assistant")).toMatchObject({
content: [
{
state: {
content: [
{ type: "text", text: "Took a screenshot" },
{ type: "file", uri: "data:image/png;base64,", mime: "image/png", name: "page.png" },
{ type: "file", uri: "file:///project/notes.md", mime: "text/markdown" },
],
},
},
{
state: { status: "completed", content: [{ type: "file", uri: "data:image/png;base64,", mime: "image/png" }] },
},
],
})
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))