2026-03-20 09:30:16 +01:00

324 lines
8.7 KiB
TypeScript

import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js";
import {
asOptionalBoolean,
asOptionalString,
asString,
asTrimmedString,
type AcpxErrorEvent,
type AcpxJsonObject,
isRecord,
} from "./shared.js";
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
if (asTrimmedString(value.type) !== "error") {
return null;
}
return {
message: asTrimmedString(value.message) || "acpx reported an error",
code: asOptionalString(value.code),
retryable: asOptionalBoolean(value.retryable),
};
}
export function parseJsonLines(value: string): AcpxJsonObject[] {
const events: AcpxJsonObject[] = [];
for (const line of value.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
events.push(parsed);
}
} catch {
// Ignore malformed lines; callers handle missing typed events via exit code.
}
}
return events;
}
function asOptionalFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function resolveStructuredPromptPayload(parsed: Record<string, unknown>): {
type: string;
payload: Record<string, unknown>;
tag?: AcpSessionUpdateTag;
} {
const method = asTrimmedString(parsed.method);
if (method === "session/update") {
const params = parsed.params;
if (isRecord(params) && isRecord(params.update)) {
const update = params.update;
const tag = asOptionalString(update.sessionUpdate) as AcpSessionUpdateTag | undefined;
return {
type: tag ?? "",
payload: update,
...(tag ? { tag } : {}),
};
}
}
const sessionUpdate = asOptionalString(parsed.sessionUpdate) as AcpSessionUpdateTag | undefined;
if (sessionUpdate) {
return {
type: sessionUpdate,
payload: parsed,
tag: sessionUpdate,
};
}
const type = asTrimmedString(parsed.type);
const tag = asOptionalString(parsed.tag) as AcpSessionUpdateTag | undefined;
return {
type,
payload: parsed,
...(tag ? { tag } : {}),
};
}
function resolveStatusTextForTag(params: {
tag: AcpSessionUpdateTag;
payload: Record<string, unknown>;
}): string | null {
const { tag, payload } = params;
if (tag === "available_commands_update") {
const commands = Array.isArray(payload.availableCommands) ? payload.availableCommands : [];
return commands.length > 0
? `available commands updated (${commands.length})`
: "available commands updated";
}
if (tag === "current_mode_update") {
const mode =
asTrimmedString(payload.currentModeId) ||
asTrimmedString(payload.modeId) ||
asTrimmedString(payload.mode);
return mode ? `mode updated: ${mode}` : "mode updated";
}
if (tag === "config_option_update") {
const id = asTrimmedString(payload.id) || asTrimmedString(payload.configOptionId);
const value =
asTrimmedString(payload.currentValue) ||
asTrimmedString(payload.value) ||
asTrimmedString(payload.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(payload.summary) || asTrimmedString(payload.message) || "session updated"
);
}
if (tag === "plan") {
const entries = Array.isArray(payload.entries) ? payload.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
return content ? `plan: ${content}` : null;
}
return null;
}
function resolveTextChunk(params: {
payload: Record<string, unknown>;
stream: "output" | "thought";
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
const contentRaw = params.payload.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.payload.text);
if (!text || text.length === 0) {
return null;
}
return {
type: "text_delta",
text,
stream: params.stream,
tag: params.tag,
};
}
function createTextDeltaEvent(params: {
content: string | null | undefined;
stream: "output" | "thought";
tag?: AcpSessionUpdateTag;
}): AcpRuntimeEvent | null {
if (params.content == null || params.content.length === 0) {
return null;
}
return {
type: "text_delta",
text: params.content,
stream: params.stream,
...(params.tag ? { tag: params.tag } : {}),
};
}
function createToolCallEvent(params: {
payload: Record<string, unknown>;
tag: AcpSessionUpdateTag;
}): AcpRuntimeEvent {
const title = asTrimmedString(params.payload.title) || "tool call";
const status = asTrimmedString(params.payload.status);
const toolCallId = asOptionalString(params.payload.toolCallId);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
tag: params.tag,
...(toolCallId ? { toolCallId } : {}),
...(status ? { status } : {}),
title,
};
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return {
type: "status",
text: trimmed,
};
}
if (!isRecord(parsed)) {
return null;
}
const structured = resolveStructuredPromptPayload(parsed);
const type = structured.type;
const payload = structured.payload;
const tag = structured.tag;
switch (type) {
case "text":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "output",
tag,
});
case "thought":
return createTextDeltaEvent({
content: asString(payload.content),
stream: "thought",
tag,
});
case "tool_call":
return createToolCallEvent({
payload,
tag: (tag ?? "tool_call") as AcpSessionUpdateTag,
});
case "tool_call_update":
return createToolCallEvent({
payload,
tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag,
});
case "agent_message_chunk":
return resolveTextChunk({
payload,
stream: "output",
tag: "agent_message_chunk",
});
case "agent_thought_chunk":
return resolveTextChunk({
payload,
stream: "thought",
tag: "agent_thought_chunk",
});
case "usage_update": {
const used = asOptionalFiniteNumber(payload.used);
const size = asOptionalFiniteNumber(payload.size);
const text =
used != null && size != null ? `usage updated: ${used}/${size}` : "usage updated";
return {
type: "status",
text,
tag: "usage_update",
...(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: type as AcpSessionUpdateTag,
payload,
});
if (!text) {
return null;
}
return {
type: "status",
text,
tag: type as AcpSessionUpdateTag,
};
}
case "client_operation": {
const method = asTrimmedString(payload.method) || "operation";
const status = asTrimmedString(payload.status);
const summary = asTrimmedString(payload.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text, ...(tag ? { tag } : {}) };
}
case "update": {
const update = asTrimmedString(payload.update);
if (!update) {
return null;
}
return { type: "status", text: update, ...(tag ? { tag } : {}) };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(payload.stopReason),
};
}
case "error": {
const message = asTrimmedString(payload.message) || "acpx runtime error";
return {
type: "error",
message,
code: asOptionalString(payload.code),
retryable: asOptionalBoolean(payload.retryable),
};
}
default:
return null;
}
}