Skip to content

Commit 7f0b580

Browse files
authored
fix(cloud-task): don't show file_unchanged read sentinel as sidebar content (#3065)
1 parent 784e3ad commit 7f0b580

2 files changed

Lines changed: 75 additions & 0 deletions

File tree

packages/core/src/task-detail/cloudToolChanges.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function toolCall(overrides: Partial<ParsedToolCall>): ParsedToolCall {
2222
status: overrides.status ?? "completed",
2323
locations: overrides.locations,
2424
content: overrides.content,
25+
rawOutput: overrides.rawOutput,
2526
};
2627
}
2728

@@ -191,6 +192,59 @@ describe("extractCloudFileContent", () => {
191192
expect(result).toEqual({ content: "edited content", touched: true });
192193
});
193194

195+
// A file_unchanged read carries Claude Code's "Wasted call ..." dedup
196+
// sentinel instead of the file body, so it must never be treated as content.
197+
const fileUnchangedRead = (id: string): ParsedToolCall =>
198+
toolCall({
199+
toolCallId: id,
200+
kind: "read",
201+
locations: [{ path: "src/app.ts" }],
202+
rawOutput: { type: "file_unchanged" },
203+
content: textContent(
204+
"```\nWasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.\n```",
205+
),
206+
});
207+
208+
it.each([
209+
{
210+
name: "read alone yields no content (dedup sentinel not shown)",
211+
calls: [fileUnchangedRead("tc-unchanged")],
212+
expected: { content: null, touched: false },
213+
},
214+
{
215+
name: "read after a real read keeps the real content",
216+
calls: [
217+
toolCall({
218+
toolCallId: "tc-read",
219+
kind: "read",
220+
locations: [{ path: "src/app.ts" }],
221+
content: textContent("real content"),
222+
}),
223+
fileUnchangedRead("tc-unchanged"),
224+
],
225+
expected: { content: "real content", touched: true },
226+
},
227+
{
228+
name: "read after a write keeps the written content",
229+
calls: [
230+
toolCall({
231+
toolCallId: "tc-write",
232+
kind: "write",
233+
locations: [{ path: "src/app.ts" }],
234+
content: diffContent("src/app.ts", "written content"),
235+
}),
236+
fileUnchangedRead("tc-unchanged"),
237+
],
238+
expected: { content: "written content", touched: true },
239+
},
240+
])("file_unchanged $name", ({ calls, expected }) => {
241+
const result = extractCloudFileContent(
242+
makeToolCalls(...calls),
243+
"src/app.ts",
244+
);
245+
expect(result).toEqual(expected);
246+
});
247+
194248
it("marks deleted files as touched with null content", () => {
195249
const calls = makeToolCalls(
196250
toolCall({

packages/core/src/task-detail/cloudToolChanges.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ export interface ParsedToolCall {
4646
status?: string | null;
4747
locations?: ToolCallLocation[];
4848
content?: ToolCallContent[];
49+
rawOutput?: unknown;
50+
}
51+
52+
/**
53+
* A Read whose file is unchanged since the agent's last read returns a
54+
* `file_unchanged` result (Claude Code's "Wasted call — ... Refer to that
55+
* earlier tool_result instead." sentinel) instead of the file body. Its
56+
* `content` is that sentinel message, not the file, so it must not be treated
57+
* as file content.
58+
*/
59+
function isFileUnchangedRead(toolCall: ParsedToolCall): boolean {
60+
const raw = toolCall.rawOutput;
61+
return (
62+
typeof raw === "object" &&
63+
raw !== null &&
64+
(raw as { type?: unknown }).type === "file_unchanged"
65+
);
4966
}
5067

5168
// Match file paths that may differ in format (absolute vs relative)
@@ -87,6 +104,7 @@ function mergeToolCall(
87104
patch.content && patch.content.length > 0
88105
? patch.content
89106
: existing?.content,
107+
rawOutput: patch.rawOutput ?? existing?.rawOutput,
90108
};
91109
}
92110

@@ -204,6 +222,7 @@ export function buildCloudEventSummary(
204222
content: Array.isArray(update.content)
205223
? (update.content as ToolCallContent[])
206224
: undefined,
225+
rawOutput: update.rawOutput,
207226
};
208227

209228
const merged = mergeToolCall(toolCalls.get(toolCallId), patch);
@@ -329,6 +348,8 @@ export function extractCloudFileContent(
329348
const locationPath = toolCall.locations?.[0]?.path;
330349

331350
if (kind === "read" && pathsMatch(locationPath, filePath)) {
351+
// A `file_unchanged` read carries the dedup sentinel, not the file body.
352+
if (isFileUnchangedRead(toolCall)) continue;
332353
const text = getReadToolContent(toolCall.content);
333354
if (text != null) {
334355
latestContent = text;

0 commit comments

Comments
 (0)