Skip to content

Commit ec00d0b

Browse files
authored
feat(agent): add file content caching and console log forwarding (#1135)
Add file content caching for Read/Write tool results to enable full-file diffs in cloud runs. This wires up console log forwarding to log writer as well.
1 parent 0b21ee4 commit ec00d0b

File tree

3 files changed

+159
-10
lines changed

3 files changed

+159
-10
lines changed

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ function handleToolUseChunk(
192192
const toolInfo = toolInfoFromToolUse(chunk, {
193193
supportsTerminalOutput: ctx.supportsTerminalOutput,
194194
toolUseId: chunk.id,
195+
cachedFileContent: ctx.fileContentCache,
195196
});
196197

197198
const meta: Record<string, unknown> = {
@@ -221,6 +222,66 @@ function handleToolUseChunk(
221222
};
222223
}
223224

225+
function extractTextFromContent(content: unknown): string | null {
226+
if (Array.isArray(content)) {
227+
const parts: string[] = [];
228+
for (const item of content) {
229+
if (
230+
typeof item === "object" &&
231+
item !== null &&
232+
"text" in item &&
233+
typeof (item as Record<string, unknown>).text === "string"
234+
) {
235+
parts.push((item as { text: string }).text);
236+
}
237+
}
238+
return parts.length > 0 ? parts.join("") : null;
239+
}
240+
if (typeof content === "string") {
241+
return content;
242+
}
243+
return null;
244+
}
245+
246+
function stripCatLineNumbers(text: string): string {
247+
return text.replace(/^ *\d+[\t]/gm, "");
248+
}
249+
250+
function updateFileContentCache(
251+
toolUse: { name: string; input: unknown },
252+
chunk: { content?: unknown },
253+
ctx: ChunkHandlerContext,
254+
): void {
255+
const input = toolUse.input as Record<string, unknown> | undefined;
256+
const filePath = input?.file_path ? String(input.file_path) : undefined;
257+
if (!filePath) return;
258+
259+
if (toolUse.name === "Read" && !input?.limit && !input?.offset) {
260+
const fileText = extractTextFromContent(chunk.content);
261+
if (fileText !== null) {
262+
ctx.fileContentCache[filePath] = stripCatLineNumbers(fileText);
263+
}
264+
} else if (toolUse.name === "Write") {
265+
const content = input?.content;
266+
if (typeof content === "string") {
267+
ctx.fileContentCache[filePath] = content;
268+
}
269+
} else if (toolUse.name === "Edit") {
270+
const oldString = input?.old_string;
271+
const newString = input?.new_string;
272+
if (
273+
typeof oldString === "string" &&
274+
typeof newString === "string" &&
275+
filePath in ctx.fileContentCache
276+
) {
277+
const current = ctx.fileContentCache[filePath];
278+
ctx.fileContentCache[filePath] = input?.replace_all
279+
? current.replaceAll(oldString, newString)
280+
: current.replace(oldString, newString);
281+
}
282+
}
283+
}
284+
224285
function handleToolResultChunk(
225286
chunk: AnthropicContentChunk & {
226287
tool_use_id: string;
@@ -241,12 +302,17 @@ function handleToolResultChunk(
241302
return [];
242303
}
243304

305+
if (!chunk.is_error) {
306+
updateFileContentCache(toolUse, chunk, ctx);
307+
}
308+
244309
const { _meta: resultMeta, ...toolUpdate } = toolUpdateFromToolResult(
245310
chunk as Parameters<typeof toolUpdateFromToolResult>[0],
246311
toolUse,
247312
{
248313
supportsTerminalOutput: ctx.supportsTerminalOutput,
249314
toolUseId: chunk.tool_use_id,
315+
cachedFileContent: ctx.fileContentCache,
250316
},
251317
);
252318

packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ type ToolInfo = Pick<ToolCall, "title" | "kind" | "content" | "locations">;
3434

3535
export function toolInfoFromToolUse(
3636
toolUse: Pick<ToolUseBlock, "name" | "input">,
37-
options?: { supportsTerminalOutput?: boolean; toolUseId?: string },
37+
options?: {
38+
supportsTerminalOutput?: boolean;
39+
toolUseId?: string;
40+
cachedFileContent?: Record<string, string>;
41+
},
3842
): ToolInfo {
3943
const name = toolUse.name;
4044
const input = toolUse.input as Record<string, unknown> | undefined;
@@ -144,8 +148,24 @@ export function toolInfoFromToolUse(
144148

145149
case "Edit": {
146150
const path = input?.file_path ? String(input.file_path) : undefined;
147-
const oldText = input?.old_string ? String(input.old_string) : null;
148-
const newText = input?.new_string ? String(input.new_string) : "";
151+
let oldText: string | null = input?.old_string
152+
? String(input.old_string)
153+
: null;
154+
let newText: string = input?.new_string ? String(input.new_string) : "";
155+
156+
// If we have cached file content, show a full-file diff
157+
if (
158+
path &&
159+
options?.cachedFileContent &&
160+
path in options.cachedFileContent
161+
) {
162+
const oldContent = options.cachedFileContent[path];
163+
const newContent = input?.replace_all
164+
? oldContent.replaceAll(oldText ?? "", newText)
165+
: oldContent.replace(oldText ?? "", newText);
166+
oldText = oldContent;
167+
newText = newContent;
168+
}
149169

150170
return {
151171
title: path ? `Edit \`${path}\`` : "Edit",
@@ -170,8 +190,12 @@ export function toolInfoFromToolUse(
170190
const filePath = input?.file_path ? String(input.file_path) : undefined;
171191
const contentStr = input?.content ? String(input.content) : undefined;
172192
if (filePath) {
193+
const oldContent =
194+
options?.cachedFileContent && filePath in options.cachedFileContent
195+
? options.cachedFileContent[filePath]
196+
: null;
173197
contentResult = toolContent()
174-
.diff(filePath, null, contentStr ?? "")
198+
.diff(filePath, oldContent, contentStr ?? "")
175199
.build();
176200
} else if (contentStr) {
177201
contentResult = toolContent().text(contentStr).build();
@@ -453,7 +477,11 @@ export function toolUpdateFromToolResult(
453477
| BetaRequestMCPToolResultBlockParam
454478
| BetaToolSearchToolResultBlockParam,
455479
toolUse: Pick<ToolUseBlock, "name" | "input"> | undefined,
456-
options?: { supportsTerminalOutput?: boolean; toolUseId?: string },
480+
options?: {
481+
supportsTerminalOutput?: boolean;
482+
toolUseId?: string;
483+
cachedFileContent?: Record<string, string>;
484+
},
457485
): Pick<ToolCallUpdate, "title" | "content" | "locations" | "_meta"> {
458486
if (
459487
"is_error" in toolResult &&

packages/agent/src/server/agent-server.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TreeTracker } from "../tree-tracker.js";
1717
import type {
1818
AgentMode,
1919
DeviceInfo,
20+
LogLevel,
2021
TaskRun,
2122
TreeSnapshotEvent,
2223
} from "../types.js";
@@ -155,6 +156,35 @@ export class AgentServer {
155156
private questionRelayedToSlack = false;
156157
private detectedPrUrl: string | null = null;
157158

159+
private emitConsoleLog = (
160+
level: LogLevel,
161+
_scope: string,
162+
message: string,
163+
data?: unknown,
164+
): void => {
165+
if (!this.session) return;
166+
167+
const formatted =
168+
data !== undefined ? `${message} ${JSON.stringify(data)}` : message;
169+
170+
const notification = {
171+
jsonrpc: "2.0",
172+
method: POSTHOG_NOTIFICATIONS.CONSOLE,
173+
params: { level, message: formatted },
174+
};
175+
176+
this.broadcastEvent({
177+
type: "notification",
178+
timestamp: new Date().toISOString(),
179+
notification,
180+
});
181+
182+
this.session.logWriter.appendRawLine(
183+
this.session.payload.run_id,
184+
JSON.stringify(notification),
185+
);
186+
};
187+
158188
constructor(config: AgentServerConfig) {
159189
this.config = config;
160190
this.logger = new Logger({ debug: true, prefix: "[AgentServer]" });
@@ -590,6 +620,17 @@ export class AgentServer {
590620
logWriter,
591621
};
592622

623+
this.logger = new Logger({
624+
debug: true,
625+
prefix: "[AgentServer]",
626+
onLog: (level, scope, message, data) => {
627+
// Preserve console output (onLog suppresses default console.*)
628+
const _formatted =
629+
data !== undefined ? `${message} ${JSON.stringify(data)}` : message;
630+
this.emitConsoleLog(level, scope, message, data);
631+
},
632+
});
633+
593634
this.logger.info("Session initialized successfully");
594635

595636
// Signal in_progress so the UI can start polling for updates
@@ -1103,15 +1144,29 @@ Important:
11031144
...snapshot,
11041145
device: this.session.deviceInfo,
11051146
};
1147+
1148+
const notification = {
1149+
jsonrpc: "2.0" as const,
1150+
method: POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT,
1151+
params: snapshotWithDevice,
1152+
};
1153+
11061154
this.broadcastEvent({
11071155
type: "notification",
11081156
timestamp: new Date().toISOString(),
1109-
notification: {
1110-
jsonrpc: "2.0",
1111-
method: POSTHOG_NOTIFICATIONS.TREE_SNAPSHOT,
1112-
params: snapshotWithDevice,
1113-
},
1157+
notification,
11141158
});
1159+
1160+
// Persist to log writer so cloud runs have tree snapshots
1161+
const { archiveUrl: _, ...paramsWithoutArchive } = snapshotWithDevice;
1162+
const logNotification = {
1163+
...notification,
1164+
params: paramsWithoutArchive,
1165+
};
1166+
this.session.logWriter.appendRawLine(
1167+
this.session.payload.run_id,
1168+
JSON.stringify(logNotification),
1169+
);
11151170
}
11161171
} catch (error) {
11171172
this.logger.error("Failed to capture tree state", error);

0 commit comments

Comments
 (0)