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.
- 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/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

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. |
| 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. |
| 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. |
| 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. |
@@ -58,8 +58,9 @@ session with predictable session mapping and basic streaming updates.
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is still intentionally narrow in bridge mode. The
bridge does not yet emit ACP terminals, file locations, or structured diffs.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## 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. |
| 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. |
| 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. |
| 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. |
@@ -52,8 +52,9 @@ updates.
snapshots, not live ACP-native runtime accounting. Usage is approximate,
carries no cost data, and is only emitted when the Gateway marks total token
data as fresh.
- Tool follow-along data is still intentionally narrow in bridge mode. The
bridge does not yet emit ACP terminals, file locations, or structured diffs.
- Tool follow-along data is best-effort. The bridge can surface file paths that
appear in known tool args/results, but it does not yet emit ACP terminals or
structured file diffs.
## 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 = {
type: string;
@@ -6,6 +12,39 @@ export type GatewayAttachment = {
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>> = {
"\0": "\\0",
"\r": "\\r",
@@ -56,6 +95,150 @@ function escapeResourceTitle(value: string): string {
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 {
const parts: string[] = [];
// 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";
}
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;
}
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 {
return {
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", () => {
it("emits a fresh usage snapshot after prompt completion when gateway totals are available", async () => {
const sessionStore = createInMemorySessionStore();

View File

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