Visitar URL original
fix(core): show the start of tool output that exceeds the limit (#63272) · anomalyco/opencode@7392bd4 · GitHub
Skip to content

Commit 7392bd4

Browse files
authored
fix(core): show the start of tool output that exceeds the limit (#63272)
1 parent afbd99f commit 7392bd4

2 files changed

Lines changed: 238 additions & 28 deletions

File tree

‎packages/core/src/tool-output.ts‎

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -75,48 +75,56 @@ const layer = Layer.effect(
7575
const truncate = Effect.fnUntraced(function* (result: Result) {
7676
if (result.metadata?.truncated !== undefined) return result
7777
const content = result.content
78-
const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
78+
// A line break joins text parts unless the text before already ends in one.
79+
const text = content.reduce(
80+
(joined, item) =>
81+
item.type !== "text"
82+
? joined
83+
: joined === "" || joined.endsWith("\n")
84+
? joined + item.text
85+
: `${joined}\n${item.text}`,
86+
"",
87+
)
7988
const limits = state.get()
80-
const lines = text.split("\n")
81-
if (text.endsWith("\n")) lines.pop()
82-
const totalBytes = Buffer.byteLength(text, "utf-8")
83-
if (lines.length <= limits.maxLines && totalBytes <= limits.maxBytes)
89+
const totalLines = countLines(text)
90+
if (totalLines <= limits.maxLines && Buffer.byteLength(text, "utf-8") <= limits.maxBytes)
8491
return { ...result, metadata: { ...result.metadata, truncated: false } }
8592

86-
const kept: string[] = []
87-
let bytes = 0
88-
for (const line of lines.slice(0, limits.maxLines)) {
89-
const size = Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0)
90-
if (bytes + size > limits.maxBytes) break
91-
kept.push(line)
92-
bytes += size
93-
}
93+
// Cut once, at the line or byte limit, whichever comes first, so a line too long for the space left shows its
94+
// start. The preview never ends on a line break.
95+
const end = Math.min(afterLines(text, limits.maxLines), byteBoundary(text, limits.maxBytes))
96+
const shown = text.slice(0, text.endsWith("\r\n", end) ? end - 2 : text.endsWith("\n", end) ? end - 1 : end)
9497
const file = path.join(directory, fileName())
9598
const failure = yield* fs.ensureDir(directory).pipe(
9699
Effect.andThen(fs.writeFileString(file, text)),
97100
Effect.as(undefined),
98101
Effect.catch((error) =>
99-
Effect.logWarning("failed to save full tool output", { file, cause: error }).pipe(
100-
Effect.as(describe(error)),
101-
),
102+
Effect.logWarning("failed to save full tool output", { file, cause: error }).pipe(Effect.as(describe(error))),
102103
),
103104
)
104-
const shown = kept.length > 0 ? `lines 1-${kept.length}` : "0 lines"
105-
const marker = `[showing ${shown} of ${lines.length}; ${
105+
// Counted before the final line break is stripped, so a blank last line still counts.
106+
const shownLines = countLines(text.slice(0, end))
107+
const lastLineCut =
108+
shownLines > 0 &&
109+
shown.length < text.length &&
110+
!text.startsWith("\n", shown.length) &&
111+
!text.startsWith("\r\n", shown.length)
112+
const range = shownLines === 0 ? "0 lines" : `lines 1-${shownLines}`
113+
const marker = `[showing ${range} of ${totalLines}${lastLineCut ? " (last line cut)" : ""}; ${
106114
failure === undefined ? `full output saved to ${file}` : `full output could not be saved: ${failure}`
107115
}]`
108116
const bounded: Tool.Content[] = []
109-
let remaining = kept.join("\n").length
110-
let seenText = false
117+
let remaining = shown.length
118+
let separator = false
111119
let marked = false
112120
for (const item of content) {
113121
if (item.type === "file") {
114122
bounded.push(item)
115123
continue
116124
}
117-
if (seenText && remaining > 0) remaining--
118-
seenText = true
119-
if (remaining >= item.text.length) {
125+
if (separator && remaining > 0) remaining--
126+
separator = item.text !== "" && !item.text.endsWith("\n")
127+
if (!marked && remaining >= item.text.length) {
120128
bounded.push(item)
121129
remaining -= item.text.length
122130
continue
@@ -172,3 +180,41 @@ export const node = makeLocationNode({
172180
layer,
173181
deps: [FSUtil.node, Global.node, cleanupNode],
174182
})
183+
184+
/** How many lines `text` has, not counting an empty one after a final line break. Counts without splitting. */
185+
function countLines(text: string) {
186+
if (text === "") return 0
187+
let count = 1
188+
for (let index = text.indexOf("\n"); index !== -1 && index < text.length - 1; index = text.indexOf("\n", index + 1))
189+
count++
190+
return count
191+
}
192+
193+
/** Where the line after the first `count` lines starts, or the end of `text`. */
194+
function afterLines(text: string, count: number) {
195+
let index = 0
196+
for (let line = 0; line < count; line++) {
197+
const next = text.indexOf("\n", index)
198+
if (next === -1) return text.length
199+
index = next + 1
200+
}
201+
return index
202+
}
203+
204+
const encoder = new TextEncoder()
205+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
206+
207+
/** The longest start of `text` that fits in `budget` UTF-8 bytes without splitting a character, emoji included. */
208+
function byteBoundary(text: string, budget: number) {
209+
// encodeInto only encodes what fits in the buffer, and stops at a whole code point.
210+
const index = encoder.encodeInto(text, new Uint8Array(Math.max(0, budget))).read
211+
if (index === text.length || index === 0) return index
212+
const before = text.charCodeAt(index - 1)
213+
const after = text.charCodeAt(index)
214+
// Two ASCII characters always have a boundary between them, except the two halves of \r\n.
215+
if (before < 0x80 && after < 0x80) return before === 13 && after === 10 ? index - 1 : index
216+
// Otherwise back off to the start of the grapheme the cut is in. A line break always ends one, so segmenting from the
217+
// start of the line keeps flag pairs and long emoji sequences exact; one code point past the cut gives the context.
218+
const start = text.lastIndexOf("\n", index - 1) + 1
219+
return start + (graphemes.segment(text.slice(start, index + 2)).containing(index - start)?.index ?? index - start)
220+
}

‎packages/core/test/tool-output.test.ts‎

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,31 +59,192 @@ describe("ToolOutput", () => {
5959
{ type: "text", text: "one\ntwo" },
6060
{
6161
type: "text",
62-
text: expect.stringMatching(/^\[showing lines 1-2 of 3; full output could not be saved: E[A-Z]+: [^\n]+\]$/),
62+
text: expect.stringMatching(
63+
/^\[showing lines 1-2 of 3; full output could not be saved: E[A-Z]+: [^\n]+\]$/,
64+
),
6365
},
6466
])
6567
}),
6668
{ maxLines: 2, maxBytes: 1_000 },
6769
),
6870
)
6971

70-
it.live("reports lines shown under the byte limit", () =>
72+
it.live("cuts at the byte limit, inside a line when it falls there", () =>
7173
withStore(
7274
(output) =>
7375
Effect.gen(function* () {
7476
const result = yield* output.truncate({ content: [{ type: "text", text: "one\ntwo" }] })
7577
expect(result.content).toEqual([
76-
{ type: "text", text: "one" },
78+
{ type: "text", text: "one\nt" },
7779
{
7880
type: "text",
79-
text: expect.stringMatching(/^\[showing lines 1-1 of 2; full output saved to .+\]$/),
81+
text: expect.stringMatching(/^\[showing lines 1-2 of 2 \(last line cut\); full output saved to .+\]$/),
8082
},
8183
])
8284
}),
8385
{ maxLines: 100, maxBytes: 5 },
8486
),
8587
)
8688

89+
it.live("shows the start of a single line longer than the byte limit", () =>
90+
withStore(
91+
(output) =>
92+
Effect.gen(function* () {
93+
const text = JSON.stringify({ items: Array.from({ length: 50 }, (_, index) => index) })
94+
const result = yield* output.truncate({ content: [{ type: "text", text }] })
95+
expect(result.content).toEqual([
96+
{ type: "text", text: text.slice(0, 20) },
97+
{
98+
type: "text",
99+
text: expect.stringMatching(
100+
new RegExp("^\\[showing lines 1-1 of 1 \\(last line cut\\); full output saved to .+\\]$"),
101+
),
102+
},
103+
])
104+
}),
105+
{ maxLines: 100, maxBytes: 20 },
106+
),
107+
)
108+
109+
it.live("never splits a character or an emoji at the cut", () =>
110+
withStore(
111+
(output) =>
112+
Effect.gen(function* () {
113+
const family = "👨‍👩‍👧‍👦"
114+
const cases = [
115+
{ text: "ab✓✓✓", shown: "ab✓" },
116+
{ text: `ab${family}cd`, shown: "ab" },
117+
{ text: "ab👍🏽cd", shown: "ab" },
118+
{ text: "ab🇯🇵cd", shown: "ab" },
119+
{ text: "ab𝒳𝒳", shown: "ab𝒳" },
120+
]
121+
for (const item of cases) {
122+
const result = yield* output.truncate({ content: [{ type: "text", text: item.text }] })
123+
const first = result.content[0]
124+
expect(first).toEqual({ type: "text", text: item.shown })
125+
expect(first?.type === "text" && first.text.isWellFormed()).toBe(true)
126+
}
127+
}),
128+
{ maxLines: 100, maxBytes: 7 },
129+
),
130+
)
131+
132+
it.live("keeps flags and long clusters whole however long the text before them", () =>
133+
withStore(
134+
(output) =>
135+
Effect.gen(function* () {
136+
const flags = yield* output.truncate({ content: [{ type: "text", text: "a" + "🇯🇵".repeat(40) }] })
137+
expect(flags.content[0]).toEqual({ type: "text", text: "a" + "🇯🇵".repeat(20) })
138+
const cluster = "👍" + "🏽\u0301".repeat(60)
139+
const long = yield* output.truncate({ content: [{ type: "text", text: `ab${cluster}z` }] })
140+
expect(long.content[0]).toEqual({ type: "text", text: "ab" })
141+
}),
142+
{ maxLines: 100, maxBytes: 165 },
143+
),
144+
)
145+
146+
it.live("cuts inside a later text part and leaves out the parts after it", () =>
147+
withStore(
148+
(output) =>
149+
Effect.gen(function* () {
150+
const inside = yield* output.truncate({
151+
content: [
152+
{ type: "text", text: "" },
153+
{ type: "text", text: "abc" },
154+
{ type: "text", text: "defgh" },
155+
{ type: "text", text: "" },
156+
],
157+
})
158+
expect(inside.content).toEqual([
159+
{ type: "text", text: "" },
160+
{ type: "text", text: "abc" },
161+
{ type: "text", text: "de" },
162+
{
163+
type: "text",
164+
text: expect.stringMatching(/^\[showing lines 1-2 of 2 \(last line cut\);/),
165+
},
166+
])
167+
const between = yield* output.truncate({
168+
content: [
169+
{ type: "text", text: "abcde" },
170+
{ type: "text", text: "fgh" },
171+
],
172+
})
173+
expect(between.content).toEqual([
174+
{ type: "text", text: "abcde" },
175+
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 2;/) },
176+
])
177+
}),
178+
{ maxLines: 100, maxBytes: 6 },
179+
),
180+
)
181+
182+
it.live("ends a line cut before its line break", () =>
183+
withStore(
184+
(output) =>
185+
Effect.gen(function* () {
186+
const crlf = yield* output.truncate({ content: [{ type: "text", text: "one\r\ntwo\r\nthree" }] })
187+
expect(crlf.content).toEqual([
188+
{ type: "text", text: "one" },
189+
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 3;/) },
190+
])
191+
const blank = yield* output.truncate({ content: [{ type: "text", text: "\nfoo\nbar" }] })
192+
expect(blank.content).toEqual([{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 3;/) }])
193+
}),
194+
{ maxLines: 1, maxBytes: 1_000 },
195+
),
196+
)
197+
198+
it.live("counts a blank last line of the preview", () =>
199+
withStore(
200+
(output) =>
201+
Effect.gen(function* () {
202+
const result = yield* output.truncate({ content: [{ type: "text", text: "a\n\n\nb" }] })
203+
expect(result.content).toEqual([
204+
{ type: "text", text: "a\n" },
205+
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-2 of 4;/) },
206+
])
207+
}),
208+
{ maxLines: 2, maxBytes: 1_000 },
209+
),
210+
)
211+
212+
it.live("shows nothing under a zero line limit", () =>
213+
withStore(
214+
(output) =>
215+
Effect.gen(function* () {
216+
const result = yield* output.truncate({ content: [{ type: "text", text: "secret" }] })
217+
expect(result.content).toEqual([{ type: "text", text: expect.stringMatching(/^\[showing 0 lines of 1;/) }])
218+
}),
219+
{ maxLines: 0, maxBytes: 1_000 },
220+
),
221+
)
222+
223+
it.live("joins text parts with a line break only where one is missing", () =>
224+
withStore(
225+
(output, fs) =>
226+
Effect.gen(function* () {
227+
const result = yield* output.truncate({
228+
content: [
229+
{ type: "text", text: "one\n" },
230+
{ type: "text", text: "two" },
231+
{ type: "text", text: "three" },
232+
],
233+
})
234+
const outputPath = result.metadata?.outputPath
235+
expect(typeof outputPath).toBe("string")
236+
if (typeof outputPath !== "string") return
237+
expect(yield* fs.readFileString(outputPath)).toBe("one\ntwo\nthree")
238+
expect(result.content).toEqual([
239+
{ type: "text", text: "one\n" },
240+
{ type: "text", text: "two" },
241+
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-2 of 3;/) },
242+
])
243+
}),
244+
{ maxLines: 2, maxBytes: 1_000 },
245+
),
246+
)
247+
87248
it.live("preserves mixed content ordering", () =>
88249
withStore(
89250
(output) =>
@@ -144,7 +305,10 @@ describe("ToolOutput", () => {
144305
const result = yield* output.truncate({ content: [{ type: "text", text: "one\n" }] })
145306
expect(result.content).toEqual([
146307
{ type: "text", text: "one" },
147-
{ type: "text", text: expect.stringMatching(/^\[showing lines 1-1 of 1; full output saved to /) },
308+
{
309+
type: "text",
310+
text: expect.stringMatching(/^\[showing lines 1-1 of 1; full output saved to /),
311+
},
148312
])
149313
}),
150314
{ maxLines: 2, maxBytes: 3 },

0 commit comments

Comments
 (0)