Skip to content

Commit fbe7ca9

Browse files
committed
Collapse task dispatch args to a sentence, not raw JSON
A task spawn with a large brief fell through to painting the whole arguments object into the transcript. Prefer description then prompt as the subject, always summarise object args, and expand nested fields with real line breaks instead of escaped JSON strings.
1 parent 264a365 commit fbe7ca9

6 files changed

Lines changed: 187 additions & 37 deletions

File tree

src/tui/diff-rows.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,64 @@ describe("diff transcript rows", () => {
120120
}, WIDE)
121121
})
122122

123+
test("a task/dispatch call paints a sentence, never the full spawn JSON (CL-5762)", async () => {
124+
const brief = {
125+
agent: "explore",
126+
description: "map callers of leaveObserve",
127+
prompt: "Find every call site of leaveObserve.\nReport paths and line numbers.",
128+
intent: "explore",
129+
maxTurns: 40,
130+
success_criteria: ["list call sites", "note tests"],
131+
do_not: ["edit code", "open PRs"],
132+
}
133+
const args = JSON.stringify(brief)
134+
const row = toolCallRow({ name: "task", arguments: args })
135+
136+
// Structural: summary set, not raw args; detail expands with real newlines.
137+
expect(row.summary).toBe("map callers of leaveObserve")
138+
expect(row.verb).toBe("Explore")
139+
expect(row.text).toBe(args) // clipboard still has raw; paint must not use it
140+
expect(row.summary).not.toContain("success_criteria")
141+
expect(row.summary).not.toContain("maxTurns")
142+
// Expanded body uses real line breaks, not literal \\n escape sequences.
143+
const detailPlain = (row.detail ?? [])
144+
.map((line) => line.map((s) => s.text).join(""))
145+
.join("\n")
146+
expect(detailPlain).toContain("Find every call site of leaveObserve.")
147+
expect(detailPlain).toContain("Report paths and line numbers.")
148+
// A pretty-printed JSON dump would keep \\n inside the prompt string.
149+
expect(detailPlain).not.toContain("\\n")
150+
expect(detailPlain).toContain("list call sites")
151+
152+
await withTestRenderer(async (h) => {
153+
const shell = createAppShell(h.renderer, shellOpts)
154+
appendStreamRow(shell, row)
155+
await settle(h)
156+
const frame = h.captureCharFrame()
157+
expect(frame).toContain("map callers of leaveObserve")
158+
expect(frame).not.toContain('"maxTurns"')
159+
expect(frame).not.toContain('"success_criteria"')
160+
expect(frame).not.toContain(args.slice(0, 40))
161+
}, WIDE)
162+
})
163+
164+
test("a task without description still collapses — falls back to prompt, not raw JSON", () => {
165+
const prompt = "Find every call site of leaveObserve and report them."
166+
const args = JSON.stringify({
167+
agent: "explore",
168+
prompt,
169+
intent: "explore",
170+
success_criteria: ["list sites"],
171+
})
172+
const row = toolCallRow({ name: "task", arguments: args })
173+
expect(row.summary).toBeDefined()
174+
expect(row.summary!.length).toBeGreaterThan(0)
175+
expect(row.summary).not.toContain("success_criteria")
176+
expect(row.summary).not.toContain('"intent"')
177+
// Paint layer must not fall through to raw text.
178+
expect(row.summary).not.toBe(args)
179+
})
180+
123181
test("a write_file call paints the whole body as additions", async () => {
124182
await withTestRenderer(async (h) => {
125183
const shell = createAppShell(h.renderer, shellOpts)

src/tui/diff.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,15 @@ export function toolCallRow(input: ToolCallRowInput): StreamRow {
488488
// Identity of the sentence this call paints, not of its arguments: two calls
489489
// that read the same line are what a repeat looks like to the operator.
490490
const callKey = `${input.name} ${verb ?? ""} ${summary ?? ""}`
491+
// Never leave `summary` unset when we have a verb or a summarised view —
492+
// `undefined` makes the paint layer fall through to raw argument JSON
493+
// (CL-5762). An empty string is fine: the verb alone names the call.
494+
const paintSummary =
495+
summary !== undefined
496+
? summary
497+
: call !== null || summarised !== null
498+
? ""
499+
: undefined
491500
return {
492501
role: "tool",
493502
text,
@@ -497,12 +506,7 @@ export function toolCallRow(input: ToolCallRowInput): StreamRow {
497506
...(input.callId !== undefined ? { callId: input.callId } : {}),
498507
...(diff !== null ? { diff } : {}),
499508
...(verb !== undefined ? { verb } : {}),
500-
// A summarised call may deliberately have no subject — its verb already
501-
// names the whole call — and that blank must survive, or the row falls
502-
// back to painting the raw arguments.
503-
...(summary !== undefined && (summary.length > 0 || summarised !== null)
504-
? { summary }
505-
: {}),
509+
...(paintSummary !== undefined ? { summary: paintSummary } : {}),
506510
...(stat !== undefined ? { stat } : {}),
507511
...(detail !== undefined && detail.length > 0 ? { detail } : {}),
508512
}

src/tui/runner-host.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ describe("rowFromTranscriptEntry", () => {
7474
text: "{}",
7575
meta: "grep",
7676
verb: "Grep",
77+
// Empty summary is intentional: without it the paint layer falls through
78+
// to raw argument JSON (CL-5762). Verb alone names the call.
79+
summary: "",
7780
pending: true,
7881
callKey: "grep Grep ",
7982
callId: "c",

src/tui/tool-args.ts

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -124,29 +124,73 @@ function isScalar(value: unknown): boolean {
124124
}
125125

126126
/**
127-
* Scalar arguments as `key value` pairs with their newlines intact — a shell
128-
* command or a prompt is written to be read as text, and pretty-printed JSON
129-
* would hand it back with its line breaks escaped.
127+
* Scalar (or scalar-array) arguments as `key value` pairs with their newlines
128+
* intact — a shell command or a spawn prompt is written to be read as text, and
129+
* pretty-printed JSON would hand it back with its line breaks escaped (CL-5762).
130+
*
131+
* Nested objects recurse one level so a task brief expands as fields rather than
132+
* a JSON dump; deeper nesting collapses to a compact token.
130133
*/
131-
function scalarDetail(args: Record<string, unknown>): readonly StyledBodyLine[] | null {
132-
const entries = Object.entries(args)
133-
if (entries.length === 0 || !entries.every(([, value]) => isScalar(value))) {
134-
return null
135-
}
134+
function fieldDetail(
135+
args: Record<string, unknown>,
136+
indent = 0,
137+
): readonly StyledBodyLine[] {
138+
const pad = " ".repeat(indent)
136139
const lines: StyledBodyLine[] = []
137-
for (const [key, value] of entries) {
138-
const text = typeof value === "string" ? value : JSON.stringify(value)
139-
const rows = (text ?? "null").split("\n")
140-
rows.forEach((row, i) => {
141-
lines.push(
142-
i === 0
143-
? [
144-
{ text: `${key}: `, fg: UI.textDim },
145-
{ text: row, fg: UI.text },
146-
]
147-
: [{ text: `${" ".repeat(key.length + 2)}${row}`, fg: UI.text }],
148-
)
149-
})
140+
for (const [key, value] of Object.entries(args)) {
141+
if (isScalar(value)) {
142+
const text = typeof value === "string" ? value : JSON.stringify(value)
143+
const rows = (text ?? "null").split("\n")
144+
rows.forEach((row, i) => {
145+
lines.push(
146+
i === 0
147+
? [
148+
{ text: `${pad}${key}: `, fg: UI.textDim },
149+
{ text: row, fg: UI.text },
150+
]
151+
: [{ text: `${pad}${" ".repeat(key.length + 2)}${row}`, fg: UI.text }],
152+
)
153+
})
154+
continue
155+
}
156+
if (Array.isArray(value) && value.every(isScalar)) {
157+
if (value.length === 0) {
158+
lines.push([
159+
{ text: `${pad}${key}: `, fg: UI.textDim },
160+
{ text: "[]", fg: UI.text },
161+
])
162+
continue
163+
}
164+
lines.push([{ text: `${pad}${key}:`, fg: UI.textDim }])
165+
for (const item of value) {
166+
const text = typeof item === "string" ? item : JSON.stringify(item)
167+
for (const row of text.split("\n")) {
168+
lines.push([{ text: `${pad} - ${row}`, fg: UI.text }])
169+
}
170+
}
171+
continue
172+
}
173+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
174+
// One level of nesting is enough for a spawn brief; deeper stays compact.
175+
if (indent === 0) {
176+
lines.push([{ text: `${pad}${key}:`, fg: UI.textDim }])
177+
lines.push(...fieldDetail(value as Record<string, unknown>, indent + 2))
178+
} else {
179+
lines.push([
180+
{ text: `${pad}${key}: `, fg: UI.textDim },
181+
{ text: "{…}", fg: UI.text },
182+
])
183+
}
184+
continue
185+
}
186+
// Arrays of objects, etc. — compact rather than a wall of JSON.
187+
lines.push([
188+
{ text: `${pad}${key}: `, fg: UI.textDim },
189+
{
190+
text: Array.isArray(value) ? `[${value.length} items]` : "{…}",
191+
fg: UI.text,
192+
},
193+
])
150194
}
151195
return lines.slice(0, MAX_DETAIL_LINES)
152196
}
@@ -221,7 +265,9 @@ function subjectFor(
221265
args: Record<string, unknown>,
222266
): string {
223267
const { summary } = summarizeToolArgs(name, raw)
224-
if (!isArgumentList(args, summary)) return summary
268+
// An empty formatter summary is not a subject — fall through to primarySubject
269+
// so a task without description still paints its prompt rather than raw JSON.
270+
if (summary.length > 0 && !isArgumentList(args, summary)) return summary
225271
return primarySubject(args) ?? summary
226272
}
227273

@@ -246,7 +292,7 @@ export function toolArgsView(name: string, rawArgs: string): ToolArgsView | null
246292
// Its arguments are a query, not a subject: nobody reads a transcript for
247293
// the pagination cursor, so they belong behind the expand key or nowhere.
248294
if (args !== null && isMcpToolName(name)) {
249-
return withDetail("", scalarDetail(args) ?? jsonDetail(args))
295+
return withDetail("", fieldDetail(args))
250296
}
251297

252298
if (args === null && raw.length <= INLINE_MAX && !raw.includes("\n")) return null
@@ -256,8 +302,10 @@ export function toolArgsView(name: string, rawArgs: string): ToolArgsView | null
256302
return summary.length === 0 ? null : withDetail(summary, jsonDetail(raw))
257303
}
258304
const subject = subjectFor(name, raw, args)
259-
if (subject.length === 0) return null
260-
return withDetail(subject, scalarDetail(args) ?? jsonDetail(args))
305+
// Object args always get a summarised view — even with an empty subject the
306+
// verb alone names the call and the body expands with real line breaks. A
307+
// null return here is what used to dump raw argument JSON into the transcript.
308+
return withDetail(subject, fieldDetail(args))
261309
}
262310

263311
/**

src/tui/tool-formatter.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,18 @@ describe("describeToolCall for task tool", () => {
285285
expect(result.summary).toBe("map all callers");
286286
});
287287

288+
test("task without description falls back to the prompt subject", () => {
289+
const prompt = "Find every call site of leaveObserve and report them.";
290+
const args = JSON.stringify({ agent: "explore", prompt, intent: "explore" });
291+
const result = describeToolCall("task", args);
292+
expect(result.display).toBe("Explore");
293+
// ARG_VALUE_MAX = 48 with ellipsis when truncated
294+
expect(result.summary.length).toBeLessThanOrEqual(48);
295+
expect(result.full).toBe(prompt);
296+
expect(result.summary.startsWith("Find every call site")).toBe(true);
297+
expect(result.summary).not.toContain("intent");
298+
});
299+
288300
test("long description is abbreviated", () => {
289301
const long = "a".repeat(100);
290302
const args = JSON.stringify({ agent: "critique", description: long, prompt: "..." });
@@ -333,6 +345,19 @@ describe("task activity transcript lines", () => {
333345
expect(s.full).toBe("map callers of leaveObserve");
334346
});
335347

348+
test("summarizeToolArgs falls back to prompt when description is missing", () => {
349+
const prompt = "Find every call site of leaveObserve and report them with paths.";
350+
const s = summarizeToolArgs(
351+
"task",
352+
JSON.stringify({ agent: "explore", prompt, intent: "explore", maxTurns: 40 }),
353+
);
354+
expect(s.summary.length).toBeLessThanOrEqual(48);
355+
expect(s.full).toBe(prompt);
356+
expect(s.summary.startsWith("Find every call site")).toBe(true);
357+
expect(s.summary).not.toContain("maxTurns");
358+
expect(s.summary).not.toContain("intent");
359+
});
360+
336361
test("describeToolCall full keeps the untrimmed description for Ctrl+O", () => {
337362
const long = "a".repeat(80);
338363
const d = describeToolCall(

src/tui/tool-formatter.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,20 @@ export function describeToolCall(toolName: string, rawArgs: string): ToolCallDes
132132
if (!(taskParsed instanceof type.errors)) {
133133
const agentName = taskParsed.agent?.trim();
134134
const description = (taskParsed.description ?? "").trim();
135+
// description is optional on spawn; the brief's prompt is the next best
136+
// subject so the row never falls through to raw argument JSON.
137+
const prompt = (taskParsed.prompt ?? "").trim();
138+
const subject = description.length > 0 ? description : prompt;
135139
const display =
136140
agentName !== undefined && agentName.length > 0
137141
? agentName[0]!.toUpperCase() + agentName.slice(1)
138142
: "Task";
139-
// Collapsed row uses the abbreviated description; Alt+E expands to the full text.
143+
// Collapsed row uses the abbreviated subject; Alt+E expands to the full text.
140144
return {
141145
display,
142146
role: "accent",
143-
summary: description.length > 0 ? abbreviate(description, ARG_VALUE_MAX) : "",
144-
full: description,
147+
summary: subject.length > 0 ? abbreviate(subject, ARG_VALUE_MAX) : "",
148+
full: subject,
145149
isShell: false,
146150
};
147151
}
@@ -183,7 +187,11 @@ const SearchFilesArgSchema = type({ pattern: "string", "path?": "string" });
183187
const WebSearchArgSchema = type({ query: "string" });
184188
const WebFetchArgSchema = type({ url: "string" });
185189
const ShellArgSchema = type({ command: "string" });
186-
const TaskArgSchema = type({ "agent?": "string", "description?": "string" });
190+
const TaskArgSchema = type({
191+
"agent?": "string",
192+
"description?": "string",
193+
"prompt?": "string",
194+
});
187195
const WebSearchResultSchema = type({ results: "unknown[]" });
188196
const WebFetchResultSchema = type({ content: "string" });
189197

@@ -234,14 +242,18 @@ export function summarizeToolArgs(toolName: string, rawArgs: string): ToolArgSum
234242
}
235243
case "task": {
236244
// Spawns carry a large structured brief (prompt, intent, criteria). The
237-
// transcript only needs the short description; Alt+E still shows the
238-
// full description text, not every spawn field.
245+
// transcript only needs a short subject — prefer description, then prompt —
246+
// so the row never dumps the whole JSON payload.
239247
const parsed = TaskArgSchema(obj);
240248
if (!(parsed instanceof type.errors)) {
241249
const desc = (parsed.description ?? "").trim();
242250
if (desc.length > 0) {
243251
return { summary: abbreviate(desc, ARG_VALUE_MAX), full: desc };
244252
}
253+
const prompt = (parsed.prompt ?? "").trim();
254+
if (prompt.length > 0) {
255+
return { summary: abbreviate(prompt, ARG_VALUE_MAX), full: prompt };
256+
}
245257
}
246258
return { summary: "", full: "" };
247259
}

0 commit comments

Comments
 (0)