ACP: carry dedupe/projector updates onto configurable acpx branch

This commit is contained in:
Onur
2026-03-01 09:19:11 +01:00
committed by Onur Solmaz
parent f88bc09f85
commit 2466a9bb13
14 changed files with 1076 additions and 171 deletions

View File

@@ -44,6 +44,7 @@ describe("PromptStreamProjector", () => {
type: "text_delta",
text: "hello world",
stream: "output",
tag: "agent_message_chunk",
});
});
@@ -71,6 +72,7 @@ describe("PromptStreamProjector", () => {
type: "text_delta",
text: " indented",
stream: "output",
tag: "agent_message_chunk",
});
});
@@ -98,10 +100,11 @@ describe("PromptStreamProjector", () => {
type: "text_delta",
text: "thinking",
stream: "thought",
tag: "agent_thought_chunk",
});
});
it("maps tool call updates to tool_call events", () => {
it("maps tool call updates with metadata and stable fallback title", () => {
const projector = new PromptStreamProjector();
beginPrompt(projector);
const event = projector.ingestLine(
@@ -111,9 +114,8 @@ describe("PromptStreamProjector", () => {
params: {
sessionId: "session-1",
update: {
sessionUpdate: "tool_call",
toolCallId: "call-1",
title: "exec",
sessionUpdate: "tool_call_update",
toolCallId: "call_ABC123",
status: "in_progress",
},
},
@@ -122,7 +124,38 @@ describe("PromptStreamProjector", () => {
expect(event).toEqual({
type: "tool_call",
text: "exec (in_progress)",
text: "tool call (in_progress)",
tag: "tool_call_update",
toolCallId: "call_ABC123",
status: "in_progress",
title: "tool call",
});
});
it("maps usage updates with numeric metadata", () => {
const projector = new PromptStreamProjector();
beginPrompt(projector);
const event = projector.ingestLine(
jsonLine({
jsonrpc: "2.0",
method: "session/update",
params: {
sessionId: "session-1",
update: {
sessionUpdate: "usage_update",
used: 12,
size: 500,
},
},
}),
);
expect(event).toEqual({
type: "status",
text: "usage updated: 12/500",
tag: "usage_update",
used: 12,
size: 500,
});
});
@@ -167,6 +200,7 @@ describe("PromptStreamProjector", () => {
type: "text_delta",
text: "new turn",
stream: "output",
tag: "agent_message_chunk",
});
});

View File

@@ -1,4 +1,4 @@
import type { AcpRuntimeEvent } from "openclaw/plugin-sdk";
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "openclaw/plugin-sdk";
import { isAcpJsonRpcMessage, normalizeJsonRpcId } from "./jsonrpc.js";
import {
asOptionalString,
@@ -27,6 +27,10 @@ export function parseJsonLines(value: string): AcpxJsonObject[] {
return events;
}
function asOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function parsePromptStopReason(message: Record<string, unknown>): string | undefined {
if (!Object.hasOwn(message, "result")) {
return undefined;
@@ -39,6 +43,88 @@ function parsePromptStopReason(message: Record<string, unknown>): string | undef
return stopReason && stopReason.trim().length > 0 ? stopReason : undefined;
}
function resolveTextChunk(params: {
update: Record<string, unknown>;
stream: "output" | "thought";
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
const contentRaw = params.update.content;
if (isRecord(contentRaw)) {
const contentType = asTrimmedString(contentRaw.type);
if (contentType && contentType !== "text") {
return null;
}
const text = asString(contentRaw.text);
if (text && text.length > 0) {
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
}
const text = asString(params.update.text);
if (!text || text.length === 0) {
return null;
}
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
function resolveStatusTextForTag(params: {
tag: AcpSessionUpdateTag;
update: Record<string, unknown>;
}): string | null {
const { tag, update } = params;
if (tag === "available_commands_update") {
const commands = Array.isArray(update.availableCommands) ? update.availableCommands : [];
return commands.length > 0
? `available commands updated (${commands.length})`
: "available commands updated";
}
if (tag === "current_mode_update") {
const mode =
asTrimmedString(update.currentModeId) ||
asTrimmedString(update.modeId) ||
asTrimmedString(update.mode);
return mode ? `mode updated: ${mode}` : "mode updated";
}
if (tag === "config_option_update") {
const id = asTrimmedString(update.id) || asTrimmedString(update.configOptionId);
const value =
asTrimmedString(update.currentValue) ||
asTrimmedString(update.value) ||
asTrimmedString(update.optionValue);
if (id && value) {
return `config updated: ${id}=${value}`;
}
if (id) {
return `config updated: ${id}`;
}
return "config updated";
}
if (tag === "session_info_update") {
return asTrimmedString(update.summary) || asTrimmedString(update.message) || "session updated";
}
if (tag === "plan") {
const entries = Array.isArray(update.entries) ? update.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return "plan updated";
}
const status = asTrimmedString(first?.status);
return status ? `plan: [${status}] ${content}` : `plan: ${content}`;
}
return null;
}
function parseSessionUpdateEvent(message: Record<string, unknown>): AcpRuntimeEvent | null {
if (asTrimmedString(message.method) !== "session/update") {
return null;
@@ -52,105 +138,65 @@ function parseSessionUpdateEvent(message: Record<string, unknown>): AcpRuntimeEv
return null;
}
const sessionUpdate = asTrimmedString(update.sessionUpdate);
switch (sessionUpdate) {
case "agent_message_chunk": {
const content = isRecord(update.content) ? update.content : null;
if (!content || asTrimmedString(content.type) !== "text") {
return null;
}
const text = asString(content.text);
if (!text) {
return null;
}
return {
type: "text_delta",
text,
const tag = asOptionalString(update.sessionUpdate) as AcpSessionUpdateTag | undefined;
if (!tag) {
return null;
}
switch (tag) {
case "agent_message_chunk":
return resolveTextChunk({
update,
stream: "output",
};
}
case "agent_thought_chunk": {
const content = isRecord(update.content) ? update.content : null;
if (!content || asTrimmedString(content.type) !== "text") {
return null;
}
const text = asString(content.text);
if (!text) {
return null;
}
return {
type: "text_delta",
text,
tag,
});
case "agent_thought_chunk":
return resolveTextChunk({
update,
stream: "thought",
};
}
tag,
});
case "tool_call":
case "tool_call_update": {
const title =
asTrimmedString(update.title) ||
asTrimmedString(update.toolCallId) ||
asTrimmedString(update.kind) ||
"tool";
const title = asTrimmedString(update.title) || "tool call";
const status = asTrimmedString(update.status);
const toolCallId = asOptionalString(update.toolCallId);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
};
}
case "plan": {
const entries = Array.isArray(update.entries) ? update.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return { type: "status", text: "plan updated" };
}
const status = asTrimmedString(first?.status);
return {
type: "status",
text: status ? `plan: [${status}] ${content}` : `plan: ${content}`,
};
}
case "available_commands_update": {
const commands = Array.isArray(update.availableCommands)
? update.availableCommands.length
: 0;
return {
type: "status",
text: `available commands updated (${commands})`,
};
}
case "current_mode_update": {
const modeId = asTrimmedString(update.currentModeId);
return {
type: "status",
text: modeId ? `mode updated: ${modeId}` : "mode updated",
};
}
case "config_option_update": {
const options = Array.isArray(update.configOptions) ? update.configOptions.length : 0;
return {
type: "status",
text: `config options updated (${options})`,
};
}
case "session_info_update": {
const title = asTrimmedString(update.title);
return {
type: "status",
text: title ? `session info updated: ${title}` : "session info updated",
tag,
...(toolCallId ? { toolCallId } : {}),
...(status ? { status } : {}),
title,
};
}
case "usage_update": {
const used =
typeof update.used === "number" && Number.isFinite(update.used) ? update.used : null;
const size =
typeof update.size === "number" && Number.isFinite(update.size) ? update.size : null;
if (used == null || size == null) {
return { type: "status", text: "usage updated" };
const used = asOptionalFiniteNumber(update.used);
const size = asOptionalFiniteNumber(update.size);
return {
type: "status",
text: used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated",
tag,
...(used != null ? { used } : {}),
...(size != null ? { size } : {}),
};
}
case "available_commands_update":
case "current_mode_update":
case "config_option_update":
case "session_info_update":
case "plan": {
const text = resolveStatusTextForTag({
tag,
update,
});
if (!text) {
return null;
}
return {
type: "status",
text: `usage updated: ${used}/${size}`,
text,
tag,
};
}
default: