Skip to content
Closed
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
7 changes: 4 additions & 3 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { cloneProject, sanitizeProject } from "./global-sync/utils"
import { formatServerError } from "@/utils/server-errors"

type GlobalStore = {
Expand Down Expand Up @@ -97,8 +97,9 @@ function createGlobalSync() {
cacheProjects()
return
}
setGlobalStore("project", next)
cacheProjects()
const list = next.map(cloneProject)
setGlobalStore("project", list)
setProjectCache("value", list.map(sanitizeProject))
}

const setBootStore = ((...input: unknown[]) => {
Expand Down
91 changes: 89 additions & 2 deletions packages/app/src/context/global-sync/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils"
import type { Agent, Project } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import { cloneProject, normalizeAgentList, sanitizeProject } from "./utils"

const agent = (name = "build") =>
({
Expand Down Expand Up @@ -33,3 +34,89 @@ describe("normalizeAgentList", () => {
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
})
})

describe("sanitizeProject", () => {
test("cloneProject detaches nested project data without stripping icon fields", () => {
const [store] = createStore({
value: {
id: "proj_clone",
worktree: "/tmp/project-clone",
icon: {
url: "https://example.com/icon.png",
override: "data:image/png;base64,abc",
color: "blue",
},
commands: {
start: "bun dev",
},
time: {
created: 1,
updated: 2,
},
sandboxes: ["/tmp/project-a"],
} satisfies Project,
})

const next = cloneProject(store.value)

expect(next).not.toBe(store.value)
expect(next.time).not.toBe(store.value.time)
expect(next.sandboxes).not.toBe(store.value.sandboxes)
expect(next.commands).not.toBe(store.value.commands)
expect(next.icon).not.toBe(store.value.icon)
expect(next.icon?.url).toBe("https://example.com/icon.png")
expect(next.icon?.override).toBe("data:image/png;base64,abc")
})

test("clones nested project data and strips cached icon urls", () => {
const [store] = createStore({
value: {
id: "proj_1",
worktree: "/tmp/project",
name: "Project",
icon: {
url: "https://example.com/icon.png",
override: "data:image/png;base64,abc",
color: "pink",
},
commands: {
start: "bun dev",
},
time: {
created: 1,
updated: 2,
},
sandboxes: ["/tmp/project-a"],
} satisfies Project,
})

const next = sanitizeProject(store.value)

expect(next).not.toBe(store.value)
expect(next.time).not.toBe(store.value.time)
expect(next.sandboxes).not.toBe(store.value.sandboxes)
expect(next.commands).not.toBe(store.value.commands)
expect(next.icon).not.toBe(store.value.icon)
expect(next.icon?.url).toBeUndefined()
expect(next.icon?.override).toBeUndefined()
expect(next.icon?.color).toBe("pink")

next.sandboxes.push("/tmp/project-b")
expect(store.value.sandboxes).toEqual(["/tmp/project-a"])
})

test("returns a detached copy even without icon overrides", () => {
const project = {
id: "proj_2",
worktree: "/tmp/project-2",
time: { created: 1, updated: 1 },
sandboxes: [],
} satisfies Project

const next = sanitizeProject(project)

expect(next).not.toBe(project)
expect(next.time).not.toBe(project.time)
expect(next.sandboxes).not.toBe(project.sandboxes)
})
})
29 changes: 22 additions & 7 deletions packages/app/src/context/global-sync/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,29 @@ export function normalizeProviderList(input: ProviderListResponse): ProviderList
}
}

export function sanitizeProject(project: Project) {
if (!project.icon?.url && !project.icon?.override) return project
export function cloneProject(project: Project) {
return {
...project,
icon: {
...project.icon,
url: undefined,
override: undefined,
},
time: { ...project.time },
sandboxes: [...project.sandboxes],
...(project.commands ? { commands: { ...project.commands } } : {}),
...(project.icon ? { icon: { ...project.icon } } : {}),
}
}

export function sanitizeProject(project: Project) {
const next = cloneProject(project)
if (!next.icon) return next
return {
...next,
...(project.icon
? {
icon: {
...next.icon,
url: undefined,
override: undefined,
},
}
: {}),
}
}
6 changes: 5 additions & 1 deletion packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Effect, Layer, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { InstanceState } from "@/effect/instance-state"
import { isOverflow as overflow } from "./overflow"
import { Evidence } from "./evidence"

export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
Expand Down Expand Up @@ -130,7 +131,10 @@ export namespace SessionCompaction {
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
const state = part.state
const evidence = Evidence.tool({ tool: part.tool, state })
state.time.compacted = Date.now()
state.metadata = { ...state.metadata, evidence }
yield* session.updatePart(part)
}
}
Expand Down
90 changes: 90 additions & 0 deletions packages/opencode/src/session/evidence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Hash } from "@/util/hash"
import { Locale } from "@/util/locale"
import type { MessageV2 } from "./message-v2"

