mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(client): let clients drop tool-result image payloads from the store (#59101)
This commit is contained in:
committed by
GitHub
Unverified
parent
7b5bd8fdde
commit
e943bb62da
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user