mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 08:07:27 +00:00
ACP: carry dedupe/projector updates onto configurable acpx branch
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user