Skip to content

Commit 8851e4d

Browse files
authored
fix(acp): clean read tool display content (anomalyco#30569)
1 parent e707e41 commit 8851e4d

5 files changed

Lines changed: 179 additions & 7 deletions

File tree

packages/opencode/src/acp/tool.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,12 +98,14 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
9898
}
9999

100100
export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
101+
const text =
102+
toolName.toLocaleLowerCase() === "read" ? (readDisplayText(state.metadata) ?? state.output) : state.output
101103
const content: ToolCallContent[] = [
102104
{
103105
type: "content",
104106
content: {
105107
type: "text",
106-
text: state.output,
108+
text,
107109
},
108110
},
109111
]
@@ -288,6 +290,18 @@ function diffContent(input: ToolInput): ToolCallContent[] {
288290
]
289291
}
290292

293+
function readDisplayText(metadata: unknown) {
294+
if (!metadata || typeof metadata !== "object") return undefined
295+
const display = (metadata as Record<string, unknown>).display
296+
if (!display || typeof display !== "object") return undefined
297+
const info = display as Record<string, unknown>
298+
if (info.type === "file") return stringValue(info.text)
299+
if (info.type === "directory" && Array.isArray(info.entries)) {
300+
return info.entries.filter((item): item is string => typeof item === "string").join("\n")
301+
}
302+
return undefined
303+
}
304+
291305
function dataUrlImage(attachment: ToolAttachment) {
292306
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
293307
const mime = match?.[1] ?? stringValue(attachment.mime)

packages/opencode/src/tool/read.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,37 @@ export const Parameters = Schema.Struct({
3636
}),
3737
})
3838

39-
export const ReadTool = Tool.define(
39+
type Display =
40+
| {
41+
type: "directory"
42+
path: string
43+
entries: string[]
44+
offset: number
45+
totalEntries: number
46+
truncated: boolean
47+
}
48+
| {
49+
type: "file"
50+
path: string
51+
text: string
52+
lineStart: number
53+
lineEnd: number
54+
totalLines: number
55+
truncated: boolean
56+
}
57+
58+
type Metadata = {
59+
preview: string
60+
truncated: boolean
61+
loaded: string[]
62+
display?: Display
63+
}
64+
65+
export const ReadTool = Tool.define<
66+
typeof Parameters,
67+
Metadata,
68+
FSUtil.Service | Instruction.Service | LSP.Service | Reference.Service | Scope.Scope
69+
>(
4070
"read",
4171
Effect.gen(function* () {
4272
const fs = yield* FSUtil.Service
@@ -200,7 +230,7 @@ export const ReadTool = Tool.define(
200230

201231
const run = Effect.fn("ReadTool.execute")(function* (
202232
params: Schema.Schema.Type<typeof Parameters>,
203-
ctx: Tool.Context,
233+
ctx: Tool.Context<Metadata>,
204234
) {
205235
const instance = yield* InstanceState.context
206236
let filepath = params.filePath
@@ -258,6 +288,14 @@ export const ReadTool = Tool.define(
258288
preview: sliced.slice(0, 20).join("\n"),
259289
truncated,
260290
loaded: [] as string[],
291+
display: {
292+
type: "directory" as const,
293+
path: filepath,
294+
entries: sliced,
295+
offset,
296+
totalEntries: items.length,
297+
truncated,
298+
},
261299
},
262300
}
263301
}
@@ -328,14 +366,23 @@ export const ReadTool = Tool.define(
328366
preview: file.raw.slice(0, 20).join("\n"),
329367
truncated,
330368
loaded: loaded.map((item) => item.filepath),
369+
display: {
370+
type: "file" as const,
371+
path: filepath,
372+
text: file.raw.join("\n"),
373+
lineStart: file.offset,
374+
lineEnd: last,
375+
totalLines: file.count,
376+
truncated,
377+
},
331378
},
332379
}
333380
})
334381

335382
return {
336383
description: DESCRIPTION,
337384
parameters: Parameters,
338-
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
385+
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
339386
run(params, ctx).pipe(Effect.orDie),
340387
}
341388
}),

packages/opencode/test/acp/event.test.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,20 +251,25 @@ function completedTool(
251251
callID: string,
252252
output = "done",
253253
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
254+
options: {
255+
readonly tool?: string
256+
readonly input?: Record<string, unknown>
257+
readonly metadata?: Record<string, unknown>
258+
} = {},
254259
) {
255260
return {
256261
id: `part_${callID}`,
257262
sessionID,
258263
messageID: `msg_${callID}`,
259264
type: "tool",
260265
callID,
261-
tool: "bash",
266+
tool: options.tool ?? "bash",
262267
state: {
263268
status: "completed",
264-
input: { cmd: "printf done" },
269+
input: options.input ?? { cmd: "printf done" },
265270
output,
266271
title: "bash",
267-
metadata: { exit: 0 },
272+
metadata: options.metadata ?? { exit: 0 },
268273
time: { start: Date.now() - 1, end: Date.now() },
269274
...(attachments.length ? { attachments } : {}),
270275
},
@@ -605,6 +610,55 @@ describe("acp event routing", () => {
605610
})
606611
})
607612

