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
36 changes: 32 additions & 4 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
SessionInboxCompaction,
ShellInfo,
SkillInfo,
ToolContent,
UsageLimitsInfo,
VcsInfo,
OpenCodeEvent,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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>>()
Expand Down Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down Expand Up @@ -1719,7 +1747,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))
Expand Down Expand Up @@ -1787,7 +1815,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)
Expand Down
91 changes: 91 additions & 0 deletions packages/client/test/solid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading