mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user