613+
it("emits clean read display content and preserves rawOutput", async () => {
614+
const harness = createHarness()
615+
await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
616+
const output = [
617+
"<path>/workspace/file.ts</path>",
618+
"<type>file</type>",
619+
"<content>",
620+
"1: import { value } from './value'",
621+
"2: export { value }",
622+
"",
623+
"(End of file - total 2 lines)",
624+
"</content>",
625+
].join("\n")
626+
const metadata = {
627+
display: {
628+
type: "file",
629+
path: "/workspace/file.ts",
630+
text: "import { value } from './value'\nexport { value }",
631+
lineStart: 1,
632+
lineEnd: 2,
633+
totalLines: 2,
634+
truncated: false,
635+
},
636+
}
637+
638+
await harness.subscription.handle(
639+
toolUpdated(
640+
completedTool("ses_read", "call_read", output, [], {
641+
tool: "read",
642+
input: { filePath: "/workspace/file.ts" },
643+
metadata,
644+
}),
645+
),
646+
)
647+
648+
expect(harness.updates.at(-1)?.update).toMatchObject({
649+
sessionUpdate: "tool_call_update",
650+
toolCallId: "call_read",
651+
status: "completed",
652+
content: [
653+
{
654+
type: "content",
655+
content: { type: "text", text: "import { value } from './value'\nexport { value }" },
656+
},
657+
],
658+
rawOutput: { output, metadata },
659+
})
660+
})
661+
608662
it("emits error tool output", async () => {
609663
const harness = createHarness()
610664
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))

packages/opencode/test/acp/tool.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,46 @@ describe("acp tool conversion", () => {
104104
])
105105
})
106106

107+
test("uses clean read display text for completed content", () => {
108+
const output = [
109+
"<path>/tmp/file.ts</path>",
110+
"<type>file</type>",
111+
"<content>",
112+
"7: first",
113+
"8: second",
114+
"",
115+
"(End of file - total 8 lines)",
116+
"</content>",
117+
].join("\n")
118+
const state = {
119+
status: "completed" as const,
120+
input: { filePath: "/tmp/file.ts" },
121+
output,
122+
metadata: {
123+
display: {
124+
type: "file",
125+
path: "/tmp/file.ts",
126+
text: "first\nsecond",
127+
lineStart: 7,
128+
lineEnd: 8,
129+
totalLines: 8,
130+
truncated: false,
131+
},
132+
},
133+
}
134+
135+
expect(completedToolContent("read", state)).toEqual([
136+
{
137+
type: "content",
138+
content: { type: "text", text: "first\nsecond" },
139+
},
140+
])
141+
expect(completedToolRawOutput(state)).toEqual({
142+
output,
143+
metadata: state.metadata,
144+
})
145+
})
146+
107147
test("builds completed raw output with optional metadata and attachments", () => {
108148
const attachments = [
109149
{

packages/opencode/test/tool/read.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,15 @@ describe("tool.read truncation", () => {
428428
const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
429429
expect(result.metadata.truncated).toBe(false)
430430
expect(result.output).toContain("End of file")
431+
expect(result.metadata.display).toMatchObject({
432+
type: "file",
433+
path: path.join(test.directory, "small.txt"),
434+
text: "hello world",
435+
lineStart: 1,
436+
lineEnd: 1,
437+
totalLines: 1,
438+
truncated: false,
439+
})
431440
}),
432441
)
433442

@@ -495,6 +504,14 @@ describe("tool.read truncation", () => {
495504
const result = yield* exec(dir, { filePath: path.join(dir, "dir"), offset: 6, limit: 5 })
496505
expect(result.metadata.truncated).toBe(false)
497506
expect(result.output).not.toContain("Showing 5 of 10 entries")
507+
expect(result.metadata.display).toMatchObject({
508+
type: "directory",
509+
path: path.join(dir, "dir"),
510+
entries: ["file-5.txt", "file-6.txt", "file-7.txt", "file-8.txt", "file-9.txt"],
511+
offset: 6,
512+
totalEntries: 10,
513+
truncated: false,
514+
})
498515
}),
499516
)
500517

0 commit comments

Comments
 (0)