Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ fx builds as a native binary or WebAssembly. Applications embedding fx can provi

| Surface | Use |
| --- | --- |
| `fx acp` | Connect the native agent to editors and other Agent Client Protocol clients. |
| `fx acp` | Connect the native agent to Agent Client Protocol clients with streamed messages, tool calls, and file diffs. |
| `createFxAgent()` | Embed the agent core in a JavaScript host with `fx-core.wasm`. |
| `createFxTerminal()` | Embed the interactive terminal with `fx-term.wasm`. |

Expand Down
47 changes: 43 additions & 4 deletions src/acp/prompt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,26 @@ const AcpContext = struct {
try self.sendUpdate(out.writer.buffered());
}

fn sendToolCallDiff(
self: *AcpContext,
tool_call_id: []const u8,
path: []const u8,
old_text: ?[]const u8,
new_text: []const u8,
) !void {
var out: std.Io.Writer.Allocating = .init(self.alloc);
defer out.deinit();
try acp_types.writeToolCallDiffUpdate(
&out.writer,
tool_call_id,
.in_progress,
path,
old_text,
new_text,
);
try self.sendUpdate(out.writer.buffered());
}

fn sendToolCallError(self: *AcpContext, tool_call_id: []const u8, err_text: []const u8) !void {
try self.sendToolCallErrorWithCommandResult(tool_call_id, err_text, null);
}
Expand Down Expand Up @@ -1553,10 +1573,27 @@ fn completeToolCallTransport(
}

fn publishCommittedFileHandoff(
_: *anyopaque,
_: file_mutation.CommittedFileHandoff,
raw_ctx: *anyopaque,
handoff: file_mutation.CommittedFileHandoff,
) agent_runtime.SecondaryPublicationReport {
return .{ .diff = .skipped, .tracker = .skipped };
const full_view = handoff.full_view orelse {
return .{ .diff = .failed, .tracker = .skipped };
};
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
ctx.sendToolCallDiff(
full_view.lifecycle_id.call_id,
handoff.tracker.raw_path,
handoff.tracker.previous_content,
full_view.after_content,
) catch |err| {
debug_trace.logf(
"acp",
"committed file diff publication failed call_id={s} err={s}",
.{ full_view.lifecycle_id.call_id, @errorName(err) },
);
return .{ .diff = .failed, .tracker = .skipped };
};
return .{ .diff = .published, .tracker = .skipped };
}

fn publishDeferredToolCompletion(
Expand All @@ -1566,7 +1603,9 @@ fn publishDeferredToolCompletion(
const ctx: *AcpContext = @ptrCast(@alignCast(raw_ctx));
ctx.sendToolCallCompletedWithCommandResult(
completion.transport_id,
completion.content_text,
// ACP content updates replace the collection. Omitting content keeps
// the authoritative diff published by the committed-file handoff.
null,
completion.command_result_json,
) catch |err| {
debug_trace.logf(
Expand Down
50 changes: 50 additions & 0 deletions src/acp/types.zig
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,31 @@ pub fn writeToolCallUpdate(w: *std.Io.Writer, tool_call_id: []const u8, status:
try writeToolCallUpdateWithCommandResult(w, tool_call_id, status, content_text, null);
}

pub fn writeToolCallDiffUpdate(
w: *std.Io.Writer,
tool_call_id: []const u8,
status: ToolCallStatus,
path: []const u8,
old_text: ?[]const u8,
new_text: []const u8,
) !void {
try w.writeAll("{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":");
try writeJsonStr(tool_call_id, w);
try w.writeAll(",\"status\":");
try writeJsonStr(status.jsonString(), w);
try w.writeAll(",\"content\":[{\"type\":\"diff\",\"path\":");
try writeJsonStr(path, w);
try w.writeAll(",\"oldText\":");
if (old_text) |text| {
try writeJsonStr(text, w);
} else {
try w.writeAll("null");
}
try w.writeAll(",\"newText\":");
try writeJsonStr(new_text, w);
try w.writeAll("}]}");
}

pub fn writeToolCallUpdateWithCommandResult(
w: *std.Io.Writer,
tool_call_id: []const u8,
Expand Down Expand Up @@ -260,6 +285,31 @@ test "writeToolCallUpdate with content" {
try std.testing.expect(std.mem.find(u8, out.writer.buffered(), "File written successfully") != null);
}

test "writeToolCallDiffUpdate preserves the authoritative file change" {
const alloc = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try writeToolCallDiffUpdate(
&out.writer,
"call_diff",
.in_progress,
"/workspace/src/main.zig",
"const before = true;\n",
"const after = true;\n",
);
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, out.writer.buffered(), .{});
defer parsed.deinit();

try std.testing.expectEqualStrings("tool_call_update", parsed.value.object.get("sessionUpdate").?.string);
try std.testing.expectEqualStrings("call_diff", parsed.value.object.get("toolCallId").?.string);
try std.testing.expectEqualStrings("in_progress", parsed.value.object.get("status").?.string);
const diff = parsed.value.object.get("content").?.array.items[0].object;
try std.testing.expectEqualStrings("diff", diff.get("type").?.string);
try std.testing.expectEqualStrings("/workspace/src/main.zig", diff.get("path").?.string);
try std.testing.expectEqualStrings("const before = true;\n", diff.get("oldText").?.string);
try std.testing.expectEqualStrings("const after = true;\n", diff.get("newText").?.string);
}

test "writeToolCallUpdate can include structured command result" {
const alloc = std.testing.allocator;
var out: std.Io.Writer.Allocating = .init(alloc);
Expand Down
59 changes: 59 additions & 0 deletions tests/e2e/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5048,6 +5048,65 @@ describe("acp: model-independent", () => {
TIMEOUT,
);

test(
"ACP publishes committed file changes as authoritative diffs",
async () => {
const root = createIsolatedRoot("fx-acp-file-diff-");
const target = join(root.workspace, "example.txt");
writeFileSync(target, "before\n");
const gateway = startFakeGateway([
fileToolCall("write_file_diff", target, "before\nafter\n"),
finalText("ACP file diff complete"),
]);
try {
client = await AcpClient.create({
cwd: root.workspace,
env: fakeGatewayEnv(root, gateway),
});
await startCodeSession(client);
const prompt = await runPrompt(
client,
`Append one line to ${target} with write_file.`,
TIMEOUT,
);
const updates = prompt.messages
.filter((message: any) =>
message.method === "session/update" &&
message.params?.update?.sessionUpdate === "tool_call_update" &&
message.params.update.toolCallId === "write_file_diff"
)
.map((message: any) => message.params.update);
const diffIndex = updates.findIndex((update: any) =>
update.content?.[0]?.type === "diff"
);
const completedIndex = updates.findIndex((update: any) =>
update.status === "completed"
);

expect(diffIndex).toBeGreaterThanOrEqual(0);
expect(completedIndex).toBeGreaterThan(diffIndex);
expect(updates[diffIndex]).toMatchObject({
status: "in_progress",
content: [{
type: "diff",
path: target,
oldText: "before\n",
newText: "before\nafter\n",
}],
});
expect(updates[completedIndex]!.content).toBeUndefined();
expect(readFileSync(target, "utf-8")).toBe("before\nafter\n");
expect(prompt.promptResult.result.stopReason).toBe("end_turn");
expect(client.stderr).toBe("");
} finally {
await client?.close();
gateway.stop();
rmSync(root.root, { recursive: true, force: true });
}
},
TIMEOUT,
);

test(
"ACP automatic ask returns to the agent before requesting permission",
async () => {
Expand Down