export namespace Evidence {
const INPUT_MAX = 240
const OUTPUT_MAX = 600
const OUTPUT_LINES = 12
const HASH_MAX = 12
const FILE_MAX = 3

export interface Tool {
tool: string
title: string
input: string
excerpt: string
hash: string
bytes: number
lines: number
path?: string
files?: string[]
}

function clip(input: string) {
return Locale.truncate(input.split("\n").slice(0, OUTPUT_LINES).join("\n"), OUTPUT_MAX)
}

function files(input?: MessageV2.ToolStateCompleted["attachments"]) {
if (!input?.length) return undefined
const list = input.map((item) => item.filename ?? item.mime)
if (list.length <= FILE_MAX) return list
return [...list.slice(0, FILE_MAX), `+${list.length - FILE_MAX} more`]
}

function path(input?: MessageV2.ToolStateCompleted["metadata"]) {
return typeof input?.outputPath === "string" ? input.outputPath : undefined
}

export function tool(input: {
tool: string
state: Pick<MessageV2.ToolStateCompleted, "title" | "input" | "output" | "metadata" | "attachments">
}): Tool {
const data = JSON.stringify(input.state.input)
return {
tool: input.tool,
title: input.state.title,
input: Locale.truncate(data === undefined ? "{}" : data, INPUT_MAX),
excerpt: clip(input.state.output),
hash: Hash.fast(input.state.output).slice(0, HASH_MAX),
bytes: Buffer.byteLength(input.state.output, "utf-8"),
lines: input.state.output.split("\n").length,
path: path(input.state.metadata),
files: files(input.state.attachments),
}
}

export function isTool(input: unknown): input is Tool {
if (!input || typeof input !== "object") return false
return (
"tool" in input &&
typeof input.tool === "string" &&
"title" in input &&
typeof input.title === "string" &&
"input" in input &&
typeof input.input === "string" &&
"excerpt" in input &&
typeof input.excerpt === "string" &&
"hash" in input &&
typeof input.hash === "string" &&
"bytes" in input &&
typeof input.bytes === "number" &&
"lines" in input &&
typeof input.lines === "number"
)
}

export function text(input: Tool) {
return [
"[Compacted tool result]",
`tool: ${input.tool}`,
`title: ${input.title}`,
`input: ${input.input}`,
`proof: sha1=${input.hash}, bytes=${input.bytes}, lines=${input.lines}`,
...(input.path ? [`path: ${input.path}`] : []),
...(input.files?.length ? [`attachments: ${input.files.join(", ")}`] : []),
"excerpt:",
input.excerpt,
].join("\n")
}
}
6 changes: 5 additions & 1 deletion packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export namespace LLM {
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none"
opts?: Record<string, any>
}

export type StreamRequest = StreamInput & {
Expand Down Expand Up @@ -142,6 +143,7 @@ export namespace LLM {
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
mergeDeep(input.opts ?? {}),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
Expand Down Expand Up @@ -255,7 +257,7 @@ export namespace LLM {
}
}

return streamText({
const result = streamText({
onError(error) {
l.error("stream error", {
error,
Expand Down Expand Up @@ -332,6 +334,8 @@ export namespace LLM {
},
},
})

return result
}

function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
Expand Down
12 changes: 9 additions & 3 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
import { Evidence } from "./evidence"

/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
Expand Down Expand Up @@ -255,6 +256,7 @@ export namespace MessageV2 {
reason: z.string(),
snapshot: z.string().optional(),
cost: z.number(),
metadata: z.record(z.string(), z.any()).optional(),
tokens: z.object({
total: z.number().optional(),
input: z.number(),
Expand Down Expand Up @@ -715,8 +717,12 @@ export namespace MessageV2 {
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
const state = part.state
const proof = Evidence.isTool(state.metadata?.evidence)
? state.metadata.evidence
: Evidence.tool({ tool: part.tool, state })
const outputText = state.time.compacted ? Evidence.text(proof) : state.output
const attachments = state.time.compacted || options?.stripMedia ? [] : (state.attachments ?? [])

// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
Expand All @@ -739,7 +745,7 @@ export namespace MessageV2 {
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
input: state.input,
output,
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
const log = Log.create({ service: "session.processor" })

function finishMetadata(input: Record<string, any> | undefined) {
const id = typeof input?.openai?.responseId === "string" ? input.openai.responseId : undefined
if (!id) return
return { openai: { responseId: id } }
}

export type Result = "compact" | "stop" | "continue"

export type Event = LLM.Event
Expand Down Expand Up @@ -277,6 +283,7 @@ export namespace SessionProcessor {
id: PartID.ascending(),
reason: value.finishReason,
snapshot: yield* snapshot.track(),
metadata: finishMetadata(value.providerMetadata),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "step-finish",
Expand Down
Loading
Loading