ACPX extension: parse pure ACP JSON-RPC stream

This commit is contained in:
Onur
2026-02-28 15:15:28 +01:00
committed by Onur Solmaz
parent 9cae5107d1
commit bdc355d0b0
4 changed files with 630 additions and 185 deletions

View File

@@ -9,17 +9,72 @@ import {
isRecord,
} from "./shared.js";
type JsonRpcId = string | number | null;
export type PromptParseContext = {
promptRequestIds: Set<string>;
};
function isJsonRpcId(value: unknown): value is JsonRpcId {
return (
value === null ||
typeof value === "string" ||
(typeof value === "number" && Number.isFinite(value))
);
}
function normalizeJsonRpcId(value: unknown): string | null {
if (!isJsonRpcId(value) || value == null) {
return null;
}
return String(value);
}
function isAcpJsonRpcMessage(value: unknown): value is Record<string, unknown> {
if (!isRecord(value) || value.jsonrpc !== "2.0") {
return false;
}
const hasMethod = typeof value.method === "string" && value.method.length > 0;
const hasId = Object.hasOwn(value, "id");
if (hasMethod && !hasId) {
return true;
}
if (hasMethod && hasId) {
return isJsonRpcId(value.id);
}
if (!hasMethod && hasId) {
if (!isJsonRpcId(value.id)) {
return false;
}
const hasResult = Object.hasOwn(value, "result");
const hasError = Object.hasOwn(value, "error");
return hasResult !== hasError;
}
return false;
}
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
if (!isRecord(value)) {
return null;
}
if (asTrimmedString(value.type) !== "error") {
const error = isRecord(value.error) ? value.error : null;
if (!error) {
return null;
}
const message = asTrimmedString(error.message) || "acpx reported an error";
const codeValue = error.code;
return {
message: asTrimmedString(value.message) || "acpx reported an error",
code: asOptionalString(value.code),
retryable: asOptionalBoolean(value.retryable),
message,
code:
typeof codeValue === "number" && Number.isFinite(codeValue)
? String(codeValue)
: asOptionalString(codeValue),
retryable: asOptionalBoolean(error.retryable),
};
}
@@ -42,7 +97,155 @@ export function parseJsonLines(value: string): AcpxJsonObject[] {
return events;
}
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
function parsePromptStopReason(message: Record<string, unknown>): string | undefined {
if (!Object.hasOwn(message, "result")) {
return undefined;
}
const result = isRecord(message.result) ? message.result : null;
if (!result) {
return undefined;
}
const stopReason = asString(result.stopReason);
return stopReason && stopReason.trim().length > 0 ? stopReason : undefined;
}
function parseSessionUpdateEvent(message: Record<string, unknown>): AcpRuntimeEvent | null {
if (asTrimmedString(message.method) !== "session/update") {
return null;
}
const params = isRecord(message.params) ? message.params : null;
if (!params) {
return null;
}
const update = isRecord(params.update) ? params.update : null;
if (!update) {
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,
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,
stream: "thought",
};
}
case "tool_call":
case "tool_call_update": {
const title =
asTrimmedString(update.title) ||
asTrimmedString(update.toolCallId) ||
asTrimmedString(update.kind) ||
"tool";
const status = asTrimmedString(update.status);
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",
};
}
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" };
}
return {
type: "status",
text: `usage updated: ${used}/${size}`,
};
}
default:
return null;
}
}
function shouldHandlePromptResponse(params: {
message: Record<string, unknown>;
context?: PromptParseContext;
}): boolean {
const id = normalizeJsonRpcId(params.message.id);
if (!id) {
return false;
}
if (!params.context) {
return true;
}
return params.context.promptRequestIds.has(id);
}
export function parsePromptEventLine(
line: string,
context?: PromptParseContext,
): AcpRuntimeEvent | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
@@ -61,80 +264,60 @@ export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
return null;
}
const type = asTrimmedString(parsed.type);
switch (type) {
case "text": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "output",
};
}
case "thought": {
const content = asString(parsed.content);
if (content == null || content.length === 0) {
return null;
}
return {
type: "text_delta",
text: content,
stream: "thought",
};
}
case "tool_call": {
const title = asTrimmedString(parsed.title) || asTrimmedString(parsed.toolCallId) || "tool";
const status = asTrimmedString(parsed.status);
return {
type: "tool_call",
text: status ? `${title} (${status})` : title,
};
}
case "client_operation": {
const method = asTrimmedString(parsed.method) || "operation";
const status = asTrimmedString(parsed.status);
const summary = asTrimmedString(parsed.summary);
const text = [method, status, summary].filter(Boolean).join(" ");
if (!text) {
return null;
}
return { type: "status", text };
}
case "plan": {
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
const first = entries.find((entry) => isRecord(entry)) as Record<string, unknown> | undefined;
const content = asTrimmedString(first?.content);
if (!content) {
return null;
}
return { type: "status", text: `plan: ${content}` };
}
case "update": {
const update = asTrimmedString(parsed.update);
if (!update) {
return null;
}
return { type: "status", text: update };
}
case "done": {
return {
type: "done",
stopReason: asOptionalString(parsed.stopReason),
};
}
case "error": {
const message = asTrimmedString(parsed.message) || "acpx runtime error";
if (!isAcpJsonRpcMessage(parsed)) {
const fallbackError = toAcpxErrorEvent(parsed);
if (fallbackError) {
return {
type: "error",
message,
code: asOptionalString(parsed.code),
retryable: asOptionalBoolean(parsed.retryable),
message: fallbackError.message,
code: fallbackError.code,
retryable: fallbackError.retryable,
};
}
default:
return null;
return null;
}
const updateEvent = parseSessionUpdateEvent(parsed);
if (updateEvent) {
return updateEvent;
}
if (asTrimmedString(parsed.method) === "session/prompt") {
const id = normalizeJsonRpcId(parsed.id);
if (id && context) {
context.promptRequestIds.add(id);
}
return null;
}
if (Object.hasOwn(parsed, "error")) {
if (!shouldHandlePromptResponse({ message: parsed, context })) {
return null;
}
const error = isRecord(parsed.error) ? parsed.error : null;
const message = asTrimmedString(error?.message);
const codeValue = error?.code;
return {
type: "error",
message: message || "acpx runtime error",
code:
typeof codeValue === "number" && Number.isFinite(codeValue)
? String(codeValue)
: asOptionalString(codeValue),
retryable: asOptionalBoolean(error?.retryable),
};
}
const stopReason = parsePromptStopReason(parsed);
if (stopReason) {
if (!shouldHandlePromptResponse({ message: parsed, context })) {
return null;
}
return {
type: "done",
stopReason,
};
}
return null;
}