acp: enrich streaming updates for ide clients (#41442)

Merged via squash.

Prepared head SHA: 0764368e80
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-03-09 22:26:46 +01:00
committed by GitHub
parent 30340d6835
commit 8e3f3bc3cf
6 changed files with 449 additions and 11 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky. - Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky. - ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky. - ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
## 2026.3.8 ## 2026.3.8

View File

@@ -33,7 +33,7 @@ session with predictable session mapping and basic streaming updates.
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | | Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
@@ -58,8 +58,9 @@ session with predictable session mapping and basic streaming updates.
snapshots, not live ACP-native runtime accounting. Usage is approximate, snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token carries no cost data, and is only emitted when the Gateway marks total token
data as fresh. data as fresh.
- Tool follow-along data is still intentionally narrow in bridge mode. The - Tool follow-along data is best-effort. The bridge can surface file paths that
bridge does not yet emit ACP terminals, file locations, or structured diffs. appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## How can I use this ## How can I use this

View File

@@ -27,7 +27,7 @@ updates.
| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | | Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. |
| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | | Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. |
| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | | Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. |
| Tool streaming | Partial | Tool start and result updates are forwarded, but without richer editor metadata such as file locations or structured diff-native output. | | Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. |
| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | | Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. |
| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | | Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. |
| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | | Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. |
@@ -52,8 +52,9 @@ updates.
snapshots, not live ACP-native runtime accounting. Usage is approximate, snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token carries no cost data, and is only emitted when the Gateway marks total token
data as fresh. data as fresh.
- Tool follow-along data is still intentionally narrow in bridge mode. The - Tool follow-along data is best-effort. The bridge can surface file paths that
bridge does not yet emit ACP terminals, file locations, or structured diffs. appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## Usage ## Usage

View File

@@ -1,4 +1,10 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk"; import type {
ContentBlock,
ImageContent,
ToolCallContent,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk";
export type GatewayAttachment = { export type GatewayAttachment = {
type: string; type: string;
@@ -6,6 +12,39 @@ export type GatewayAttachment = {
content: string; content: string;
}; };
const TOOL_LOCATION_PATH_KEYS = [
"path",
"filePath",
"file_path",
"targetPath",
"target_path",
"targetFile",
"target_file",
"sourcePath",
"source_path",
"destinationPath",
"destination_path",
"oldPath",
"old_path",
"newPath",
"new_path",
"outputPath",
"output_path",
"inputPath",
"input_path",
] as const;
const TOOL_LOCATION_LINE_KEYS = [
"line",
"lineNumber",
"line_number",
"startLine",
"start_line",
] as const;
const TOOL_RESULT_PATH_MARKER_RE = /^(?:FILE|MEDIA):(.+)$/gm;
const TOOL_LOCATION_MAX_DEPTH = 4;
const TOOL_LOCATION_MAX_NODES = 100;
const INLINE_CONTROL_ESCAPE_MAP: Readonly<Record<string, string>> = { const INLINE_CONTROL_ESCAPE_MAP: Readonly<Record<string, string>> = {
"\0": "\\0", "\0": "\\0",
"\r": "\\r", "\r": "\\r",
@@ -56,6 +95,150 @@ function escapeResourceTitle(value: string): string {
return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`); return escapeInlineControlChars(value).replace(/[()[\]]/g, (char) => `\\${char}`);
} }
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeToolLocationPath(value: string): string | undefined {
const trimmed = value.trim();
if (
!trimmed ||
trimmed.length > 4096 ||
trimmed.includes("\u0000") ||
trimmed.includes("\r") ||
trimmed.includes("\n")
) {
return undefined;
}
if (/^https?:\/\//i.test(trimmed)) {
return undefined;
}
if (/^file:\/\//i.test(trimmed)) {
try {
const parsed = new URL(trimmed);
return decodeURIComponent(parsed.pathname || "") || undefined;
} catch {
return undefined;
}
}
return trimmed;
}
function normalizeToolLocationLine(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
const line = Math.floor(value);
return line > 0 ? line : undefined;
}
function extractToolLocationLine(record: Record<string, unknown>): number | undefined {
for (const key of TOOL_LOCATION_LINE_KEYS) {
const line = normalizeToolLocationLine(record[key]);
if (line !== undefined) {
return line;
}
}
return undefined;
}
function addToolLocation(
locations: Map<string, ToolCallLocation>,
rawPath: string,
line?: number,
): void {
const path = normalizeToolLocationPath(rawPath);
if (!path) {
return;
}
for (const [existingKey, existing] of locations.entries()) {
if (existing.path !== path) {
continue;
}
if (line === undefined || existing.line === line) {
return;
}
if (existing.line === undefined) {
locations.delete(existingKey);
}
}
const locationKey = `${path}:${line ?? ""}`;
if (locations.has(locationKey)) {
return;
}
locations.set(locationKey, line ? { path, line } : { path });
}
function collectLocationsFromTextMarkers(
text: string,
locations: Map<string, ToolCallLocation>,
): void {
for (const match of text.matchAll(TOOL_RESULT_PATH_MARKER_RE)) {
const candidate = match[1]?.trim();
if (candidate) {
addToolLocation(locations, candidate);
}
}
}
function collectToolLocations(
value: unknown,
locations: Map<string, ToolCallLocation>,
state: { visited: number; depth: number },
): void {
if (state.visited >= TOOL_LOCATION_MAX_NODES || state.depth > TOOL_LOCATION_MAX_DEPTH) {
return;
}
state.visited += 1;
if (typeof value === "string") {
collectLocationsFromTextMarkers(value, locations);
return;
}
if (!value || typeof value !== "object") {
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectToolLocations(item, locations, { visited: state.visited, depth: state.depth + 1 });
state.visited += 1;
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
return;
}
}
return;
}
const record = value as Record<string, unknown>;
const line = extractToolLocationLine(record);
for (const key of TOOL_LOCATION_PATH_KEYS) {
const rawPath = record[key];
if (typeof rawPath === "string") {
addToolLocation(locations, rawPath, line);
}
}
const content = Array.isArray(record.content) ? record.content : undefined;
if (content) {
for (const block of content) {
const entry = asRecord(block);
if (entry?.type === "text" && typeof entry.text === "string") {
collectLocationsFromTextMarkers(entry.text, locations);
}
}
}
for (const nested of Object.values(record)) {
collectToolLocations(nested, locations, { visited: state.visited, depth: state.depth + 1 });
state.visited += 1;
if (state.visited >= TOOL_LOCATION_MAX_NODES) {
return;
}
}
}
export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string { export function extractTextFromPrompt(prompt: ContentBlock[], maxBytes?: number): string {
const parts: string[] = []; const parts: string[] = [];
// Track accumulated byte count per block to catch oversized prompts before full concatenation // Track accumulated byte count per block to catch oversized prompts before full concatenation
@@ -152,3 +335,74 @@ export function inferToolKind(name?: string): ToolKind {
} }
return "other"; return "other";
} }
export function extractToolCallContent(value: unknown): ToolCallContent[] | undefined {
if (typeof value === "string") {
return value.trim()
? [
{
type: "content",
content: {
type: "text",
text: value,
},
},
]
: undefined;
}
const record = asRecord(value);
if (!record) {
return undefined;
}
const contents: ToolCallContent[] = [];
const blocks = Array.isArray(record.content) ? record.content : [];
for (const block of blocks) {
const entry = asRecord(block);
if (entry?.type === "text" && typeof entry.text === "string" && entry.text.trim()) {
contents.push({
type: "content",
content: {
type: "text",
text: entry.text,
},
});
}
}
if (contents.length > 0) {
return contents;
}
const fallbackText =
typeof record.text === "string"
? record.text
: typeof record.message === "string"
? record.message
: typeof record.error === "string"
? record.error
: undefined;
if (!fallbackText?.trim()) {
return undefined;
}
return [
{
type: "content",
content: {
type: "text",
text: fallbackText,
},
},
];
}
export function extractToolCallLocations(...values: unknown[]): ToolCallLocation[] | undefined {
const locations = new Map<string, ToolCallLocation>();
for (const value of values) {
collectToolLocations(value, locations, { visited: 0, depth: 0 });
}
return locations.size > 0 ? [...locations.values()] : undefined;
}

View File

@@ -62,6 +62,34 @@ function createSetSessionConfigOptionRequest(
} as unknown as SetSessionConfigOptionRequest; } as unknown as SetSessionConfigOptionRequest;
} }
function createToolEvent(params: {
sessionKey: string;
phase: "start" | "update" | "result";
toolCallId: string;
name: string;
args?: Record<string, unknown>;
partialResult?: unknown;
result?: unknown;
isError?: boolean;
}): EventFrame {
return {
event: "agent",
payload: {
sessionKey: params.sessionKey,
stream: "tool",
data: {
phase: params.phase,
toolCallId: params.toolCallId,
name: params.name,
args: params.args,
partialResult: params.partialResult,
result: params.result,
isError: params.isError,
},
},
} as unknown as EventFrame;
}
function createChatFinalEvent(sessionKey: string): EventFrame { function createChatFinalEvent(sessionKey: string): EventFrame {
return { return {
event: "chat", event: "chat",
@@ -561,6 +589,117 @@ describe("acp setSessionConfigOption bridge behavior", () => {
}); });
}); });
describe("acp tool streaming bridge behavior", () => {
it("maps Gateway tool partial output and file locations into ACP tool updates", async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const sessionUpdate = connection.__sessionUpdateMock;
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return new Promise(() => {});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("tool-session"));
sessionUpdate.mockClear();
const promptPromise = agent.prompt(createPromptRequest("tool-session", "Inspect app.ts"));
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "start",
toolCallId: "tool-1",
name: "read",
args: { path: "src/app.ts", line: 12 },
}),
);
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "update",
toolCallId: "tool-1",
name: "read",
partialResult: {
content: [{ type: "text", text: "partial output" }],
details: { path: "src/app.ts" },
},
}),
);
await agent.handleGatewayEvent(
createToolEvent({
sessionKey: "tool-session",
phase: "result",
toolCallId: "tool-1",
name: "read",
result: {
content: [{ type: "text", text: "FILE:src/app.ts" }],
details: { path: "src/app.ts" },
},
}),
);
await agent.handleGatewayEvent(createChatFinalEvent("tool-session"));
await promptPromise;
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call",
toolCallId: "tool-1",
title: "read: path: src/app.ts, line: 12",
status: "in_progress",
rawInput: { path: "src/app.ts", line: 12 },
kind: "read",
locations: [{ path: "src/app.ts", line: 12 }],
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "in_progress",
rawOutput: {
content: [{ type: "text", text: "partial output" }],
details: { path: "src/app.ts" },
},
content: [
{
type: "content",
content: { type: "text", text: "partial output" },
},
],
locations: [{ path: "src/app.ts", line: 12 }],
},
});
expect(sessionUpdate).toHaveBeenCalledWith({
sessionId: "tool-session",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "tool-1",
status: "completed",
rawOutput: {
content: [{ type: "text", text: "FILE:src/app.ts" }],
details: { path: "src/app.ts" },
},
content: [
{
type: "content",
content: { type: "text", text: "FILE:src/app.ts" },
},
],
locations: [{ path: "src/app.ts", line: 12 }],
},
});
sessionStore.clearAllSessionsForTest();
});
});
describe("acp session metadata and usage updates", () => { describe("acp session metadata and usage updates", () => {
it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => { it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => {
const sessionStore = createInMemorySessionStore(); const sessionStore = createInMemorySessionStore();

View File

@@ -23,6 +23,8 @@ import type {
SetSessionModeRequest, SetSessionModeRequest,
SetSessionModeResponse, SetSessionModeResponse,
StopReason, StopReason,
ToolCallLocation,
ToolKind,
} from "@agentclientprotocol/sdk"; } from "@agentclientprotocol/sdk";
import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
import { listThinkingLevels } from "../auto-reply/thinking.js"; import { listThinkingLevels } from "../auto-reply/thinking.js";
@@ -37,8 +39,11 @@ import { shortenHomePath } from "../utils.js";
import { getAvailableCommands } from "./commands.js"; import { getAvailableCommands } from "./commands.js";
import { import {
extractAttachmentsFromPrompt, extractAttachmentsFromPrompt,
extractToolCallContent,
extractToolCallLocations,
extractTextFromPrompt, extractTextFromPrompt,
formatToolTitle, formatToolTitle,
inferToolKind,
} from "./event-mapper.js"; } from "./event-mapper.js";
import { readBool, readNumber, readString } from "./meta.js"; import { readBool, readNumber, readString } from "./meta.js";
import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js"; import { parseSessionMeta, resetSessionIfNeeded, resolveSessionKey } from "./session-mapper.js";
@@ -62,7 +67,14 @@ type PendingPrompt = {
reject: (err: Error) => void; reject: (err: Error) => void;
sentTextLength?: number; sentTextLength?: number;
sentText?: string; sentText?: string;
toolCalls?: Set<string>; toolCalls?: Map<string, PendingToolCall>;
};
type PendingToolCall = {
kind: ToolKind;
locations?: ToolCallLocation[];
rawInput?: Record<string, unknown>;
title: string;
}; };
type AcpGatewayAgentOptions = AcpServerOptions & { type AcpGatewayAgentOptions = AcpServerOptions & {
@@ -681,21 +693,48 @@ export class AcpGatewayAgent implements Agent {
if (phase === "start") { if (phase === "start") {
if (!pending.toolCalls) { if (!pending.toolCalls) {
pending.toolCalls = new Set(); pending.toolCalls = new Map();
} }
if (pending.toolCalls.has(toolCallId)) { if (pending.toolCalls.has(toolCallId)) {
return; return;
} }
pending.toolCalls.add(toolCallId);
const args = data.args as Record<string, unknown> | undefined; const args = data.args as Record<string, unknown> | undefined;
const title = formatToolTitle(name, args);
const kind = inferToolKind(name);
const locations = extractToolCallLocations(args);
pending.toolCalls.set(toolCallId, {
title,
kind,
rawInput: args,
locations,
});
await this.connection.sessionUpdate({ await this.connection.sessionUpdate({
sessionId: pending.sessionId, sessionId: pending.sessionId,
update: { update: {
sessionUpdate: "tool_call", sessionUpdate: "tool_call",
toolCallId, toolCallId,
title: formatToolTitle(name, args), title,
status: "in_progress", status: "in_progress",
rawInput: args, rawInput: args,
kind,
locations,
},
});
return;
}
if (phase === "update") {
const toolState = pending.toolCalls?.get(toolCallId);
const partialResult = data.partialResult;
await this.connection.sessionUpdate({
sessionId: pending.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "in_progress",
rawOutput: partialResult,
content: extractToolCallContent(partialResult),
locations: extractToolCallLocations(toolState?.locations, partialResult),
}, },
}); });
return; return;
@@ -703,6 +742,7 @@ export class AcpGatewayAgent implements Agent {
if (phase === "result") { if (phase === "result") {
const isError = Boolean(data.isError); const isError = Boolean(data.isError);
const toolState = pending.toolCalls?.get(toolCallId);
pending.toolCalls?.delete(toolCallId); pending.toolCalls?.delete(toolCallId);
await this.connection.sessionUpdate({ await this.connection.sessionUpdate({
sessionId: pending.sessionId, sessionId: pending.sessionId,
@@ -711,6 +751,8 @@ export class AcpGatewayAgent implements Agent {
toolCallId, toolCallId,
status: isError ? "failed" : "completed", status: isError ? "failed" : "completed",
rawOutput: data.result, rawOutput: data.result,
content: extractToolCallContent(data.result),
locations: extractToolCallLocations(toolState?.locations, data.result),
}, },
}); });
} }