mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
refactor: consume acpx runtime library (#61495)
* refactor: consume acpx runtime library * refactor: remove duplicated acpx runtime files * fix: update acpx runtime dependency * fix: preserve acp runtime error codes * fix: migrate legacy acpx session files * fix: update acpx runtime dependency * fix: import Dirent from node fs * ACPX: repin shared runtime engine * ACPX: repin runtime semantics fixes * ACPX: repin runtime contract cleanup * Extensions: repin ACPX after layout refactor * ACPX: drop legacy session migration * ACPX: drop direct ACP SDK dependency * Discord ACP: stop duplicate direct fallback replies * ACP: rename delivered text visibility hook * ACPX: pin extension to 0.5.0 * Deps: drop stale ACPX build-script allowlist * ACPX: add local development guidance * ACPX: document temporary pnpm exception flow * SDK: preserve legacy ACP visibility hook * ACP: keep reset commands on local path * ACP: make in-place reset start fresh session * ACP: recover broken bindings on fresh reset * ACP: defer fresh reset marker until close succeeds * ACP: reset bound sessions fresh again * Discord: ensure ACP bindings before /new * ACP: recover missing persistent sessions
This commit is contained in:
54
extensions/acpx/AGENTS.md
Normal file
54
extensions/acpx/AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ACPX Extension Notes
|
||||
|
||||
This file applies to work under `extensions/acpx/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The bundled ACPX extension is a thin OpenClaw wrapper around the published `acpx` package. Keep reusable ACP runtime logic in `openclaw/acpx`, not in this extension.
|
||||
|
||||
## Default Version Policy
|
||||
|
||||
- `extensions/acpx/package.json` should point at a published npm release by default.
|
||||
- Do not leave the extension pinned to a temporary GitHub commit or local checkout once the ACPX release exists.
|
||||
- Do not leave temporary pnpm build-script allowlist exceptions behind after switching back to a published ACPX package.
|
||||
|
||||
## Unreleased ACPX Development Flow
|
||||
|
||||
Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX version is published.
|
||||
|
||||
1. Make the ACPX code change in the `openclaw/acpx` repo first.
|
||||
2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need.
|
||||
3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx` to `onlyBuiltDependencies` in both `package.json` and `pnpm-workspace.yaml`.
|
||||
4. Refresh the root workspace lock:
|
||||
- `pnpm install --lockfile-only --filter ./extensions/acpx`
|
||||
5. Refresh the extension-local npm lock for install metadata:
|
||||
- `cd extensions/acpx && npm install --package-lock-only --ignore-scripts`
|
||||
6. Rebuild OpenClaw and restart the gateway before doing live ACP validation.
|
||||
7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again.
|
||||
8. Remove any temporary `acpx` build-script allowlist entries that were only needed for the GitHub-sourced development pin.
|
||||
|
||||
## Lockfile Notes
|
||||
|
||||
- `pnpm-lock.yaml` is the tracked workspace lockfile and must match the ACPX version referenced by `extensions/acpx/package.json`.
|
||||
- `extensions/acpx/package-lock.json` is useful local install metadata for the bundled plugin package.
|
||||
- If `extensions/acpx/package-lock.json` is gitignored in this repo state, regenerating it is still useful for local verification, but it will not appear in `git status`.
|
||||
|
||||
## Local Runtime Validation
|
||||
|
||||
When ACPX integration changes here, prefer this sequence:
|
||||
|
||||
1. `pnpm install --filter ./extensions/acpx`
|
||||
2. `pnpm test:extension acpx`
|
||||
3. `pnpm build`
|
||||
4. Restart the local gateway if ACP runtime behavior or bundled plugin wiring changed.
|
||||
5. If the change affects direct ACP behavior in chat, run a real ACP smoke after restart.
|
||||
|
||||
## Direct ACPX Binary Policy
|
||||
|
||||
- Prefer the plugin-local ACPX binary under `extensions/acpx/node_modules/.bin/acpx`.
|
||||
- Do not rely on a globally installed `acpx` binary for OpenClaw ACP validation.
|
||||
- If the plugin-local ACPX binary is missing or on the wrong version, reinstall it from the version pinned in `extensions/acpx/package.json`.
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
If a change feels like shared ACP runtime behavior instead of OpenClaw-specific glue, move it to `openclaw/acpx` and consume it from here instead of re-implementing it inside `extensions/acpx`.
|
||||
1
extensions/acpx/CLAUDE.md
Symbolic link
1
extensions/acpx/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"label": "Strict Windows cmd Wrapper",
|
||||
"help": "Enabled by default. On Windows, reject unresolved .cmd/.bat wrappers instead of shell fallback. Disable only for compatibility with non-standard wrappers.",
|
||||
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Reserved compatibility field for future queued embedded prompt ownership.",
|
||||
"help": "Reserved compatibility field for the older embedded ACPX queue-owner path. Accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"mcpServers": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.18.0"
|
||||
"acpx": "0.5.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { OutputErrorAcpPayload } from "./runtime-types.js";
|
||||
|
||||
const RESOURCE_NOT_FOUND_ACP_CODES = new Set([-32001, -32002]);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toAcpErrorPayload(value: unknown): OutputErrorAcpPayload | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof record.code !== "number" || !Number.isFinite(record.code)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof record.message !== "string" || record.message.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
code: record.code,
|
||||
message: record.message,
|
||||
data: record.data,
|
||||
};
|
||||
}
|
||||
|
||||
function extractAcpErrorInternal(value: unknown, depth: number): OutputErrorAcpPayload | undefined {
|
||||
if (depth > 5) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const direct = toAcpErrorPayload(value);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if ("error" in record) {
|
||||
const nested = extractAcpErrorInternal(record.error, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
if ("acp" in record) {
|
||||
const nested = extractAcpErrorInternal(record.acp, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
if ("cause" in record) {
|
||||
const nested = extractAcpErrorInternal(record.cause, depth + 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatUnknownErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (error && typeof error === "object") {
|
||||
const maybeMessage = (error as { message?: unknown }).message;
|
||||
if (typeof maybeMessage === "string" && maybeMessage.length > 0) {
|
||||
return maybeMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
// Matches "session" followed by optional ID (quoted or unquoted) followed by "not found"
|
||||
// Examples: "Session \"abc\" not found", "Session abc-123 not found"
|
||||
const SESSION_NOT_FOUND_PATTERN = /session\s+["'\w-]+\s+not found/i;
|
||||
|
||||
function isSessionNotFoundText(value: unknown): boolean {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes("resource_not_found") ||
|
||||
normalized.includes("resource not found") ||
|
||||
normalized.includes("session not found") ||
|
||||
normalized.includes("unknown session") ||
|
||||
normalized.includes("invalid session identifier") ||
|
||||
SESSION_NOT_FOUND_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function hasSessionNotFoundHint(value: unknown, depth = 0): boolean {
|
||||
if (depth > 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isSessionNotFoundText(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((entry) => hasSessionNotFoundHint(entry, depth + 1));
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(record).some((entry) => hasSessionNotFoundHint(entry, depth + 1));
|
||||
}
|
||||
|
||||
export function extractAcpError(error: unknown): OutputErrorAcpPayload | undefined {
|
||||
return extractAcpErrorInternal(error, 0);
|
||||
}
|
||||
|
||||
export function isAcpResourceNotFoundError(error: unknown): boolean {
|
||||
const acp = extractAcpError(error);
|
||||
if (acp && RESOURCE_NOT_FOUND_ACP_CODES.has(acp.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acp) {
|
||||
if (isSessionNotFoundText(acp.message)) {
|
||||
return true;
|
||||
}
|
||||
if (hasSessionNotFoundHint(acp.data)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return isSessionNotFoundText(formatUnknownErrorMessage(error));
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import type { AnyMessage, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
|
||||
type JsonRpcId = string | number | null;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasValidId(value: unknown): value is JsonRpcId {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
(typeof value === "number" && Number.isFinite(value))
|
||||
);
|
||||
}
|
||||
|
||||
function isErrorObject(value: unknown): value is { code: number; message: string } {
|
||||
const record = asRecord(value);
|
||||
return (
|
||||
!!record &&
|
||||
typeof record.code === "number" &&
|
||||
Number.isFinite(record.code) &&
|
||||
typeof record.message === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function hasResultOrError(value: Record<string, unknown>): boolean {
|
||||
const hasResult = Object.hasOwn(value, "result");
|
||||
const hasError = Object.hasOwn(value, "error");
|
||||
if (hasResult && hasError) {
|
||||
return false;
|
||||
}
|
||||
if (!hasResult && !hasError) {
|
||||
return false;
|
||||
}
|
||||
if (hasError && !isErrorObject(value.error)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isAcpJsonRpcMessage(value: unknown): value is AnyMessage {
|
||||
const record = asRecord(value);
|
||||
if (!record || record.jsonrpc !== "2.0") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasMethod = typeof record.method === "string" && record.method.length > 0;
|
||||
const hasId = Object.hasOwn(record, "id");
|
||||
|
||||
if (hasMethod && !hasId) {
|
||||
// Notification
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasMethod && hasId) {
|
||||
// Request
|
||||
return hasValidId(record.id);
|
||||
}
|
||||
|
||||
if (!hasMethod && hasId) {
|
||||
// Response
|
||||
if (!hasValidId(record.id)) {
|
||||
return false;
|
||||
}
|
||||
return hasResultOrError(record);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isJsonRpcNotification(message: AnyMessage): boolean {
|
||||
return (
|
||||
Object.hasOwn(message, "method") &&
|
||||
typeof (message as { method?: unknown }).method === "string" &&
|
||||
!Object.hasOwn(message, "id")
|
||||
);
|
||||
}
|
||||
|
||||
export function isSessionUpdateNotification(message: AnyMessage): boolean {
|
||||
return (
|
||||
isJsonRpcNotification(message) && (message as { method?: unknown }).method === "session/update"
|
||||
);
|
||||
}
|
||||
|
||||
export function extractSessionUpdateNotification(
|
||||
message: AnyMessage,
|
||||
): SessionNotification | undefined {
|
||||
if (!isSessionUpdateNotification(message)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = asRecord((message as { params?: unknown }).params);
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionId = typeof params.sessionId === "string" ? params.sessionId : null;
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const update = asRecord(params.update);
|
||||
if (!update || typeof update.sessionUpdate !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
update: update as SessionNotification["update"],
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePromptStopReason(message: AnyMessage): string | undefined {
|
||||
if (!Object.hasOwn(message, "id") || !Object.hasOwn(message, "result")) {
|
||||
return undefined;
|
||||
}
|
||||
const record = asRecord((message as { result?: unknown }).result);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof record.stopReason === "string" ? record.stopReason : undefined;
|
||||
}
|
||||
|
||||
export function parseJsonRpcErrorMessage(message: AnyMessage): string | undefined {
|
||||
if (!Object.hasOwn(message, "error")) {
|
||||
return undefined;
|
||||
}
|
||||
const errorRecord = asRecord((message as { error?: unknown }).error);
|
||||
if (!errorRecord || typeof errorRecord.message !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return errorRecord.message;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
const AGENT_SESSION_ID_META_KEYS = ["agentSessionId", "sessionId"] as const;
|
||||
|
||||
export function normalizeAgentSessionId(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function asMetaRecord(meta: unknown): Record<string, unknown> | undefined {
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return undefined;
|
||||
}
|
||||
return meta as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function extractAgentSessionId(meta: unknown): string | undefined {
|
||||
const record = asMetaRecord(meta);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const key of AGENT_SESSION_ID_META_KEYS) {
|
||||
const normalized = normalizeAgentSessionId(record[key]);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export { AGENT_SESSION_ID_META_KEYS };
|
||||
@@ -1,61 +0,0 @@
|
||||
const ACP_ADAPTER_PACKAGE_RANGES = {
|
||||
pi: "^0.0.22",
|
||||
codex: "^0.11.1",
|
||||
claude: "^0.25.0",
|
||||
} as const;
|
||||
|
||||
export const AGENT_REGISTRY: Record<string, string> = {
|
||||
pi: `npx pi-acp@${ACP_ADAPTER_PACKAGE_RANGES.pi}`,
|
||||
openclaw: "openclaw acp",
|
||||
codex: `npx @zed-industries/codex-acp@${ACP_ADAPTER_PACKAGE_RANGES.codex}`,
|
||||
claude: `npx -y @agentclientprotocol/claude-agent-acp@${ACP_ADAPTER_PACKAGE_RANGES.claude}`,
|
||||
gemini: "gemini --acp",
|
||||
cursor: "cursor-agent acp",
|
||||
copilot: "copilot --acp --stdio",
|
||||
droid: "droid exec --output-format acp",
|
||||
iflow: "iflow --experimental-acp",
|
||||
kilocode: "npx -y @kilocode/cli acp",
|
||||
kimi: "kimi acp",
|
||||
kiro: "kiro-cli-chat acp",
|
||||
opencode: "npx -y opencode-ai acp",
|
||||
qoder: "qodercli --acp",
|
||||
qwen: "qwen --acp",
|
||||
trae: "traecli acp serve",
|
||||
};
|
||||
|
||||
const AGENT_ALIASES: Record<string, string> = {
|
||||
"factory-droid": "droid",
|
||||
factorydroid: "droid",
|
||||
};
|
||||
|
||||
export const DEFAULT_AGENT_NAME = "codex";
|
||||
|
||||
export function normalizeAgentName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function mergeAgentRegistry(overrides?: Record<string, string>): Record<string, string> {
|
||||
if (!overrides) {
|
||||
return { ...AGENT_REGISTRY };
|
||||
}
|
||||
|
||||
const merged = { ...AGENT_REGISTRY };
|
||||
for (const [name, command] of Object.entries(overrides)) {
|
||||
const normalized = normalizeAgentName(name);
|
||||
if (!normalized || !command.trim()) {
|
||||
continue;
|
||||
}
|
||||
merged[normalized] = command.trim();
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function resolveAgentCommand(agentName: string, overrides?: Record<string, string>): string {
|
||||
const normalized = normalizeAgentName(agentName);
|
||||
const registry = mergeAgentRegistry(overrides);
|
||||
return registry[normalized] ?? registry[AGENT_ALIASES[normalized] ?? normalized] ?? agentName;
|
||||
}
|
||||
|
||||
export function listBuiltInAgents(overrides?: Record<string, string>): string[] {
|
||||
return Object.keys(mergeAgentRegistry(overrides));
|
||||
}
|
||||
@@ -43,6 +43,10 @@ export type ResolvedAcpxPluginConfig = {
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
agents: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -239,6 +239,10 @@ export function resolveAcpxPluginConfig(params: {
|
||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||
timeoutSeconds: normalized.timeoutSeconds,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
|
||||
},
|
||||
mcpServers,
|
||||
agents,
|
||||
};
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import {
|
||||
extractAcpError,
|
||||
formatUnknownErrorMessage,
|
||||
isAcpResourceNotFoundError,
|
||||
} from "./acp-error-shapes.js";
|
||||
import {
|
||||
AuthPolicyError,
|
||||
PermissionDeniedError,
|
||||
PermissionPromptUnavailableError,
|
||||
} from "./errors.js";
|
||||
import {
|
||||
EXIT_CODES,
|
||||
OUTPUT_ERROR_CODES,
|
||||
OUTPUT_ERROR_ORIGINS,
|
||||
type ExitCode,
|
||||
type OutputErrorAcpPayload,
|
||||
type OutputErrorCode,
|
||||
type OutputErrorOrigin,
|
||||
} from "./runtime-types.js";
|
||||
|
||||
const AUTH_REQUIRED_ACP_CODES = new Set([-32000]);
|
||||
const QUERY_CLOSED_BEFORE_RESPONSE_DETAIL = "query closed before response received";
|
||||
|
||||
type ErrorMeta = {
|
||||
outputCode?: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
};
|
||||
|
||||
export type NormalizedOutputError = {
|
||||
code: OutputErrorCode;
|
||||
message: string;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
};
|
||||
|
||||
export type NormalizeOutputErrorOptions = {
|
||||
defaultCode?: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isAuthRequiredMessage(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes("auth required") ||
|
||||
normalized.includes("authentication required") ||
|
||||
normalized.includes("authorization required") ||
|
||||
normalized.includes("credential required") ||
|
||||
normalized.includes("credentials required") ||
|
||||
normalized.includes("token required") ||
|
||||
normalized.includes("login required")
|
||||
);
|
||||
}
|
||||
|
||||
function isAcpAuthRequiredPayload(acp: OutputErrorAcpPayload | undefined): boolean {
|
||||
if (!acp) {
|
||||
return false;
|
||||
}
|
||||
if (!AUTH_REQUIRED_ACP_CODES.has(acp.code)) {
|
||||
return false;
|
||||
}
|
||||
if (isAuthRequiredMessage(acp.message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = asRecord(acp.data);
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.authRequired === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const methodId = data.methodId;
|
||||
if (typeof methodId === "string" && methodId.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const methods = data.methods;
|
||||
if (Array.isArray(methods) && methods.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isOutputErrorCode(value: unknown): value is OutputErrorCode {
|
||||
return typeof value === "string" && OUTPUT_ERROR_CODES.includes(value as OutputErrorCode);
|
||||
}
|
||||
|
||||
function isOutputErrorOrigin(value: unknown): value is OutputErrorOrigin {
|
||||
return typeof value === "string" && OUTPUT_ERROR_ORIGINS.includes(value as OutputErrorOrigin);
|
||||
}
|
||||
|
||||
function readOutputErrorMeta(error: unknown): ErrorMeta {
|
||||
const record = asRecord(error);
|
||||
if (!record) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const outputCode = isOutputErrorCode(record.outputCode) ? record.outputCode : undefined;
|
||||
const detailCode =
|
||||
typeof record.detailCode === "string" && record.detailCode.trim().length > 0
|
||||
? record.detailCode
|
||||
: undefined;
|
||||
const origin = isOutputErrorOrigin(record.origin) ? record.origin : undefined;
|
||||
const retryable = typeof record.retryable === "boolean" ? record.retryable : undefined;
|
||||
|
||||
const acp = extractAcpError(record.acp);
|
||||
return {
|
||||
outputCode,
|
||||
detailCode,
|
||||
origin,
|
||||
retryable,
|
||||
acp,
|
||||
};
|
||||
}
|
||||
|
||||
function isTimeoutLike(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "TimeoutError";
|
||||
}
|
||||
|
||||
function isNoSessionLike(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === "NoSessionError";
|
||||
}
|
||||
|
||||
function isUsageLike(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
error.name === "CommanderError" ||
|
||||
error.name === "InvalidArgumentError" ||
|
||||
asRecord(error)?.code === "commander.invalidArgument"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
return formatUnknownErrorMessage(error);
|
||||
}
|
||||
|
||||
export { extractAcpError, isAcpResourceNotFoundError };
|
||||
|
||||
export function isAcpQueryClosedBeforeResponseError(error: unknown): boolean {
|
||||
const acp = extractAcpError(error);
|
||||
if (!acp || acp.code !== -32603) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = asRecord(acp.data);
|
||||
const details = data?.details;
|
||||
if (typeof details !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return details.toLowerCase().includes(QUERY_CLOSED_BEFORE_RESPONSE_DETAIL);
|
||||
}
|
||||
|
||||
function mapErrorCode(error: unknown): OutputErrorCode | undefined {
|
||||
if (error instanceof PermissionPromptUnavailableError) {
|
||||
return "PERMISSION_PROMPT_UNAVAILABLE";
|
||||
}
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return "PERMISSION_DENIED";
|
||||
}
|
||||
if (isTimeoutLike(error)) {
|
||||
return "TIMEOUT";
|
||||
}
|
||||
if (isNoSessionLike(error) || isAcpResourceNotFoundError(error)) {
|
||||
return "NO_SESSION";
|
||||
}
|
||||
if (isUsageLike(error)) {
|
||||
return "USAGE";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeOutputError(
|
||||
error: unknown,
|
||||
options: NormalizeOutputErrorOptions = {},
|
||||
): NormalizedOutputError {
|
||||
const meta = readOutputErrorMeta(error);
|
||||
const mapped = mapErrorCode(error);
|
||||
let code = mapped ?? options.defaultCode ?? "RUNTIME";
|
||||
|
||||
if (meta.outputCode) {
|
||||
code = meta.outputCode;
|
||||
}
|
||||
|
||||
if (code === "RUNTIME" && isAcpResourceNotFoundError(error)) {
|
||||
code = "NO_SESSION";
|
||||
}
|
||||
|
||||
const acp = options.acp ?? meta.acp ?? extractAcpError(error);
|
||||
const detailCode =
|
||||
meta.detailCode ??
|
||||
options.detailCode ??
|
||||
(error instanceof AuthPolicyError || isAcpAuthRequiredPayload(acp)
|
||||
? "AUTH_REQUIRED"
|
||||
: undefined);
|
||||
return {
|
||||
code,
|
||||
message: formatErrorMessage(error),
|
||||
detailCode,
|
||||
origin: meta.origin ?? options.origin,
|
||||
retryable: meta.retryable ?? options.retryable,
|
||||
acp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when an error from `client.prompt()` looks transient and
|
||||
* can reasonably be retried (e.g. model-API 400/500, network hiccups that
|
||||
* surface as ACP internal errors).
|
||||
*
|
||||
* Errors that are definitively non-recoverable (auth, missing session,
|
||||
* invalid params, timeout, permission) return false.
|
||||
*/
|
||||
export function isRetryablePromptError(error: unknown): boolean {
|
||||
if (error instanceof PermissionDeniedError || error instanceof PermissionPromptUnavailableError) {
|
||||
return false;
|
||||
}
|
||||
if (isTimeoutLike(error) || isNoSessionLike(error) || isUsageLike(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract ACP payload once and reuse for all subsequent checks.
|
||||
const acp = extractAcpError(error);
|
||||
if (!acp) {
|
||||
// Non-ACP errors (e.g. process crash) are not retried at the prompt level.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resource-not-found (session gone) — check using the already-extracted payload.
|
||||
if (acp.code === -32001 || acp.code === -32002) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auth-required errors are never retryable. Use the same thorough check as normalizeOutputError.
|
||||
if (isAcpAuthRequiredPayload(acp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Method-not-found or invalid-params are permanent protocol errors.
|
||||
if (acp.code === -32601 || acp.code === -32602) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ACP internal errors (-32603) typically wrap model-API failures → retryable.
|
||||
// Parse errors (-32700) can also be transient.
|
||||
return acp.code === -32603 || acp.code === -32700;
|
||||
}
|
||||
|
||||
export function exitCodeForOutputErrorCode(code: OutputErrorCode): ExitCode {
|
||||
switch (code) {
|
||||
case "USAGE":
|
||||
return EXIT_CODES.USAGE;
|
||||
case "TIMEOUT":
|
||||
return EXIT_CODES.TIMEOUT;
|
||||
case "NO_SESSION":
|
||||
return EXIT_CODES.NO_SESSION;
|
||||
case "PERMISSION_DENIED":
|
||||
case "PERMISSION_PROMPT_UNAVAILABLE":
|
||||
return EXIT_CODES.PERMISSION_DENIED;
|
||||
case "RUNTIME":
|
||||
default:
|
||||
return EXIT_CODES.ERROR;
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { OutputErrorAcpPayload, OutputErrorCode, OutputErrorOrigin } from "./runtime-types.js";
|
||||
|
||||
type AcpxErrorOptions = ErrorOptions & {
|
||||
outputCode?: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
outputAlreadyEmitted?: boolean;
|
||||
};
|
||||
|
||||
export class AcpxOperationalError extends Error {
|
||||
readonly outputCode?: OutputErrorCode;
|
||||
readonly detailCode?: string;
|
||||
readonly origin?: OutputErrorOrigin;
|
||||
readonly retryable?: boolean;
|
||||
readonly acp?: OutputErrorAcpPayload;
|
||||
readonly outputAlreadyEmitted?: boolean;
|
||||
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = new.target.name;
|
||||
this.outputCode = options?.outputCode;
|
||||
this.detailCode = options?.detailCode;
|
||||
this.origin = options?.origin;
|
||||
this.retryable = options?.retryable;
|
||||
this.acp = options?.acp;
|
||||
this.outputAlreadyEmitted = options?.outputAlreadyEmitted;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionNotFoundError extends AcpxOperationalError {
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
super(`Session not found: ${sessionId}`);
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionResolutionError extends AcpxOperationalError {}
|
||||
|
||||
export class AgentSpawnError extends AcpxOperationalError {
|
||||
readonly agentCommand: string;
|
||||
|
||||
constructor(agentCommand: string, cause?: unknown) {
|
||||
super(`Failed to spawn agent command: ${agentCommand}`, {
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
this.agentCommand = agentCommand;
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentDisconnectedError extends AcpxOperationalError {
|
||||
readonly reason: string;
|
||||
readonly exitCode: number | null;
|
||||
readonly signal: NodeJS.Signals | null;
|
||||
|
||||
constructor(
|
||||
reason: string,
|
||||
exitCode: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
options?: AcpxErrorOptions,
|
||||
) {
|
||||
super(
|
||||
`ACP agent disconnected during request (${reason}, exit=${exitCode ?? "null"}, signal=${signal ?? "null"})`,
|
||||
{
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "AGENT_DISCONNECTED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
},
|
||||
);
|
||||
this.reason = reason;
|
||||
this.exitCode = exitCode;
|
||||
this.signal = signal;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionResumeRequiredError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "SESSION_RESUME_REQUIRED",
|
||||
origin: "acp",
|
||||
retryable: true,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GeminiAcpStartupTimeoutError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "TIMEOUT",
|
||||
detailCode: "GEMINI_ACP_STARTUP_TIMEOUT",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionModeReplayError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "SESSION_MODE_REPLAY_FAILED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionModelReplayError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "SESSION_MODEL_REPLAY_FAILED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ClaudeAcpSessionCreateTimeoutError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "TIMEOUT",
|
||||
detailCode: "CLAUDE_ACP_SESSION_CREATE_TIMEOUT",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CopilotAcpUnsupportedError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "COPILOT_ACP_UNSUPPORTED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthPolicyError extends AcpxOperationalError {
|
||||
constructor(message: string, options?: AcpxErrorOptions) {
|
||||
super(message, {
|
||||
outputCode: "RUNTIME",
|
||||
detailCode: "AUTH_REQUIRED",
|
||||
origin: "acp",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueConnectionError extends AcpxOperationalError {}
|
||||
|
||||
export class QueueProtocolError extends AcpxOperationalError {}
|
||||
|
||||
export class PermissionDeniedError extends AcpxOperationalError {}
|
||||
|
||||
export class PermissionPromptUnavailableError extends AcpxOperationalError {
|
||||
constructor() {
|
||||
super("Permission prompt unavailable in non-interactive mode");
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedAcpxPluginConfig } from "../config-schema.js";
|
||||
|
||||
const { clientState } = vi.hoisted(() => ({
|
||||
clientState: {
|
||||
constructorArgs: [] as Array<Record<string, unknown>>,
|
||||
start: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../transport/acp-client.js", () => ({
|
||||
AcpClient: class {
|
||||
initializeResult = { protocolVersion: 1 };
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
clientState.constructorArgs.push(options);
|
||||
}
|
||||
|
||||
start = clientState.start;
|
||||
close = clientState.close;
|
||||
},
|
||||
}));
|
||||
|
||||
import { probeEmbeddedRuntime } from "./probe.js";
|
||||
|
||||
function createConfig(overrides: Partial<ResolvedAcpxPluginConfig> = {}): ResolvedAcpxPluginConfig {
|
||||
return {
|
||||
cwd: "/workspace",
|
||||
stateDir: "/workspace/state",
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
pluginToolsMcpBridge: false,
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
mcpServers: {},
|
||||
agents: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clientState.constructorArgs.length = 0;
|
||||
clientState.start.mockReset();
|
||||
clientState.close.mockReset();
|
||||
});
|
||||
|
||||
describe("probeEmbeddedRuntime", () => {
|
||||
it("probes the default agent instead of the first override entry", async () => {
|
||||
const report = await probeEmbeddedRuntime(
|
||||
createConfig({
|
||||
agents: {
|
||||
claude: "broken-claude-acp",
|
||||
codex: "codex-override --acp",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report.ok).toBe(true);
|
||||
expect(clientState.constructorArgs[0]?.agentCommand).toBe("codex-override --acp");
|
||||
expect(report.details).toContain("agent=codex");
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { DEFAULT_AGENT_NAME, resolveAgentCommand } from "../agents/registry.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
import type { McpServer } from "../runtime-types.js";
|
||||
import { AcpClient } from "../transport/acp-client.js";
|
||||
|
||||
export type RuntimeHealthReport = {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
};
|
||||
|
||||
function toSdkMcpServers(config: ResolvedAcpxPluginConfig): McpServer[] {
|
||||
return Object.entries(config.mcpServers).map(([name, server]) => ({
|
||||
name,
|
||||
command: server.command,
|
||||
args: [...(server.args ?? [])],
|
||||
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||
name: envName,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveProbeAgentName(config: ResolvedAcpxPluginConfig): string {
|
||||
return DEFAULT_AGENT_NAME;
|
||||
}
|
||||
|
||||
export async function probeEmbeddedRuntime(
|
||||
config: ResolvedAcpxPluginConfig,
|
||||
): Promise<RuntimeHealthReport> {
|
||||
const agentName = resolveProbeAgentName(config);
|
||||
const agentCommand = resolveAgentCommand(agentName, config.agents);
|
||||
const client = new AcpClient({
|
||||
agentCommand,
|
||||
cwd: config.cwd,
|
||||
mcpServers: toSdkMcpServers(config),
|
||||
permissionMode: config.permissionMode,
|
||||
nonInteractivePermissions: config.nonInteractivePermissions,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
return {
|
||||
ok: true,
|
||||
message: "embedded ACP runtime ready",
|
||||
details: [
|
||||
`agent=${agentName}`,
|
||||
`command=${agentCommand}`,
|
||||
`cwd=${config.cwd}`,
|
||||
`stateDir=${config.stateDir}`,
|
||||
...(client.initializeResult?.protocolVersion
|
||||
? [`protocolVersion=${client.initializeResult.protocolVersion}`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "embedded ACP runtime probe failed",
|
||||
details: [
|
||||
`agent=${agentName}`,
|
||||
`command=${agentCommand}`,
|
||||
`cwd=${config.cwd}`,
|
||||
`stateDir=${config.stateDir}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,681 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
ContentBlock,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
ToolCall,
|
||||
ToolCallUpdate,
|
||||
UsageUpdate,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { textPrompt } from "../prompt-content.js";
|
||||
import type {
|
||||
ClientOperation,
|
||||
PromptInput,
|
||||
SessionAcpxState,
|
||||
SessionConversation,
|
||||
SessionAgentContent,
|
||||
SessionAgentMessage,
|
||||
SessionMessage,
|
||||
SessionTokenUsage,
|
||||
SessionToolResult,
|
||||
SessionToolResultContent,
|
||||
SessionToolUse,
|
||||
SessionUserContent,
|
||||
} from "../runtime-types.js";
|
||||
|
||||
export type LegacyHistoryEntry = {
|
||||
role: "user" | "assistant";
|
||||
timestamp: string;
|
||||
textPreview: string;
|
||||
};
|
||||
|
||||
const MAX_RUNTIME_MESSAGES = 200;
|
||||
const MAX_RUNTIME_AGENT_TEXT_CHARS = 8_000;
|
||||
const MAX_RUNTIME_THINKING_CHARS = 4_000;
|
||||
const MAX_RUNTIME_TOOL_IO_CHARS = 4_000;
|
||||
const MAX_RUNTIME_REQUEST_TOKEN_USAGE = 100;
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function hasOwn(source: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(source, key);
|
||||
}
|
||||
|
||||
function normalizeAgentName(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function extractText(content: ContentBlock): string | undefined {
|
||||
if (content.type === "text") {
|
||||
return content.text;
|
||||
}
|
||||
|
||||
if (content.type === "resource_link") {
|
||||
return content.title ?? content.name ?? content.uri;
|
||||
}
|
||||
|
||||
if (content.type === "resource") {
|
||||
if ("text" in content.resource && typeof content.resource.text === "string") {
|
||||
return content.resource.text;
|
||||
}
|
||||
return content.resource.uri;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function contentToUserContent(content: ContentBlock): SessionUserContent | undefined {
|
||||
if (content.type === "text") {
|
||||
return {
|
||||
Text: content.text,
|
||||
};
|
||||
}
|
||||
|
||||
if (content.type === "resource_link") {
|
||||
const value = content.title ?? content.name ?? content.uri;
|
||||
return {
|
||||
Mention: {
|
||||
uri: content.uri,
|
||||
content: value,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (content.type === "resource") {
|
||||
if ("text" in content.resource && typeof content.resource.text === "string") {
|
||||
return {
|
||||
Text: content.resource.text,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Mention: {
|
||||
uri: content.resource.uri,
|
||||
content: content.resource.uri,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (content.type === "image") {
|
||||
return {
|
||||
Image: {
|
||||
source: content.data,
|
||||
size: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nextUserMessageId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function isUserMessage(message: SessionMessage): message is {
|
||||
User: SessionConversation["messages"][number] extends infer T
|
||||
? T extends { User: infer U }
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
} {
|
||||
return typeof message === "object" && message !== null && hasOwn(message, "User");
|
||||
}
|
||||
|
||||
function isAgentMessage(message: SessionMessage): message is { Agent: SessionAgentMessage } {
|
||||
return typeof message === "object" && message !== null && hasOwn(message, "Agent");
|
||||
}
|
||||
|
||||
function isAgentTextContent(content: SessionAgentContent): content is { Text: string } {
|
||||
return hasOwn(content, "Text");
|
||||
}
|
||||
|
||||
function isAgentThinkingContent(
|
||||
content: SessionAgentContent,
|
||||
): content is { Thinking: { text: string; signature?: string | null } } {
|
||||
return hasOwn(content, "Thinking");
|
||||
}
|
||||
|
||||
function isAgentToolUseContent(
|
||||
content: SessionAgentContent,
|
||||
): content is { ToolUse: SessionToolUse } {
|
||||
return hasOwn(content, "ToolUse");
|
||||
}
|
||||
|
||||
function updateConversationTimestamp(conversation: SessionConversation, timestamp: string): void {
|
||||
conversation.updated_at = timestamp;
|
||||
}
|
||||
|
||||
function ensureAgentMessage(conversation: SessionConversation): SessionAgentMessage {
|
||||
const last = conversation.messages.at(-1);
|
||||
if (last && isAgentMessage(last)) {
|
||||
return last.Agent;
|
||||
}
|
||||
|
||||
const created: SessionAgentMessage = {
|
||||
content: [],
|
||||
tool_results: {},
|
||||
};
|
||||
conversation.messages.push({ Agent: created });
|
||||
return created;
|
||||
}
|
||||
|
||||
function appendAgentText(agent: SessionAgentMessage, text: string): void {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = agent.content.at(-1);
|
||||
if (last && isAgentTextContent(last)) {
|
||||
last.Text = trimRuntimeText(`${last.Text}${text}`, MAX_RUNTIME_AGENT_TEXT_CHARS);
|
||||
return;
|
||||
}
|
||||
|
||||
const next: SessionAgentContent = {
|
||||
Text: text,
|
||||
};
|
||||
agent.content.push(next);
|
||||
}
|
||||
|
||||
function appendAgentThinking(agent: SessionAgentMessage, text: string): void {
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = agent.content.at(-1);
|
||||
if (last && isAgentThinkingContent(last)) {
|
||||
last.Thinking.text = trimRuntimeText(
|
||||
`${last.Thinking.text}${text}`,
|
||||
MAX_RUNTIME_THINKING_CHARS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const next: SessionAgentContent = {
|
||||
Thinking: {
|
||||
text,
|
||||
signature: null,
|
||||
},
|
||||
};
|
||||
agent.content.push(next);
|
||||
}
|
||||
|
||||
function trimRuntimeText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, Math.max(0, maxChars - 3))}...`;
|
||||
}
|
||||
|
||||
function statusIndicatesComplete(status: unknown): boolean {
|
||||
if (typeof status !== "string") {
|
||||
return false;
|
||||
}
|
||||
const normalized = status.toLowerCase();
|
||||
return (
|
||||
normalized.includes("complete") ||
|
||||
normalized.includes("done") ||
|
||||
normalized.includes("success") ||
|
||||
normalized.includes("failed") ||
|
||||
normalized.includes("error") ||
|
||||
normalized.includes("cancel")
|
||||
);
|
||||
}
|
||||
|
||||
function statusIndicatesError(status: unknown): boolean {
|
||||
if (typeof status !== "string") {
|
||||
return false;
|
||||
}
|
||||
const normalized = status.toLowerCase();
|
||||
return normalized.includes("fail") || normalized.includes("error");
|
||||
}
|
||||
|
||||
function toToolResultContent(value: unknown): SessionToolResultContent {
|
||||
if (typeof value === "string") {
|
||||
return { Text: trimRuntimeText(value, MAX_RUNTIME_TOOL_IO_CHARS) };
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
try {
|
||||
return { Text: trimRuntimeText(JSON.stringify(value), MAX_RUNTIME_TOOL_IO_CHARS) };
|
||||
} catch {
|
||||
return { Text: "[Unserializable value]" };
|
||||
}
|
||||
}
|
||||
|
||||
return { Text: "" };
|
||||
}
|
||||
|
||||
function toRawInput(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return trimRuntimeText(value, MAX_RUNTIME_TOOL_IO_CHARS);
|
||||
}
|
||||
|
||||
try {
|
||||
return trimRuntimeText(JSON.stringify(value ?? {}), MAX_RUNTIME_TOOL_IO_CHARS);
|
||||
} catch {
|
||||
return value == null ? "" : "[Unserializable input]";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureToolUseContent(agent: SessionAgentMessage, toolCallId: string): SessionToolUse {
|
||||
for (const content of agent.content) {
|
||||
if (isAgentToolUseContent(content) && content.ToolUse.id === toolCallId) {
|
||||
return content.ToolUse;
|
||||
}
|
||||
}
|
||||
|
||||
const created: SessionToolUse = {
|
||||
id: toolCallId,
|
||||
name: "tool_call",
|
||||
raw_input: "{}",
|
||||
input: {},
|
||||
is_input_complete: false,
|
||||
thought_signature: null,
|
||||
};
|
||||
agent.content.push({ ToolUse: created });
|
||||
return created;
|
||||
}
|
||||
|
||||
function upsertToolResult(
|
||||
agent: SessionAgentMessage,
|
||||
toolCallId: string,
|
||||
patch: Partial<SessionToolResult>,
|
||||
): void {
|
||||
const existing = agent.tool_results[toolCallId];
|
||||
const next: SessionToolResult = {
|
||||
tool_use_id: toolCallId,
|
||||
tool_name: patch.tool_name ?? existing?.tool_name ?? "tool_call",
|
||||
is_error: patch.is_error ?? existing?.is_error ?? false,
|
||||
content: patch.content ?? existing?.content ?? { Text: "" },
|
||||
output: patch.output ?? existing?.output,
|
||||
};
|
||||
agent.tool_results[toolCallId] = next;
|
||||
}
|
||||
|
||||
function applyToolCallUpdate(agent: SessionAgentMessage, update: ToolCall | ToolCallUpdate): void {
|
||||
const tool = ensureToolUseContent(agent, update.toolCallId);
|
||||
|
||||
if (hasOwn(update, "title")) {
|
||||
tool.name =
|
||||
normalizeAgentName((update as { title?: unknown }).title) ?? tool.name ?? "tool_call";
|
||||
}
|
||||
|
||||
if (hasOwn(update, "kind")) {
|
||||
const kindName = normalizeAgentName((update as { kind?: unknown }).kind);
|
||||
if (!tool.name || tool.name === "tool_call") {
|
||||
tool.name = kindName ?? tool.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(update, "rawInput")) {
|
||||
const rawInput = deepClone((update as { rawInput?: unknown }).rawInput);
|
||||
tool.input = rawInput ?? {};
|
||||
tool.raw_input = toRawInput(rawInput);
|
||||
}
|
||||
|
||||
if (hasOwn(update, "status")) {
|
||||
tool.is_input_complete = statusIndicatesComplete((update as { status?: unknown }).status);
|
||||
}
|
||||
|
||||
if (
|
||||
hasOwn(update, "rawOutput") ||
|
||||
hasOwn(update, "status") ||
|
||||
hasOwn(update, "title") ||
|
||||
hasOwn(update, "kind")
|
||||
) {
|
||||
const status = (update as { status?: unknown }).status;
|
||||
const output = hasOwn(update, "rawOutput")
|
||||
? deepClone((update as { rawOutput?: unknown }).rawOutput)
|
||||
: undefined;
|
||||
|
||||
upsertToolResult(agent, update.toolCallId, {
|
||||
tool_name: tool.name,
|
||||
is_error: statusIndicatesError(status),
|
||||
content: output === undefined ? undefined : toToolResultContent(output),
|
||||
output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function numberField(source: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function usageToTokenUsage(update: UsageUpdate): SessionTokenUsage | undefined {
|
||||
const updateRecord = asRecord(update);
|
||||
const usageMeta = asRecord(updateRecord?._meta)?.usage;
|
||||
const source = asRecord(usageMeta) ?? updateRecord;
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: SessionTokenUsage = {
|
||||
input_tokens: numberField(source, ["input_tokens", "inputTokens"]),
|
||||
output_tokens: numberField(source, ["output_tokens", "outputTokens"]),
|
||||
cache_creation_input_tokens: numberField(source, [
|
||||
"cache_creation_input_tokens",
|
||||
"cacheCreationInputTokens",
|
||||
"cachedWriteTokens",
|
||||
]),
|
||||
cache_read_input_tokens: numberField(source, [
|
||||
"cache_read_input_tokens",
|
||||
"cacheReadInputTokens",
|
||||
"cachedReadTokens",
|
||||
]),
|
||||
};
|
||||
|
||||
if (
|
||||
normalized.input_tokens === undefined &&
|
||||
normalized.output_tokens === undefined &&
|
||||
normalized.cache_creation_input_tokens === undefined &&
|
||||
normalized.cache_read_input_tokens === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState {
|
||||
return state ?? {};
|
||||
}
|
||||
|
||||
function lastUserMessageId(conversation: SessionConversation): string | undefined {
|
||||
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = conversation.messages[index];
|
||||
if (message && isUserMessage(message)) {
|
||||
return message.User.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSessionConversation(timestamp = isoNow()): SessionConversation {
|
||||
return {
|
||||
title: null,
|
||||
messages: [],
|
||||
updated_at: timestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneSessionConversation(
|
||||
conversation: SessionConversation | undefined,
|
||||
): SessionConversation {
|
||||
if (!conversation) {
|
||||
return createSessionConversation();
|
||||
}
|
||||
|
||||
return {
|
||||
title: conversation.title,
|
||||
messages: deepClone(conversation.messages ?? []),
|
||||
updated_at: conversation.updated_at,
|
||||
cumulative_token_usage: deepClone(conversation.cumulative_token_usage ?? {}),
|
||||
request_token_usage: deepClone(conversation.request_token_usage ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneSessionAcpxState(
|
||||
state: SessionAcpxState | undefined,
|
||||
): SessionAcpxState | undefined {
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
current_mode_id: state.current_mode_id,
|
||||
desired_mode_id: state.desired_mode_id,
|
||||
current_model_id: state.current_model_id,
|
||||
available_models: state.available_models ? [...state.available_models] : undefined,
|
||||
available_commands: state.available_commands ? [...state.available_commands] : undefined,
|
||||
config_options: state.config_options ? deepClone(state.config_options) : undefined,
|
||||
session_options: state.session_options
|
||||
? {
|
||||
model: state.session_options.model,
|
||||
allowed_tools: state.session_options.allowed_tools
|
||||
? [...state.session_options.allowed_tools]
|
||||
: undefined,
|
||||
max_turns: state.session_options.max_turns,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendLegacyHistory(
|
||||
conversation: SessionConversation,
|
||||
entries: LegacyHistoryEntry[],
|
||||
): void {
|
||||
for (const entry of entries) {
|
||||
const text = entry.textPreview?.trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.role === "user") {
|
||||
conversation.messages.push({
|
||||
User: {
|
||||
id: nextUserMessageId(),
|
||||
content: [{ Text: text }],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
conversation.messages.push({
|
||||
Agent: {
|
||||
content: [{ Text: text }],
|
||||
tool_results: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateConversationTimestamp(conversation, entry.timestamp || conversation.updated_at);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordPromptSubmission(
|
||||
conversation: SessionConversation,
|
||||
prompt: PromptInput | string,
|
||||
timestamp = isoNow(),
|
||||
): void {
|
||||
const normalizedPrompt = typeof prompt === "string" ? textPrompt(prompt) : prompt;
|
||||
const userContent = normalizedPrompt
|
||||
.map((content) => contentToUserContent(content))
|
||||
.filter((content) => content !== undefined);
|
||||
if (userContent.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversation.messages.push({
|
||||
User: {
|
||||
id: nextUserMessageId(),
|
||||
content: userContent.map((content) => {
|
||||
if ("Text" in content) {
|
||||
return {
|
||||
Text: trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS),
|
||||
};
|
||||
}
|
||||
return content;
|
||||
}),
|
||||
},
|
||||
});
|
||||
updateConversationTimestamp(conversation, timestamp);
|
||||
trimConversationForRuntime(conversation);
|
||||
}
|
||||
|
||||
export function recordSessionUpdate(
|
||||
conversation: SessionConversation,
|
||||
state: SessionAcpxState | undefined,
|
||||
notification: SessionNotification,
|
||||
timestamp = isoNow(),
|
||||
): SessionAcpxState {
|
||||
const acpx = ensureAcpxState(state);
|
||||
|
||||
const update: SessionUpdate = notification.update;
|
||||
switch (update.sessionUpdate) {
|
||||
case "user_message_chunk": {
|
||||
const userContent = contentToUserContent(update.content);
|
||||
if (userContent) {
|
||||
conversation.messages.push({
|
||||
User: {
|
||||
id: nextUserMessageId(),
|
||||
content: [userContent],
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_message_chunk": {
|
||||
const text = extractText(update.content);
|
||||
if (text) {
|
||||
const agent = ensureAgentMessage(conversation);
|
||||
appendAgentText(agent, text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "agent_thought_chunk": {
|
||||
const text = extractText(update.content);
|
||||
if (text) {
|
||||
const agent = ensureAgentMessage(conversation);
|
||||
appendAgentThinking(agent, text);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_call":
|
||||
case "tool_call_update": {
|
||||
const agent = ensureAgentMessage(conversation);
|
||||
applyToolCallUpdate(agent, update);
|
||||
break;
|
||||
}
|
||||
case "usage_update": {
|
||||
const usage = usageToTokenUsage(update);
|
||||
if (usage) {
|
||||
conversation.cumulative_token_usage = usage;
|
||||
const userId = lastUserMessageId(conversation);
|
||||
if (userId) {
|
||||
conversation.request_token_usage[userId] = usage;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session_info_update": {
|
||||
if (hasOwn(update, "title")) {
|
||||
conversation.title = update.title ?? null;
|
||||
}
|
||||
if (hasOwn(update, "updatedAt")) {
|
||||
conversation.updated_at = update.updatedAt ?? conversation.updated_at;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "available_commands_update": {
|
||||
acpx.available_commands = update.availableCommands
|
||||
.map((entry) => entry.name)
|
||||
.filter((entry) => typeof entry === "string" && entry.trim().length > 0);
|
||||
break;
|
||||
}
|
||||
case "current_mode_update": {
|
||||
acpx.current_mode_id = update.currentModeId;
|
||||
break;
|
||||
}
|
||||
case "config_option_update": {
|
||||
acpx.config_options = deepClone(update.configOptions);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
updateConversationTimestamp(conversation, timestamp);
|
||||
trimConversationForRuntime(conversation);
|
||||
return acpx;
|
||||
}
|
||||
|
||||
export function recordClientOperation(
|
||||
conversation: SessionConversation,
|
||||
state: SessionAcpxState | undefined,
|
||||
operation: ClientOperation,
|
||||
timestamp = isoNow(),
|
||||
): SessionAcpxState {
|
||||
const acpx = ensureAcpxState(state);
|
||||
updateConversationTimestamp(conversation, timestamp);
|
||||
trimConversationForRuntime(conversation);
|
||||
return acpx;
|
||||
}
|
||||
|
||||
export function trimConversationForRuntime(conversation: SessionConversation): void {
|
||||
if (conversation.messages.length > MAX_RUNTIME_MESSAGES) {
|
||||
conversation.messages = conversation.messages.slice(-MAX_RUNTIME_MESSAGES);
|
||||
}
|
||||
|
||||
for (const message of conversation.messages) {
|
||||
if (!isAgentMessage(message)) {
|
||||
if (isUserMessage(message)) {
|
||||
message.User.content = message.User.content.map((content) => {
|
||||
if ("Text" in content) {
|
||||
return {
|
||||
Text: trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS),
|
||||
};
|
||||
}
|
||||
return content;
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const content of message.Agent.content) {
|
||||
if ("Text" in content) {
|
||||
content.Text = trimRuntimeText(content.Text, MAX_RUNTIME_AGENT_TEXT_CHARS);
|
||||
} else if ("Thinking" in content) {
|
||||
content.Thinking.text = trimRuntimeText(content.Thinking.text, MAX_RUNTIME_THINKING_CHARS);
|
||||
} else if ("ToolUse" in content) {
|
||||
content.ToolUse.raw_input = trimRuntimeText(
|
||||
content.ToolUse.raw_input,
|
||||
MAX_RUNTIME_TOOL_IO_CHARS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of Object.values(message.Agent.tool_results)) {
|
||||
if ("Text" in result.content) {
|
||||
result.content.Text = trimRuntimeText(result.content.Text, MAX_RUNTIME_TOOL_IO_CHARS);
|
||||
}
|
||||
if (typeof result.output === "string") {
|
||||
result.output = trimRuntimeText(result.output, MAX_RUNTIME_TOOL_IO_CHARS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestUsageEntries = Object.entries(conversation.request_token_usage);
|
||||
if (requestUsageEntries.length > MAX_RUNTIME_REQUEST_TOKEN_USAGE) {
|
||||
conversation.request_token_usage = Object.fromEntries(
|
||||
requestUsageEntries.slice(-MAX_RUNTIME_REQUEST_TOKEN_USAGE),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { z } from "zod";
|
||||
import type { AcpRuntimeEvent, AcpSessionUpdateTag } from "../../runtime-api.js";
|
||||
import {
|
||||
asOptionalBoolean,
|
||||
asOptionalString,
|
||||
asString,
|
||||
asTrimmedString,
|
||||
type AcpxErrorEvent,
|
||||
type AcpxJsonObject,
|
||||
isRecord,
|
||||
} from "./shared.js";
|
||||
|
||||
const AcpxJsonObjectSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const AcpxErrorEventSchema = z.object({
|
||||
type: z.literal("error"),
|
||||
message: z.string().trim().min(1).catch("acpx reported an error"),
|
||||
code: z.string().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export function toAcpxErrorEvent(value: unknown): AcpxErrorEvent | null {
|
||||
const parsed = AcpxErrorEventSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function parseJsonLines(value: string): AcpxJsonObject[] {
|
||||
const events: AcpxJsonObject[] = [];
|
||||
for (const line of value.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
|
||||
if (parsed) {
|
||||
events.push(parsed);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
const parsed = safeParseJsonWithSchema(AcpxJsonObjectSchema, trimmed);
|
||||
if (!parsed) {
|
||||
return {
|
||||
type: "status",
|
||||
text: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
|
||||
export type AcpxHandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
acpxRecordId?: string;
|
||||
backendSessionId?: string;
|
||||
agentSessionId?: string;
|
||||
};
|
||||
|
||||
export type AcpxJsonObject = Record<string, unknown>;
|
||||
|
||||
export type AcpxErrorEvent = {
|
||||
message: string;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function asOptionalString(value: unknown): string | undefined {
|
||||
const text = asTrimmedString(value);
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
export function asOptionalBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
export function deriveAgentFromSessionKey(sessionKey: string, fallbackAgent: string): string {
|
||||
const match = sessionKey.match(/^agent:([^:]+):/i);
|
||||
const candidate = match?.[1] ? asTrimmedString(match[1]) : "";
|
||||
return candidate || fallbackAgent;
|
||||
}
|
||||
|
||||
export function buildPermissionArgs(mode: ResolvedAcpxPluginConfig["permissionMode"]): string[] {
|
||||
if (mode === "approve-all") {
|
||||
return ["--approve-all"];
|
||||
}
|
||||
if (mode === "deny-all") {
|
||||
return ["--deny-all"];
|
||||
}
|
||||
return ["--approve-reads"];
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { PerfMetricsSnapshot } from "./runtime-types.js";
|
||||
|
||||
type TimingBucket = {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
|
||||
const counters = new Map<string, number>();
|
||||
const gauges = new Map<string, number>();
|
||||
const timings = new Map<string, TimingBucket>();
|
||||
|
||||
function hrNow(): bigint {
|
||||
return process.hrtime.bigint();
|
||||
}
|
||||
|
||||
function durationMs(start: bigint): number {
|
||||
return Number(process.hrtime.bigint() - start) / 1_000_000;
|
||||
}
|
||||
|
||||
function roundMetric(value: number): number {
|
||||
return Number(value.toFixed(3));
|
||||
}
|
||||
|
||||
export function incrementPerfCounter(name: string, delta = 1): void {
|
||||
counters.set(name, (counters.get(name) ?? 0) + delta);
|
||||
}
|
||||
|
||||
export function setPerfGauge(name: string, value: number): void {
|
||||
gauges.set(name, value);
|
||||
}
|
||||
|
||||
export function recordPerfDuration(name: string, durationMsValue: number): void {
|
||||
const next = timings.get(name) ?? {
|
||||
count: 0,
|
||||
totalMs: 0,
|
||||
maxMs: 0,
|
||||
};
|
||||
next.count += 1;
|
||||
next.totalMs += durationMsValue;
|
||||
next.maxMs = Math.max(next.maxMs, durationMsValue);
|
||||
timings.set(name, next);
|
||||
}
|
||||
|
||||
export async function measurePerf<T>(name: string, run: () => Promise<T>): Promise<T> {
|
||||
const startedAt = hrNow();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
recordPerfDuration(name, durationMs(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
export function startPerfTimer(name: string): () => number {
|
||||
const startedAt = hrNow();
|
||||
return () => {
|
||||
const elapsedMs = durationMs(startedAt);
|
||||
recordPerfDuration(name, elapsedMs);
|
||||
return elapsedMs;
|
||||
};
|
||||
}
|
||||
|
||||
export function getPerfMetricsSnapshot(): PerfMetricsSnapshot {
|
||||
return {
|
||||
counters: Object.fromEntries(counters.entries()),
|
||||
gauges: Object.fromEntries(gauges.entries()),
|
||||
timings: Object.fromEntries(
|
||||
[...timings.entries()].map(([name, bucket]) => [
|
||||
name,
|
||||
{
|
||||
count: bucket.count,
|
||||
totalMs: roundMetric(bucket.totalMs),
|
||||
maxMs: roundMetric(bucket.maxMs),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetPerfMetrics(): void {
|
||||
counters.clear();
|
||||
gauges.clear();
|
||||
timings.clear();
|
||||
}
|
||||
|
||||
export function formatPerfMetric(name: string, durationMsValue: number): string {
|
||||
return `${name}=${roundMetric(durationMsValue)}ms`;
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk";
|
||||
|
||||
export type PromptInput = ContentBlock[];
|
||||
|
||||
export class PromptInputValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PromptInputValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isBase64Data(value: string): boolean {
|
||||
if (value.length === 0 || value.length % 4 !== 0) {
|
||||
return false;
|
||||
}
|
||||
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
||||
}
|
||||
|
||||
function isImageMimeType(value: string): boolean {
|
||||
return /^image\/[A-Za-z0-9.+-]+$/i.test(value);
|
||||
}
|
||||
|
||||
function isTextBlock(value: unknown): value is Extract<ContentBlock, { type: "text" }> {
|
||||
const record = asRecord(value);
|
||||
return record?.type === "text" && typeof record.text === "string";
|
||||
}
|
||||
|
||||
function isImageBlock(value: unknown): value is Extract<ContentBlock, { type: "image" }> {
|
||||
const record = asRecord(value);
|
||||
return (
|
||||
record?.type === "image" &&
|
||||
isNonEmptyString(record.mimeType) &&
|
||||
isImageMimeType(record.mimeType) &&
|
||||
typeof record.data === "string" &&
|
||||
isBase64Data(record.data)
|
||||
);
|
||||
}
|
||||
|
||||
function isResourceLinkBlock(
|
||||
value: unknown,
|
||||
): value is Extract<ContentBlock, { type: "resource_link" }> {
|
||||
const record = asRecord(value);
|
||||
return (
|
||||
record?.type === "resource_link" &&
|
||||
isNonEmptyString(record.uri) &&
|
||||
(record.title === undefined || typeof record.title === "string") &&
|
||||
(record.name === undefined || typeof record.name === "string")
|
||||
);
|
||||
}
|
||||
|
||||
function isResourcePayload(value: unknown): boolean {
|
||||
const record = asRecord(value);
|
||||
if (!record || !isNonEmptyString(record.uri)) {
|
||||
return false;
|
||||
}
|
||||
return record.text === undefined || typeof record.text === "string";
|
||||
}
|
||||
|
||||
function isResourceBlock(value: unknown): value is Extract<ContentBlock, { type: "resource" }> {
|
||||
const record = asRecord(value);
|
||||
return record?.type === "resource" && isResourcePayload(record.resource);
|
||||
}
|
||||
|
||||
function isContentBlock(value: unknown): value is ContentBlock {
|
||||
return (
|
||||
isTextBlock(value) ||
|
||||
isImageBlock(value) ||
|
||||
isResourceLinkBlock(value) ||
|
||||
isResourceBlock(value)
|
||||
);
|
||||
}
|
||||
|
||||
function getContentBlockValidationError(value: unknown, index: number): string | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record || typeof record.type !== "string") {
|
||||
return `prompt[${index}] must be an ACP content block object`;
|
||||
}
|
||||
|
||||
switch (record.type) {
|
||||
case "text":
|
||||
return typeof record.text === "string"
|
||||
? undefined
|
||||
: `prompt[${index}] text block must include a string text field`;
|
||||
case "image":
|
||||
if (!isNonEmptyString(record.mimeType)) {
|
||||
return `prompt[${index}] image block must include a non-empty mimeType`;
|
||||
}
|
||||
if (!isImageMimeType(record.mimeType)) {
|
||||
return `prompt[${index}] image block mimeType must start with image/`;
|
||||
}
|
||||
if (typeof record.data !== "string" || record.data.length === 0) {
|
||||
return `prompt[${index}] image block must include non-empty base64 data`;
|
||||
}
|
||||
if (!isBase64Data(record.data)) {
|
||||
return `prompt[${index}] image block data must be valid base64`;
|
||||
}
|
||||
return undefined;
|
||||
case "resource_link":
|
||||
if (!isNonEmptyString(record.uri)) {
|
||||
return `prompt[${index}] resource_link block must include a non-empty uri`;
|
||||
}
|
||||
if (record.title !== undefined && typeof record.title !== "string") {
|
||||
return `prompt[${index}] resource_link block title must be a string when present`;
|
||||
}
|
||||
if (record.name !== undefined && typeof record.name !== "string") {
|
||||
return `prompt[${index}] resource_link block name must be a string when present`;
|
||||
}
|
||||
return undefined;
|
||||
case "resource":
|
||||
if (!asRecord(record.resource)) {
|
||||
return `prompt[${index}] resource block must include a resource object`;
|
||||
}
|
||||
if (!isResourcePayload(record.resource)) {
|
||||
return `prompt[${index}] resource block resource must include a non-empty uri and optional text`;
|
||||
}
|
||||
return undefined;
|
||||
default:
|
||||
return `prompt[${index}] has unsupported content block type ${JSON.stringify(record.type)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPromptInput(value: unknown): value is PromptInput {
|
||||
return Array.isArray(value) && value.every((entry) => isContentBlock(entry));
|
||||
}
|
||||
|
||||
export function textPrompt(text: string): PromptInput {
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function parseStructuredPrompt(source: string): PromptInput | undefined {
|
||||
if (!source.startsWith("[")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(source) as unknown;
|
||||
if (isPromptInput(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
const detail =
|
||||
parsed
|
||||
.map((entry, index) => getContentBlockValidationError(entry, index))
|
||||
.find((message) => message !== undefined) ??
|
||||
"Structured prompt JSON must be an array of valid ACP content blocks";
|
||||
throw new PromptInputValidationError(detail);
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof PromptInputValidationError) {
|
||||
throw error;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePromptSource(source: string): PromptInput {
|
||||
const trimmed = source.trim();
|
||||
const structured = parseStructuredPrompt(trimmed);
|
||||
if (structured) {
|
||||
return structured;
|
||||
}
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
return textPrompt(trimmed);
|
||||
}
|
||||
|
||||
export function mergePromptSourceWithText(source: string, suffixText: string): PromptInput {
|
||||
const prompt = parsePromptSource(source);
|
||||
const appended = suffixText.trim();
|
||||
if (!appended) {
|
||||
return prompt;
|
||||
}
|
||||
if (prompt.length === 0) {
|
||||
return textPrompt(appended);
|
||||
}
|
||||
return [...prompt, ...textPrompt(appended)];
|
||||
}
|
||||
|
||||
export function promptToDisplayText(prompt: PromptInput): string {
|
||||
return prompt
|
||||
.map((block) => {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text;
|
||||
case "resource_link":
|
||||
return block.title ?? block.name ?? block.uri;
|
||||
case "resource":
|
||||
return "text" in block.resource && typeof block.resource.text === "string"
|
||||
? block.resource.text
|
||||
: block.resource.uri;
|
||||
case "image":
|
||||
return `[image] ${block.mimeType}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.trim().length > 0)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
AGENT_SESSION_ID_META_KEYS,
|
||||
extractAgentSessionId,
|
||||
normalizeAgentSessionId,
|
||||
} from "./agent-session-id.js";
|
||||
|
||||
export const RUNTIME_SESSION_ID_META_KEYS = AGENT_SESSION_ID_META_KEYS;
|
||||
|
||||
export function normalizeRuntimeSessionId(value: unknown): string | undefined {
|
||||
return normalizeAgentSessionId(value);
|
||||
}
|
||||
|
||||
export function extractRuntimeSessionId(meta: unknown): string | undefined {
|
||||
return extractAgentSessionId(meta);
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
import type {
|
||||
AgentCapabilities,
|
||||
AnyMessage,
|
||||
McpServer,
|
||||
SessionNotification,
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionResponse,
|
||||
StopReason,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
export type { McpServer, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { PromptInput } from "./prompt-content.js";
|
||||
|
||||
export const EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
ERROR: 1,
|
||||
USAGE: 2,
|
||||
TIMEOUT: 3,
|
||||
NO_SESSION: 4,
|
||||
PERMISSION_DENIED: 5,
|
||||
INTERRUPTED: 130,
|
||||
} as const;
|
||||
|
||||
export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES];
|
||||
|
||||
export const OUTPUT_FORMATS = ["text", "json", "quiet"] as const;
|
||||
export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
||||
|
||||
export const PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
export type PermissionMode = (typeof PERMISSION_MODES)[number];
|
||||
|
||||
export const AUTH_POLICIES = ["skip", "fail"] as const;
|
||||
export type AuthPolicy = (typeof AUTH_POLICIES)[number];
|
||||
|
||||
export const NON_INTERACTIVE_PERMISSION_POLICIES = ["deny", "fail"] as const;
|
||||
export type NonInteractivePermissionPolicy = (typeof NON_INTERACTIVE_PERMISSION_POLICIES)[number];
|
||||
|
||||
export const SESSION_RESUME_POLICIES = ["allow-new", "same-session-only"] as const;
|
||||
export type SessionResumePolicy = (typeof SESSION_RESUME_POLICIES)[number];
|
||||
|
||||
export const OUTPUT_STREAMS = ["prompt", "control"] as const;
|
||||
export type OutputStream = (typeof OUTPUT_STREAMS)[number];
|
||||
export type AcpJsonRpcMessage = AnyMessage;
|
||||
export type AcpMessageDirection = "outbound" | "inbound";
|
||||
|
||||
export const OUTPUT_ERROR_CODES = [
|
||||
"NO_SESSION",
|
||||
"TIMEOUT",
|
||||
"PERMISSION_DENIED",
|
||||
"PERMISSION_PROMPT_UNAVAILABLE",
|
||||
"RUNTIME",
|
||||
"USAGE",
|
||||
] as const;
|
||||
export type OutputErrorCode = (typeof OUTPUT_ERROR_CODES)[number];
|
||||
|
||||
export const OUTPUT_ERROR_ORIGINS = ["cli", "runtime", "queue", "acp"] as const;
|
||||
export type OutputErrorOrigin = (typeof OUTPUT_ERROR_ORIGINS)[number];
|
||||
|
||||
export const QUEUE_ERROR_DETAIL_CODES = [
|
||||
"QUEUE_OWNER_CLOSED",
|
||||
"QUEUE_OWNER_SHUTTING_DOWN",
|
||||
"QUEUE_OWNER_OVERLOADED",
|
||||
"QUEUE_OWNER_GENERATION_MISMATCH",
|
||||
"QUEUE_REQUEST_INVALID",
|
||||
"QUEUE_REQUEST_PAYLOAD_INVALID_JSON",
|
||||
"QUEUE_ACK_MISSING",
|
||||
"QUEUE_DISCONNECTED_BEFORE_ACK",
|
||||
"QUEUE_DISCONNECTED_BEFORE_COMPLETION",
|
||||
"QUEUE_PROTOCOL_INVALID_JSON",
|
||||
"QUEUE_PROTOCOL_MALFORMED_MESSAGE",
|
||||
"QUEUE_PROTOCOL_UNEXPECTED_RESPONSE",
|
||||
"QUEUE_NOT_ACCEPTING_REQUESTS",
|
||||
"QUEUE_CONTROL_REQUEST_FAILED",
|
||||
"QUEUE_RUNTIME_PROMPT_FAILED",
|
||||
] as const;
|
||||
export type QueueErrorDetailCode = (typeof QUEUE_ERROR_DETAIL_CODES)[number];
|
||||
|
||||
export type OutputErrorAcpPayload = {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
export type PermissionStats = {
|
||||
requested: number;
|
||||
approved: number;
|
||||
denied: number;
|
||||
cancelled: number;
|
||||
};
|
||||
|
||||
export type ClientOperationMethod =
|
||||
| "fs/read_text_file"
|
||||
| "fs/write_text_file"
|
||||
| "terminal/create"
|
||||
| "terminal/output"
|
||||
| "terminal/wait_for_exit"
|
||||
| "terminal/kill"
|
||||
| "terminal/release";
|
||||
|
||||
export type ClientOperationStatus = "running" | "completed" | "failed";
|
||||
|
||||
export type ClientOperation = {
|
||||
method: ClientOperationMethod;
|
||||
status: ClientOperationStatus;
|
||||
summary: string;
|
||||
details?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type SessionEventLog = {
|
||||
active_path: string;
|
||||
segment_count: number;
|
||||
max_segment_bytes: number;
|
||||
max_segments: number;
|
||||
last_write_at?: string;
|
||||
last_write_error?: string | null;
|
||||
};
|
||||
|
||||
export type PerfMetricSummary = {
|
||||
count: number;
|
||||
totalMs: number;
|
||||
maxMs: number;
|
||||
};
|
||||
|
||||
export type PerfMetricsSnapshot = {
|
||||
counters: Record<string, number>;
|
||||
timings: Record<string, PerfMetricSummary>;
|
||||
gauges: Record<string, number>;
|
||||
};
|
||||
|
||||
export type OutputFormatterContext = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type OutputPolicy = {
|
||||
format: OutputFormat;
|
||||
jsonStrict: boolean;
|
||||
suppressReads: boolean;
|
||||
suppressNonJsonStderr: boolean;
|
||||
queueErrorAlreadyEmitted: boolean;
|
||||
suppressSdkConsoleErrors: boolean;
|
||||
};
|
||||
|
||||
export type OutputErrorEmissionPolicy = {
|
||||
queueErrorAlreadyEmitted: boolean;
|
||||
};
|
||||
|
||||
export interface OutputFormatter {
|
||||
setContext(context: OutputFormatterContext): void;
|
||||
onAcpMessage(message: AcpJsonRpcMessage): void;
|
||||
onError(params: {
|
||||
code: OutputErrorCode;
|
||||
detailCode?: string;
|
||||
origin?: OutputErrorOrigin;
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
acp?: OutputErrorAcpPayload;
|
||||
timestamp?: string;
|
||||
}): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
export type AcpClientOptions = {
|
||||
agentCommand: string;
|
||||
cwd: string;
|
||||
mcpServers?: McpServer[];
|
||||
permissionMode: PermissionMode;
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
authCredentials?: Record<string, string>;
|
||||
authPolicy?: AuthPolicy;
|
||||
suppressSdkConsoleErrors?: boolean;
|
||||
verbose?: boolean;
|
||||
sessionOptions?: {
|
||||
model?: string;
|
||||
allowedTools?: string[];
|
||||
maxTurns?: number;
|
||||
};
|
||||
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
|
||||
onSessionUpdate?: (notification: SessionNotification) => void;
|
||||
onClientOperation?: (operation: ClientOperation) => void;
|
||||
};
|
||||
|
||||
export const SESSION_RECORD_SCHEMA = "openclaw.acpx.session.v1" as const;
|
||||
export type SessionMessageImage = {
|
||||
source: string;
|
||||
size?: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SessionUserContent =
|
||||
| {
|
||||
Text: string;
|
||||
}
|
||||
| {
|
||||
Mention: {
|
||||
uri: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
Image: SessionMessageImage;
|
||||
};
|
||||
|
||||
export type SessionToolUse = {
|
||||
id: string;
|
||||
name: string;
|
||||
raw_input: string;
|
||||
input: unknown;
|
||||
is_input_complete: boolean;
|
||||
thought_signature?: string | null;
|
||||
};
|
||||
|
||||
export type SessionToolResultContent =
|
||||
| {
|
||||
Text: string;
|
||||
}
|
||||
| {
|
||||
Image: SessionMessageImage;
|
||||
};
|
||||
|
||||
export type SessionToolResult = {
|
||||
tool_use_id: string;
|
||||
tool_name: string;
|
||||
is_error: boolean;
|
||||
content: SessionToolResultContent;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
export type SessionAgentContent =
|
||||
| {
|
||||
Text: string;
|
||||
}
|
||||
| {
|
||||
Thinking: {
|
||||
text: string;
|
||||
signature?: string | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
RedactedThinking: string;
|
||||
}
|
||||
| {
|
||||
ToolUse: SessionToolUse;
|
||||
};
|
||||
|
||||
export type SessionUserMessage = {
|
||||
id: string;
|
||||
content: SessionUserContent[];
|
||||
};
|
||||
|
||||
export type SessionAgentMessage = {
|
||||
content: SessionAgentContent[];
|
||||
tool_results: Record<string, SessionToolResult>;
|
||||
reasoning_details?: unknown;
|
||||
};
|
||||
|
||||
export type SessionMessage =
|
||||
| {
|
||||
User: SessionUserMessage;
|
||||
}
|
||||
| {
|
||||
Agent: SessionAgentMessage;
|
||||
}
|
||||
| "Resume";
|
||||
|
||||
export type SessionTokenUsage = {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
};
|
||||
|
||||
export type SessionConversation = {
|
||||
title?: string | null;
|
||||
messages: SessionMessage[];
|
||||
updated_at: string;
|
||||
cumulative_token_usage: SessionTokenUsage;
|
||||
request_token_usage: Record<string, SessionTokenUsage>;
|
||||
};
|
||||
|
||||
export type SessionAcpxState = {
|
||||
current_mode_id?: string;
|
||||
desired_mode_id?: string;
|
||||
current_model_id?: string;
|
||||
available_models?: string[];
|
||||
available_commands?: string[];
|
||||
config_options?: SessionConfigOption[];
|
||||
session_options?: {
|
||||
model?: string;
|
||||
allowed_tools?: string[];
|
||||
max_turns?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionRecord = {
|
||||
schema: typeof SESSION_RECORD_SCHEMA;
|
||||
acpxRecordId: string;
|
||||
acpSessionId: string;
|
||||
agentSessionId?: string;
|
||||
agentCommand: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
lastSeq: number;
|
||||
lastRequestId?: string;
|
||||
eventLog: SessionEventLog;
|
||||
closed?: boolean;
|
||||
closedAt?: string;
|
||||
pid?: number;
|
||||
agentStartedAt?: string;
|
||||
lastPromptAt?: string;
|
||||
lastAgentExitCode?: number | null;
|
||||
lastAgentExitSignal?: NodeJS.Signals | null;
|
||||
lastAgentExitAt?: string;
|
||||
lastAgentDisconnectReason?: string;
|
||||
protocolVersion?: number;
|
||||
agentCapabilities?: AgentCapabilities;
|
||||
title?: string | null;
|
||||
messages: SessionMessage[];
|
||||
updated_at: string;
|
||||
cumulative_token_usage: SessionTokenUsage;
|
||||
request_token_usage: Record<string, SessionTokenUsage>;
|
||||
acpx?: SessionAcpxState;
|
||||
};
|
||||
|
||||
export type RunPromptResult = {
|
||||
stopReason: StopReason;
|
||||
permissionStats: PermissionStats;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type SessionSendResult = RunPromptResult & {
|
||||
record: SessionRecord;
|
||||
resumed: boolean;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type SessionSetModeResult = {
|
||||
record: SessionRecord;
|
||||
resumed: boolean;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type SessionSetConfigOptionResult = {
|
||||
record: SessionRecord;
|
||||
response: SetSessionConfigOptionResponse;
|
||||
resumed: boolean;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type SessionSetModelResult = {
|
||||
record: SessionRecord;
|
||||
resumed: boolean;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
export type SessionEnsureResult = {
|
||||
record: SessionRecord;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type SessionEnqueueResult = {
|
||||
queued: true;
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
export type SessionSendOutcome = SessionSendResult | SessionEnqueueResult;
|
||||
export type { PromptInput };
|
||||
@@ -1,121 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntimeHandle, AcpRuntimeOptions, AcpSessionStore } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { AcpRuntimeErrorMock } = vi.hoisted(() => ({
|
||||
AcpRuntimeErrorMock: class AcpRuntimeError extends Error {
|
||||
code: string;
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
capturedStore: undefined as AcpSessionStore | undefined,
|
||||
};
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "AcpRuntimeError";
|
||||
this.code = code;
|
||||
class MockAcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions) {
|
||||
state.capturedStore = options.sessionStore;
|
||||
}
|
||||
},
|
||||
|
||||
isHealthy() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async probeAvailability() {}
|
||||
|
||||
async doctor() {
|
||||
return { ok: true, message: "ok" };
|
||||
}
|
||||
|
||||
async ensureSession() {
|
||||
return {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
} satisfies AcpRuntimeHandle;
|
||||
}
|
||||
|
||||
async *runTurn() {}
|
||||
|
||||
getCapabilities() {
|
||||
return { controls: [] };
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async setMode() {}
|
||||
|
||||
async setConfigOption() {}
|
||||
|
||||
async cancel() {}
|
||||
|
||||
async close() {}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
MockAcpxRuntime,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("acpx/runtime", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: mocks.MockAcpxRuntime,
|
||||
createAcpRuntime: vi.fn(),
|
||||
createAgentRegistry: vi.fn(),
|
||||
createFileSessionStore: vi.fn(),
|
||||
decodeAcpxRuntimeHandleState: vi.fn(),
|
||||
encodeAcpxRuntimeHandleState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
AcpRuntimeError: AcpRuntimeErrorMock,
|
||||
}));
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
|
||||
import {
|
||||
AcpxRuntime,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
} from "./runtime.js";
|
||||
describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.capturedStore = undefined;
|
||||
});
|
||||
|
||||
describe("AcpxRuntime", () => {
|
||||
it("delegates session lifecycle to the native manager", async () => {
|
||||
const encoded = encodeAcpxRuntimeHandleState({
|
||||
name: "agent:codex:acp:test",
|
||||
agent: "codex",
|
||||
cwd: "/tmp/acpx",
|
||||
mode: "persistent",
|
||||
acpxRecordId: "agent:codex:acp:test",
|
||||
backendSessionId: "sid-1",
|
||||
agentSessionId: "inner-1",
|
||||
});
|
||||
expect(decodeAcpxRuntimeHandleState(encoded)).toEqual({
|
||||
name: "agent:codex:acp:test",
|
||||
agent: "codex",
|
||||
cwd: "/tmp/acpx",
|
||||
mode: "persistent",
|
||||
acpxRecordId: "agent:codex:acp:test",
|
||||
backendSessionId: "sid-1",
|
||||
agentSessionId: "inner-1",
|
||||
});
|
||||
|
||||
const manager = {
|
||||
ensureSession: vi.fn(async () => ({
|
||||
acpxRecordId: "agent:codex:acp:test",
|
||||
acpSessionId: "sid-1",
|
||||
agentSessionId: "inner-1",
|
||||
cwd: "/tmp/acpx",
|
||||
})),
|
||||
runTurn: vi.fn(async function* () {
|
||||
yield { type: "text_delta", text: "hello", stream: "output" };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
}),
|
||||
getStatus: vi.fn(async () => ({
|
||||
summary: "status=ok",
|
||||
acpxRecordId: "agent:codex:acp:test",
|
||||
})),
|
||||
setMode: vi.fn(async () => {}),
|
||||
setConfigOption: vi.fn(async () => {}),
|
||||
cancel: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
||||
const baseStore: AcpSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
cwd: "/tmp/acpx",
|
||||
stateDir: "/tmp/acpx/state",
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
pluginToolsMcpBridge: false,
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
mcpServers: {},
|
||||
agents: {},
|
||||
},
|
||||
{
|
||||
managerFactory: () => manager as never,
|
||||
},
|
||||
);
|
||||
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: "agent:codex:acp:test",
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "default",
|
||||
});
|
||||
expect(handle.acpxRecordId).toBe("agent:codex:acp:test");
|
||||
expect(handle.backendSessionId).toBe("sid-1");
|
||||
expect(handle.agentSessionId).toBe("inner-1");
|
||||
|
||||
const events = [];
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: "hello",
|
||||
mode: "prompt",
|
||||
requestId: "req-1",
|
||||
})) {
|
||||
events.push(event);
|
||||
}
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
|
||||
expect(events).toEqual([
|
||||
{ type: "text_delta", text: "hello", stream: "output" },
|
||||
{ type: "done", stopReason: "end_turn" },
|
||||
]);
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await runtime.getStatus({ handle });
|
||||
await runtime.setMode({ handle, mode: "architect" });
|
||||
await runtime.setConfigOption({ handle, key: "approval", value: "manual" });
|
||||
await runtime.cancel({ handle });
|
||||
await runtime.close({ handle, reason: "test" });
|
||||
await runtime.prepareFreshSession({
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(manager.ensureSession).toHaveBeenCalledOnce();
|
||||
expect(manager.runTurn).toHaveBeenCalledOnce();
|
||||
expect(manager.getStatus).toHaveBeenCalledOnce();
|
||||
expect(manager.setMode).toHaveBeenCalledOnce();
|
||||
expect(manager.setConfigOption).toHaveBeenCalledOnce();
|
||||
expect(manager.cancel).toHaveBeenCalledOnce();
|
||||
expect(manager.close).toHaveBeenCalledOnce();
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore?.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
name: "agent:codex:acp:binding:test",
|
||||
} as never);
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("marks the session fresh after discardPersistentState close", async () => {
|
||||
const baseStore: AcpSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "default",
|
||||
});
|
||||
|
||||
const wrappedStore = mocks.state.capturedStore;
|
||||
expect(wrappedStore).toBeDefined();
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(await wrappedStore?.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,281 +1,142 @@
|
||||
import type {
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntime,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { AcpRuntimeError } from "../runtime-api.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import type { RuntimeHealthReport } from "./health/probe.js";
|
||||
import type { SessionRuntimeManager } from "./session/manager.js";
|
||||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime as BaseAcpxRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
type AcpRuntimeOptions,
|
||||
type AcpRuntimeStatus,
|
||||
type AcpSessionRecord,
|
||||
type AcpSessionStore,
|
||||
} from "acpx/runtime";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
|
||||
export const ACPX_BACKEND_ID = "acpx";
|
||||
|
||||
const ACPX_RUNTIME_HANDLE_PREFIX = "acpx:v2:";
|
||||
const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
|
||||
controls: ["session/set_mode", "session/set_config_option", "session/status"],
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
type AcpxHandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
cwd: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
acpxRecordId?: string;
|
||||
backendSessionId?: string;
|
||||
agentSessionId?: string;
|
||||
function createResetAwareSessionStore(baseStore: AcpSessionStore): ResetAwareSessionStore {
|
||||
const freshSessionKeys = new Set<string>();
|
||||
|
||||
return {
|
||||
async load(sessionId: string): Promise<AcpSessionRecord | undefined> {
|
||||
const normalized = sessionId.trim();
|
||||
if (normalized && freshSessionKeys.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return await baseStore.load(sessionId);
|
||||
},
|
||||
async save(record: AcpSessionRecord): Promise<void> {
|
||||
await baseStore.save(record);
|
||||
const sessionName = record.name.trim();
|
||||
if (sessionName) {
|
||||
freshSessionKeys.delete(sessionName);
|
||||
}
|
||||
},
|
||||
markFresh(sessionKey: string): void {
|
||||
const normalized = sessionKey.trim();
|
||||
if (normalized) {
|
||||
freshSessionKeys.add(normalized);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
};
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function writeHandleState(handle: AcpRuntimeHandle, state: AcpxHandleState): void {
|
||||
handle.runtimeSessionName = encodeAcpxRuntimeHandleState(state);
|
||||
handle.cwd = state.cwd;
|
||||
handle.acpxRecordId = state.acpxRecordId;
|
||||
handle.backendSessionId = state.backendSessionId;
|
||||
handle.agentSessionId = state.agentSessionId;
|
||||
}
|
||||
|
||||
export function encodeAcpxRuntimeHandleState(state: AcpxHandleState): string {
|
||||
const payload = Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
|
||||
return `${ACPX_RUNTIME_HANDLE_PREFIX}${payload}`;
|
||||
}
|
||||
|
||||
export function decodeAcpxRuntimeHandleState(runtimeSessionName: string): AcpxHandleState | null {
|
||||
const trimmed = runtimeSessionName.trim();
|
||||
if (!trimmed.startsWith(ACPX_RUNTIME_HANDLE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = Buffer.from(trimmed.slice(ACPX_RUNTIME_HANDLE_PREFIX.length), "base64url").toString(
|
||||
"utf8",
|
||||
);
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const name = asOptionalString(parsed.name);
|
||||
const agent = asOptionalString(parsed.agent);
|
||||
const cwd = asOptionalString(parsed.cwd);
|
||||
const mode = asOptionalString(parsed.mode);
|
||||
if (!name || !agent || !cwd || (mode !== "persistent" && mode !== "oneshot")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
agent,
|
||||
cwd,
|
||||
mode,
|
||||
acpxRecordId: asOptionalString(parsed.acpxRecordId),
|
||||
backendSessionId: asOptionalString(parsed.backendSessionId),
|
||||
agentSessionId: asOptionalString(parsed.agentSessionId),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class AcpxRuntime implements AcpRuntime {
|
||||
private healthy = false;
|
||||
private manager: SessionRuntimeManager | null = null;
|
||||
private managerPromise: Promise<SessionRuntimeManager> | null = null;
|
||||
export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
|
||||
constructor(
|
||||
private readonly config: ResolvedAcpxPluginConfig,
|
||||
private readonly opts?: {
|
||||
logger?: PluginLogger;
|
||||
managerFactory?: (config: ResolvedAcpxPluginConfig) => SessionRuntimeManager;
|
||||
},
|
||||
) {}
|
||||
options: AcpRuntimeOptions,
|
||||
testOptions?: ConstructorParameters<typeof BaseAcpxRuntime>[1],
|
||||
) {
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore);
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...options,
|
||||
sessionStore: this.sessionStore,
|
||||
},
|
||||
testOptions,
|
||||
);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.healthy;
|
||||
return this.delegate.isHealthy();
|
||||
}
|
||||
|
||||
async probeAvailability(): Promise<void> {
|
||||
const report = await this.runProbe();
|
||||
this.healthy = report.ok;
|
||||
probeAvailability(): Promise<void> {
|
||||
return this.delegate.probeAvailability();
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
const report = await this.runProbe();
|
||||
this.healthy = report.ok;
|
||||
return {
|
||||
ok: report.ok,
|
||||
message: report.message,
|
||||
details: report.details,
|
||||
};
|
||||
doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
return this.delegate.doctor();
|
||||
}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
const sessionName = input.sessionKey.trim();
|
||||
if (!sessionName) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
|
||||
}
|
||||
const agent = input.agent.trim();
|
||||
if (!agent) {
|
||||
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP agent id is required.");
|
||||
}
|
||||
|
||||
const manager = await this.getManager();
|
||||
const record = await manager.ensureSession({
|
||||
sessionKey: sessionName,
|
||||
agent,
|
||||
cwd: input.cwd ?? this.config.cwd,
|
||||
resumeSessionId: input.resumeSessionId,
|
||||
});
|
||||
|
||||
const handle: AcpRuntimeHandle = {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: ACPX_BACKEND_ID,
|
||||
runtimeSessionName: "",
|
||||
cwd: record.cwd,
|
||||
acpxRecordId: record.acpxRecordId,
|
||||
backendSessionId: record.acpSessionId,
|
||||
agentSessionId: record.agentSessionId,
|
||||
};
|
||||
writeHandleState(handle, {
|
||||
name: sessionName,
|
||||
agent,
|
||||
cwd: record.cwd,
|
||||
mode: input.mode,
|
||||
acpxRecordId: record.acpxRecordId,
|
||||
backendSessionId: record.acpSessionId,
|
||||
agentSessionId: record.agentSessionId,
|
||||
});
|
||||
return handle;
|
||||
ensureSession(input: Parameters<AcpRuntime["ensureSession"]>[0]): Promise<AcpRuntimeHandle> {
|
||||
return this.delegate.ensureSession(input);
|
||||
}
|
||||
|
||||
async *runTurn(
|
||||
input: AcpRuntimeTurnInput,
|
||||
): AsyncIterable<import("../runtime-api.js").AcpRuntimeEvent> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
yield* manager.runTurn({
|
||||
handle: {
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
requestId: input.requestId,
|
||||
signal: input.signal,
|
||||
});
|
||||
runTurn(input: Parameters<AcpRuntime["runTurn"]>[0]): AsyncIterable<AcpRuntimeEvent> {
|
||||
return this.delegate.runTurn(input);
|
||||
}
|
||||
|
||||
getCapabilities(): AcpRuntimeCapabilities {
|
||||
return ACPX_CAPABILITIES;
|
||||
getCapabilities(): ReturnType<BaseAcpxRuntime["getCapabilities"]> {
|
||||
return this.delegate.getCapabilities();
|
||||
}
|
||||
|
||||
async getStatus(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<AcpRuntimeStatus> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
return await manager.getStatus({
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
});
|
||||
getStatus(input: Parameters<NonNullable<AcpRuntime["getStatus"]>>[0]): Promise<AcpRuntimeStatus> {
|
||||
return this.delegate.getStatus(input);
|
||||
}
|
||||
|
||||
async setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.setMode(
|
||||
{
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
input.mode,
|
||||
);
|
||||
setMode(input: Parameters<NonNullable<AcpRuntime["setMode"]>>[0]): Promise<void> {
|
||||
return this.delegate.setMode(input);
|
||||
}
|
||||
|
||||
async setConfigOption(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.setConfigOption(
|
||||
{
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
},
|
||||
input.key,
|
||||
input.value,
|
||||
);
|
||||
setConfigOption(input: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]): Promise<void> {
|
||||
return this.delegate.setConfigOption(input);
|
||||
}
|
||||
|
||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.cancel({
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
});
|
||||
cancel(input: Parameters<AcpRuntime["cancel"]>[0]): Promise<void> {
|
||||
return this.delegate.cancel(input);
|
||||
}
|
||||
|
||||
async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
|
||||
const state = this.resolveHandleState(input.handle);
|
||||
const manager = await this.getManager();
|
||||
await manager.close({
|
||||
...input.handle,
|
||||
acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
});
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
private async getManager(): Promise<SessionRuntimeManager> {
|
||||
if (this.manager) {
|
||||
return this.manager;
|
||||
}
|
||||
if (!this.managerPromise) {
|
||||
this.managerPromise = (async () => {
|
||||
const manager =
|
||||
this.opts?.managerFactory?.(this.config) ??
|
||||
new (await import("./session/manager.js")).SessionRuntimeManager(this.config);
|
||||
this.manager = manager;
|
||||
return manager;
|
||||
})();
|
||||
}
|
||||
return await this.managerPromise;
|
||||
}
|
||||
|
||||
private async runProbe(): Promise<RuntimeHealthReport> {
|
||||
return await (await import("./health/probe.js")).probeEmbeddedRuntime(this.config);
|
||||
}
|
||||
|
||||
private resolveHandleState(handle: AcpRuntimeHandle): AcpxHandleState {
|
||||
const decoded = decodeAcpxRuntimeHandleState(handle.runtimeSessionName);
|
||||
if (decoded) {
|
||||
return {
|
||||
...decoded,
|
||||
acpxRecordId: decoded.acpxRecordId ?? handle.acpxRecordId,
|
||||
backendSessionId: decoded.backendSessionId ?? handle.backendSessionId,
|
||||
agentSessionId: decoded.agentSessionId ?? handle.agentSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeSessionName = handle.runtimeSessionName.trim();
|
||||
if (!runtimeSessionName) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
"Invalid embedded ACP runtime handle: runtimeSessionName is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: runtimeSessionName,
|
||||
agent: "codex",
|
||||
cwd: handle.cwd ?? this.config.cwd,
|
||||
mode: "persistent",
|
||||
acpxRecordId: handle.acpxRecordId,
|
||||
backendSessionId: handle.backendSessionId,
|
||||
agentSessionId: handle.agentSessionId,
|
||||
};
|
||||
close(input: Parameters<AcpRuntime["close"]>[0]): Promise<void> {
|
||||
return this.delegate
|
||||
.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
})
|
||||
.then(() => {
|
||||
if (input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ACPX_BACKEND_ID,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
};
|
||||
|
||||
export type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionRecord, AcpSessionStore };
|
||||
|
||||
@@ -102,4 +102,35 @@ describe("createAcpxRuntimeService", () => {
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("warns when legacy compatibility config is explicitly ignored", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 30,
|
||||
strictWindowsCmdWrapper: false,
|
||||
},
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
|
||||
),
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,17 @@ import type {
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { resolveAcpxPluginConfig, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { ACPX_BACKEND_ID, AcpxRuntime } from "./runtime.js";
|
||||
import {
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
} from "./runtime.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
@@ -21,7 +30,6 @@ type AcpxRuntimeLike = AcpRuntime & {
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
queueOwnerTtlSeconds: number;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
@@ -31,11 +39,43 @@ type CreateAcpxRuntimeServiceParams = {
|
||||
};
|
||||
|
||||
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
return new AcpxRuntime(params.pluginConfig, {
|
||||
logger: params.logger,
|
||||
return new AcpxRuntime({
|
||||
cwd: params.pluginConfig.cwd,
|
||||
sessionStore: createFileSessionStore({
|
||||
stateDir: params.pluginConfig.stateDir,
|
||||
}),
|
||||
agentRegistry: createAgentRegistry({
|
||||
overrides: params.pluginConfig.agents,
|
||||
}),
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs:
|
||||
params.pluginConfig.timeoutSeconds != null
|
||||
? params.pluginConfig.timeoutSeconds * 1_000
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function warnOnIgnoredLegacyCompatibilityConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
}): void {
|
||||
const ignoredFields: string[] = [];
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.queueOwnerTtlSeconds != null) {
|
||||
ignoredFields.push("queueOwnerTtlSeconds");
|
||||
}
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.strictWindowsCmdWrapper === false) {
|
||||
ignoredFields.push("strictWindowsCmdWrapper=false");
|
||||
}
|
||||
if (ignoredFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.logger?.warn(
|
||||
`embedded acpx runtime ignores legacy compatibility config: ${ignoredFields.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatDoctorFailureMessage(report: { message: string; details?: string[] }): string {
|
||||
const detailText = report.details?.filter(Boolean).join("; ").trim();
|
||||
return detailText ? `${report.message} (${detailText})` : report.message;
|
||||
@@ -55,11 +95,14 @@ export function createAcpxRuntimeService(
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
warnOnIgnoredLegacyCompatibilityConfig({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
|
||||
runtime = runtimeFactory({
|
||||
pluginConfig,
|
||||
queueOwnerTtlSeconds: pluginConfig.queueOwnerTtlSeconds,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { SessionModelState } from "@agentclientprotocol/sdk";
|
||||
import type { SessionAcpxState, SessionRecord } from "./runtime-types.js";
|
||||
|
||||
function ensureAcpxState(state: SessionAcpxState | undefined): SessionAcpxState {
|
||||
return state ?? {};
|
||||
}
|
||||
|
||||
export function normalizeModeId(modeId: string | undefined): string | undefined {
|
||||
if (typeof modeId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = modeId.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string | undefined {
|
||||
if (typeof modelId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = modelId.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function getDesiredModeId(state: SessionAcpxState | undefined): string | undefined {
|
||||
return normalizeModeId(state?.desired_mode_id);
|
||||
}
|
||||
|
||||
export function setDesiredModeId(record: SessionRecord, modeId: string | undefined): void {
|
||||
const acpx = ensureAcpxState(record.acpx);
|
||||
const normalized = normalizeModeId(modeId);
|
||||
|
||||
if (normalized) {
|
||||
acpx.desired_mode_id = normalized;
|
||||
} else {
|
||||
delete acpx.desired_mode_id;
|
||||
}
|
||||
|
||||
record.acpx = acpx;
|
||||
}
|
||||
|
||||
export function getDesiredModelId(state: SessionAcpxState | undefined): string | undefined {
|
||||
return normalizeModelId(state?.session_options?.model);
|
||||
}
|
||||
|
||||
export function setDesiredModelId(record: SessionRecord, modelId: string | undefined): void {
|
||||
const acpx = ensureAcpxState(record.acpx);
|
||||
const normalized = normalizeModelId(modelId);
|
||||
const sessionOptions = { ...acpx.session_options };
|
||||
|
||||
if (normalized) {
|
||||
sessionOptions.model = normalized;
|
||||
} else {
|
||||
delete sessionOptions.model;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof sessionOptions.model === "string" ||
|
||||
Array.isArray(sessionOptions.allowed_tools) ||
|
||||
typeof sessionOptions.max_turns === "number"
|
||||
) {
|
||||
acpx.session_options = sessionOptions;
|
||||
} else {
|
||||
delete acpx.session_options;
|
||||
}
|
||||
|
||||
record.acpx = acpx;
|
||||
}
|
||||
|
||||
export function setCurrentModelId(record: SessionRecord, modelId: string | undefined): void {
|
||||
const acpx = ensureAcpxState(record.acpx);
|
||||
const normalized = normalizeModelId(modelId);
|
||||
|
||||
if (normalized) {
|
||||
acpx.current_model_id = normalized;
|
||||
} else {
|
||||
delete acpx.current_model_id;
|
||||
}
|
||||
|
||||
record.acpx = acpx;
|
||||
}
|
||||
|
||||
export function syncAdvertisedModelState(
|
||||
record: SessionRecord,
|
||||
models: SessionModelState | undefined,
|
||||
): void {
|
||||
if (!models) {
|
||||
return;
|
||||
}
|
||||
|
||||
const acpx = ensureAcpxState(record.acpx);
|
||||
acpx.current_model_id = models.currentModelId;
|
||||
acpx.available_models = models.availableModels.map((model) => model.modelId);
|
||||
record.acpx = acpx;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
export class TimeoutError extends Error {
|
||||
constructor(timeoutMs: number) {
|
||||
super(`Timed out after ${timeoutMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InterruptedError extends Error {
|
||||
constructor() {
|
||||
super("Interrupted");
|
||||
this.name = "InterruptedError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<T> {
|
||||
if (timeoutMs == null || timeoutMs <= 0) {
|
||||
return await promise;
|
||||
}
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new TimeoutError(timeoutMs));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withInterrupt<T>(
|
||||
run: () => Promise<T>,
|
||||
onInterrupt: () => Promise<void>,
|
||||
): Promise<T> {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (cb: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
process.off("SIGHUP", onSighup);
|
||||
cb();
|
||||
};
|
||||
|
||||
const rejectInterrupted = () => {
|
||||
void onInterrupt().finally(() => {
|
||||
finish(() => reject(new InterruptedError()));
|
||||
});
|
||||
};
|
||||
|
||||
const onSigint = () => {
|
||||
rejectInterrupted();
|
||||
};
|
||||
|
||||
const onSigterm = () => {
|
||||
rejectInterrupted();
|
||||
};
|
||||
|
||||
const onSighup = () => {
|
||||
rejectInterrupted();
|
||||
};
|
||||
|
||||
process.once("SIGINT", onSigint);
|
||||
process.once("SIGTERM", onSigterm);
|
||||
process.once("SIGHUP", onSighup);
|
||||
|
||||
void run().then(
|
||||
(result) => finish(() => resolve(result)),
|
||||
(error) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { normalizeRuntimeSessionId } from "../runtime-session-id.js";
|
||||
import type { SessionConversation, SessionRecord } from "../runtime-types.js";
|
||||
import type { AgentLifecycleSnapshot } from "../transport/acp-client.js";
|
||||
|
||||
export function applyLifecycleSnapshotToRecord(
|
||||
record: SessionRecord,
|
||||
snapshot: AgentLifecycleSnapshot,
|
||||
): void {
|
||||
record.pid = snapshot.pid;
|
||||
record.agentStartedAt = snapshot.startedAt;
|
||||
|
||||
if (snapshot.lastExit) {
|
||||
record.lastAgentExitCode = snapshot.lastExit.exitCode;
|
||||
record.lastAgentExitSignal = snapshot.lastExit.signal;
|
||||
record.lastAgentExitAt = snapshot.lastExit.exitedAt;
|
||||
record.lastAgentDisconnectReason = snapshot.lastExit.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
record.lastAgentExitCode = undefined;
|
||||
record.lastAgentExitSignal = undefined;
|
||||
record.lastAgentExitAt = undefined;
|
||||
record.lastAgentDisconnectReason = undefined;
|
||||
}
|
||||
|
||||
export function reconcileAgentSessionId(
|
||||
record: SessionRecord,
|
||||
agentSessionId: string | undefined,
|
||||
): void {
|
||||
const normalized = normalizeRuntimeSessionId(agentSessionId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
record.agentSessionId = normalized;
|
||||
}
|
||||
|
||||
export function sessionHasAgentMessages(record: SessionRecord): boolean {
|
||||
return record.messages.some(
|
||||
(message) => typeof message === "object" && message !== null && "Agent" in message,
|
||||
);
|
||||
}
|
||||
|
||||
export function applyConversation(record: SessionRecord, conversation: SessionConversation): void {
|
||||
record.title = conversation.title;
|
||||
record.messages = conversation.messages;
|
||||
record.updated_at = conversation.updated_at;
|
||||
record.cumulative_token_usage = conversation.cumulative_token_usage;
|
||||
record.request_token_usage = conversation.request_token_usage;
|
||||
}
|
||||
@@ -1,544 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnAttachment,
|
||||
} from "../../runtime-api.js";
|
||||
import { resolveAgentCommand } from "../agents/registry.js";
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
import { normalizeOutputError } from "../error-normalization.js";
|
||||
import {
|
||||
cloneSessionAcpxState,
|
||||
cloneSessionConversation,
|
||||
createSessionConversation,
|
||||
recordClientOperation,
|
||||
recordPromptSubmission,
|
||||
recordSessionUpdate,
|
||||
trimConversationForRuntime,
|
||||
} from "../history/conversation.js";
|
||||
import { parsePromptEventLine } from "../history/projector.js";
|
||||
import { textPrompt, type PromptInput } from "../prompt-content.js";
|
||||
import type {
|
||||
ClientOperation,
|
||||
McpServer,
|
||||
SessionAcpxState,
|
||||
SessionConversation,
|
||||
SessionRecord,
|
||||
SessionResumePolicy,
|
||||
} from "../runtime-types.js";
|
||||
import { withTimeout } from "../session-runtime-helpers.js";
|
||||
import { AcpClient } from "../transport/acp-client.js";
|
||||
import {
|
||||
applyConversation,
|
||||
applyLifecycleSnapshotToRecord,
|
||||
reconcileAgentSessionId,
|
||||
} from "./lifecycle.js";
|
||||
import { connectAndLoadSession } from "./reconnect.js";
|
||||
import { SessionRepository, SESSION_RECORD_SCHEMA } from "./repository.js";
|
||||
import { shouldReuseExistingRecord } from "./reuse-policy.js";
|
||||
|
||||
type ActiveSessionController = {
|
||||
hasActivePrompt: () => boolean;
|
||||
requestCancelActivePrompt: () => Promise<boolean>;
|
||||
setSessionMode: (modeId: string) => Promise<void>;
|
||||
setSessionConfigOption: (configId: string, value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class AsyncEventQueue {
|
||||
private readonly items: AcpRuntimeEvent[] = [];
|
||||
private readonly waits: Deferred<AcpRuntimeEvent | null>[] = [];
|
||||
private closed = false;
|
||||
|
||||
push(item: AcpRuntimeEvent): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const waiter = this.waits.shift();
|
||||
if (waiter) {
|
||||
waiter.resolve(item);
|
||||
return;
|
||||
}
|
||||
this.items.push(item);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
for (const waiter of this.waits.splice(0)) {
|
||||
waiter.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
async next(): Promise<AcpRuntimeEvent | null> {
|
||||
if (this.items.length > 0) {
|
||||
return this.items.shift() ?? null;
|
||||
}
|
||||
if (this.closed) {
|
||||
return null;
|
||||
}
|
||||
const waiter = createDeferred<AcpRuntimeEvent | null>();
|
||||
this.waits.push(waiter);
|
||||
return await waiter.promise;
|
||||
}
|
||||
|
||||
async *iterate(): AsyncIterable<AcpRuntimeEvent> {
|
||||
while (true) {
|
||||
const next = await this.next();
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
yield next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isoNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toPromptInput(
|
||||
text: string,
|
||||
attachments?: AcpRuntimeTurnAttachment[],
|
||||
): PromptInput | string {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return text;
|
||||
}
|
||||
const blocks: Array<
|
||||
{ type: "text"; text: string } | { type: "image"; mimeType: string; data: string }
|
||||
> = [];
|
||||
if (text) {
|
||||
blocks.push({ type: "text", text });
|
||||
}
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.mediaType.startsWith("image/")) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
mimeType: attachment.mediaType,
|
||||
data: attachment.data,
|
||||
});
|
||||
}
|
||||
}
|
||||
return blocks.length > 0 ? blocks : textPrompt(text);
|
||||
}
|
||||
|
||||
function toSdkMcpServers(config: ResolvedAcpxPluginConfig): McpServer[] {
|
||||
return Object.entries(config.mcpServers).map(([name, server]) => ({
|
||||
name,
|
||||
command: server.command,
|
||||
args: [...(server.args ?? [])],
|
||||
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||
name: envName,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function createInitialRecord(params: {
|
||||
sessionKey: string;
|
||||
sessionId: string;
|
||||
agentCommand: string;
|
||||
cwd: string;
|
||||
agentSessionId?: string;
|
||||
}): SessionRecord {
|
||||
const now = isoNow();
|
||||
return {
|
||||
schema: SESSION_RECORD_SCHEMA,
|
||||
acpxRecordId: params.sessionKey,
|
||||
acpSessionId: params.sessionId,
|
||||
agentSessionId: params.agentSessionId,
|
||||
agentCommand: params.agentCommand,
|
||||
cwd: params.cwd,
|
||||
name: params.sessionKey,
|
||||
createdAt: now,
|
||||
lastUsedAt: now,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: "",
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
last_write_at: undefined,
|
||||
last_write_error: null,
|
||||
},
|
||||
closed: false,
|
||||
closedAt: undefined,
|
||||
...createSessionConversation(now),
|
||||
acpx: {},
|
||||
};
|
||||
}
|
||||
|
||||
function statusSummary(record: SessionRecord): string {
|
||||
const parts = [
|
||||
`session=${record.acpxRecordId}`,
|
||||
`backendSessionId=${record.acpSessionId}`,
|
||||
record.agentSessionId ? `agentSessionId=${record.agentSessionId}` : null,
|
||||
record.pid != null ? `pid=${record.pid}` : null,
|
||||
record.closed ? "closed" : "open",
|
||||
].filter(Boolean);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export class SessionRuntimeManager {
|
||||
private readonly repository: SessionRepository;
|
||||
private readonly activeControllers = new Map<string, ActiveSessionController>();
|
||||
|
||||
constructor(private readonly config: ResolvedAcpxPluginConfig) {
|
||||
this.repository = new SessionRepository(config);
|
||||
}
|
||||
|
||||
async ensureSession(input: {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
cwd?: string;
|
||||
resumeSessionId?: string;
|
||||
}): Promise<SessionRecord> {
|
||||
const cwd = path.resolve(input.cwd?.trim() || this.config.cwd);
|
||||
const agentCommand = resolveAgentCommand(input.agent, this.config.agents);
|
||||
const existing = await this.repository.load(input.sessionKey);
|
||||
if (
|
||||
existing &&
|
||||
shouldReuseExistingRecord(existing, {
|
||||
cwd,
|
||||
agentCommand,
|
||||
resumeSessionId: input.resumeSessionId,
|
||||
})
|
||||
) {
|
||||
existing.closed = false;
|
||||
existing.closedAt = undefined;
|
||||
await this.repository.save(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const client = new AcpClient({
|
||||
agentCommand,
|
||||
cwd,
|
||||
mcpServers: toSdkMcpServers(this.config),
|
||||
permissionMode: this.config.permissionMode,
|
||||
nonInteractivePermissions: this.config.nonInteractivePermissions,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
let sessionId: string;
|
||||
let agentSessionId: string | undefined;
|
||||
if (input.resumeSessionId) {
|
||||
const loaded = await client.loadSession(input.resumeSessionId, cwd);
|
||||
sessionId = input.resumeSessionId;
|
||||
agentSessionId = loaded.agentSessionId;
|
||||
} else {
|
||||
const created = await client.createSession(cwd);
|
||||
sessionId = created.sessionId;
|
||||
agentSessionId = created.agentSessionId;
|
||||
}
|
||||
const record = createInitialRecord({
|
||||
sessionKey: input.sessionKey,
|
||||
sessionId,
|
||||
agentCommand,
|
||||
cwd,
|
||||
agentSessionId,
|
||||
});
|
||||
record.protocolVersion = client.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = client.initializeResult?.agentCapabilities;
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
await this.repository.save(record);
|
||||
return record;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async *runTurn(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
attachments?: AcpRuntimeTurnAttachment[];
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
}): AsyncIterable<AcpRuntimeEvent> {
|
||||
const record = await this.requireRecord(input.handle.acpxRecordId ?? input.handle.sessionKey);
|
||||
const conversation = cloneSessionConversation(record);
|
||||
let acpxState = cloneSessionAcpxState(record.acpx);
|
||||
recordPromptSubmission(conversation, toPromptInput(input.text, input.attachments), isoNow());
|
||||
trimConversationForRuntime(conversation);
|
||||
|
||||
const queue = new AsyncEventQueue();
|
||||
const client = new AcpClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
mcpServers: toSdkMcpServers(this.config),
|
||||
permissionMode: this.config.permissionMode,
|
||||
nonInteractivePermissions: this.config.nonInteractivePermissions,
|
||||
verbose: false,
|
||||
});
|
||||
let activeSessionId = record.acpSessionId;
|
||||
let sawDone = false;
|
||||
const activeController: ActiveSessionController = {
|
||||
hasActivePrompt: () => client.hasActivePrompt(),
|
||||
requestCancelActivePrompt: async () => await client.requestCancelActivePrompt(),
|
||||
setSessionMode: async (modeId: string) => {
|
||||
await client.setSessionMode(activeSessionId, modeId);
|
||||
},
|
||||
setSessionConfigOption: async (configId: string, value: string) => {
|
||||
await client.setSessionConfigOption(activeSessionId, configId, value);
|
||||
},
|
||||
};
|
||||
|
||||
const emitParsed = (payload: Record<string, unknown>): void => {
|
||||
const parsed = parsePromptEventLine(JSON.stringify(payload));
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
if (parsed.type === "done") {
|
||||
sawDone = true;
|
||||
}
|
||||
queue.push(parsed);
|
||||
};
|
||||
|
||||
const abortHandler = () => {
|
||||
void activeController.requestCancelActivePrompt();
|
||||
};
|
||||
if (input.signal) {
|
||||
if (input.signal.aborted) {
|
||||
queue.close();
|
||||
return;
|
||||
}
|
||||
input.signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
this.activeControllers.set(record.acpxRecordId, activeController);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
client.setEventHandlers({
|
||||
onSessionUpdate: (notification) => {
|
||||
acpxState = recordSessionUpdate(conversation, acpxState, notification);
|
||||
trimConversationForRuntime(conversation);
|
||||
emitParsed({
|
||||
jsonrpc: "2.0",
|
||||
method: "session/update",
|
||||
params: notification,
|
||||
});
|
||||
},
|
||||
onClientOperation: (operation: ClientOperation) => {
|
||||
acpxState = recordClientOperation(conversation, acpxState, operation);
|
||||
trimConversationForRuntime(conversation);
|
||||
emitParsed({
|
||||
type: "client_operation",
|
||||
...operation,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { sessionId, resumed, loadError } = await connectAndLoadSession({
|
||||
client,
|
||||
record,
|
||||
resumePolicy: "allow-new" satisfies SessionResumePolicy,
|
||||
timeoutMs: this.timeoutMs,
|
||||
activeController,
|
||||
onClientAvailable: (controller) => {
|
||||
this.activeControllers.set(record.acpxRecordId, controller);
|
||||
},
|
||||
onConnectedRecord: (connectedRecord) => {
|
||||
connectedRecord.lastPromptAt = isoNow();
|
||||
},
|
||||
onSessionIdResolved: (sessionIdValue) => {
|
||||
activeSessionId = sessionIdValue;
|
||||
},
|
||||
});
|
||||
|
||||
record.lastRequestId = input.requestId;
|
||||
record.lastPromptAt = isoNow();
|
||||
record.closed = false;
|
||||
record.closedAt = undefined;
|
||||
record.lastUsedAt = isoNow();
|
||||
if (resumed || loadError) {
|
||||
emitParsed({
|
||||
type: "status",
|
||||
text: loadError ? `load fallback: ${loadError}` : "session resumed",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await withTimeout(
|
||||
client.prompt(sessionId, toPromptInput(input.text, input.attachments)),
|
||||
this.timeoutMs,
|
||||
);
|
||||
|
||||
record.acpSessionId = activeSessionId;
|
||||
reconcileAgentSessionId(record, record.agentSessionId);
|
||||
record.protocolVersion = client.initializeResult?.protocolVersion;
|
||||
record.agentCapabilities = client.initializeResult?.agentCapabilities;
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
await this.repository.save(record);
|
||||
|
||||
if (!sawDone) {
|
||||
queue.push({
|
||||
type: "done",
|
||||
stopReason: response.stopReason,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeOutputError(error, { origin: "runtime" });
|
||||
queue.push({
|
||||
type: "error",
|
||||
message: normalized.message,
|
||||
code: normalized.code,
|
||||
retryable: normalized.retryable,
|
||||
});
|
||||
} finally {
|
||||
if (input.signal) {
|
||||
input.signal.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
this.activeControllers.delete(record.acpxRecordId);
|
||||
client.clearEventHandlers();
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
record.acpx = acpxState;
|
||||
applyConversation(record, conversation);
|
||||
record.lastUsedAt = isoNow();
|
||||
await this.repository.save(record).catch(() => {});
|
||||
await client.close().catch(() => {});
|
||||
queue.close();
|
||||
}
|
||||
})();
|
||||
|
||||
yield* queue.iterate();
|
||||
}
|
||||
|
||||
async getStatus(handle: AcpRuntimeHandle): Promise<AcpRuntimeStatus> {
|
||||
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
|
||||
return {
|
||||
summary: statusSummary(record),
|
||||
acpxRecordId: record.acpxRecordId,
|
||||
backendSessionId: record.acpSessionId,
|
||||
agentSessionId: record.agentSessionId,
|
||||
details: {
|
||||
cwd: record.cwd,
|
||||
lastUsedAt: record.lastUsedAt,
|
||||
closed: record.closed === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(handle: AcpRuntimeHandle, mode: string): Promise<void> {
|
||||
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
|
||||
const controller = this.activeControllers.get(record.acpxRecordId);
|
||||
if (controller) {
|
||||
await controller.setSessionMode(mode);
|
||||
} else {
|
||||
const client = new AcpClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
mcpServers: toSdkMcpServers(this.config),
|
||||
permissionMode: this.config.permissionMode,
|
||||
nonInteractivePermissions: this.config.nonInteractivePermissions,
|
||||
verbose: false,
|
||||
});
|
||||
try {
|
||||
await client.start();
|
||||
const { sessionId } = await connectAndLoadSession({
|
||||
client,
|
||||
record,
|
||||
timeoutMs: this.timeoutMs,
|
||||
activeController: {
|
||||
hasActivePrompt: () => false,
|
||||
requestCancelActivePrompt: async () => false,
|
||||
setSessionMode: async () => {},
|
||||
setSessionConfigOption: async () => {},
|
||||
},
|
||||
});
|
||||
await client.setSessionMode(sessionId, mode);
|
||||
} finally {
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
await this.repository.save(record).catch(() => {});
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
record.acpx = {
|
||||
...(record.acpx ?? ({} as SessionAcpxState)),
|
||||
desired_mode_id: mode,
|
||||
};
|
||||
await this.repository.save(record);
|
||||
}
|
||||
|
||||
async setConfigOption(handle: AcpRuntimeHandle, key: string, value: string): Promise<void> {
|
||||
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
|
||||
const controller = this.activeControllers.get(record.acpxRecordId);
|
||||
if (controller) {
|
||||
await controller.setSessionConfigOption(key, value);
|
||||
} else {
|
||||
const client = new AcpClient({
|
||||
agentCommand: record.agentCommand,
|
||||
cwd: record.cwd,
|
||||
mcpServers: toSdkMcpServers(this.config),
|
||||
permissionMode: this.config.permissionMode,
|
||||
nonInteractivePermissions: this.config.nonInteractivePermissions,
|
||||
verbose: false,
|
||||
});
|
||||
try {
|
||||
await client.start();
|
||||
const { sessionId } = await connectAndLoadSession({
|
||||
client,
|
||||
record,
|
||||
timeoutMs: this.timeoutMs,
|
||||
activeController: {
|
||||
hasActivePrompt: () => false,
|
||||
requestCancelActivePrompt: async () => false,
|
||||
setSessionMode: async () => {},
|
||||
setSessionConfigOption: async () => {},
|
||||
},
|
||||
});
|
||||
await client.setSessionConfigOption(sessionId, key, value);
|
||||
} finally {
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
await this.repository.save(record).catch(() => {});
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
await this.repository.save(record);
|
||||
}
|
||||
|
||||
async cancel(handle: AcpRuntimeHandle): Promise<void> {
|
||||
const controller = this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey);
|
||||
await controller?.requestCancelActivePrompt();
|
||||
}
|
||||
|
||||
async close(handle: AcpRuntimeHandle): Promise<void> {
|
||||
const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey);
|
||||
await this.cancel(handle);
|
||||
record.closed = true;
|
||||
record.closedAt = isoNow();
|
||||
await this.repository.save(record);
|
||||
}
|
||||
|
||||
private get timeoutMs(): number | undefined {
|
||||
return this.config.timeoutSeconds != null ? this.config.timeoutSeconds * 1_000 : undefined;
|
||||
}
|
||||
|
||||
private async requireRecord(sessionId: string): Promise<SessionRecord> {
|
||||
const record = await this.repository.load(sessionId);
|
||||
if (!record) {
|
||||
throw new Error(`ACP session not found: ${sessionId}`);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
extractAcpError,
|
||||
formatErrorMessage,
|
||||
isAcpQueryClosedBeforeResponseError,
|
||||
isAcpResourceNotFoundError,
|
||||
} from "../error-normalization.js";
|
||||
import {
|
||||
SessionModeReplayError,
|
||||
SessionModelReplayError,
|
||||
SessionResumeRequiredError,
|
||||
} from "../errors.js";
|
||||
import { incrementPerfCounter } from "../perf-metrics.js";
|
||||
import type { SessionRecord, SessionResumePolicy } from "../runtime-types.js";
|
||||
import {
|
||||
getDesiredModeId,
|
||||
getDesiredModelId,
|
||||
setCurrentModelId,
|
||||
syncAdvertisedModelState,
|
||||
} from "../session-mode-preference.js";
|
||||
import { InterruptedError, TimeoutError, withTimeout } from "../session-runtime-helpers.js";
|
||||
import type { AcpClient } from "../transport/acp-client.js";
|
||||
import {
|
||||
applyLifecycleSnapshotToRecord,
|
||||
reconcileAgentSessionId,
|
||||
sessionHasAgentMessages,
|
||||
} from "./lifecycle.js";
|
||||
|
||||
type QueueOwnerActiveSessionController = {
|
||||
hasActivePrompt: () => boolean;
|
||||
requestCancelActivePrompt: () => Promise<boolean>;
|
||||
setSessionMode: (modeId: string) => Promise<void>;
|
||||
setSessionConfigOption: (configId: string, value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function isProcessAlive(pid: number | undefined): boolean {
|
||||
if (!pid || !Number.isInteger(pid) || pid <= 0 || pid === process.pid) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectAndLoadSessionOptions = {
|
||||
client: AcpClient;
|
||||
record: SessionRecord;
|
||||
resumePolicy?: SessionResumePolicy;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
activeController: QueueOwnerActiveSessionController;
|
||||
onClientAvailable?: (controller: QueueOwnerActiveSessionController) => void;
|
||||
onConnectedRecord?: (record: SessionRecord) => void;
|
||||
onSessionIdResolved?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
export type ConnectAndLoadSessionResult = {
|
||||
sessionId: string;
|
||||
agentSessionId?: string;
|
||||
resumed: boolean;
|
||||
loadError?: string;
|
||||
};
|
||||
|
||||
// JSON-RPC codes that indicate the agent does not support session/load.
|
||||
// -32601 = Method not found, -32602 = Invalid params.
|
||||
const SESSION_LOAD_UNSUPPORTED_CODES = new Set([-32601, -32602]);
|
||||
|
||||
function shouldFallbackToNewSession(error: unknown, record: SessionRecord): boolean {
|
||||
if (error instanceof TimeoutError || error instanceof InterruptedError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAcpResourceNotFoundError(error)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const acp = extractAcpError(error);
|
||||
if (acp && SESSION_LOAD_UNSUPPORTED_CODES.has(acp.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Some adapters return JSON-RPC internal errors when trying to
|
||||
// load sessions that have never produced an agent turn yet.
|
||||
if (!sessionHasAgentMessages(record)) {
|
||||
if (isAcpQueryClosedBeforeResponseError(error)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acp?.code === -32603) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function requiresSameSession(resumePolicy: SessionResumePolicy | undefined): boolean {
|
||||
return resumePolicy === "same-session-only";
|
||||
}
|
||||
|
||||
function makeSessionResumeRequiredError(params: {
|
||||
record: SessionRecord;
|
||||
reason: string;
|
||||
cause?: unknown;
|
||||
}): SessionResumeRequiredError {
|
||||
return new SessionResumeRequiredError(
|
||||
`Persistent ACP session ${params.record.acpSessionId} could not be resumed: ${params.reason}`,
|
||||
{
|
||||
cause: params.cause instanceof Error ? params.cause : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function connectAndLoadSession(
|
||||
options: ConnectAndLoadSessionOptions,
|
||||
): Promise<ConnectAndLoadSessionResult> {
|
||||
const record = options.record;
|
||||
const client = options.client;
|
||||
const sameSessionOnly = requiresSameSession(options.resumePolicy);
|
||||
const originalSessionId = record.acpSessionId;
|
||||
const originalAgentSessionId = record.agentSessionId;
|
||||
const desiredModeId = getDesiredModeId(record.acpx);
|
||||
const desiredModelId = getDesiredModelId(record.acpx);
|
||||
const storedProcessAlive = isProcessAlive(record.pid);
|
||||
const shouldReconnect = Boolean(record.pid) && !storedProcessAlive;
|
||||
|
||||
if (options.verbose) {
|
||||
if (storedProcessAlive) {
|
||||
process.stderr.write(
|
||||
`[acpx] saved session pid ${record.pid} is running; reconnecting with loadSession\n`,
|
||||
);
|
||||
} else if (shouldReconnect) {
|
||||
process.stderr.write(
|
||||
`[acpx] saved session pid ${record.pid} is dead; respawning agent and attempting session/load\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reusingLoadedSession = client.hasReusableSession(record.acpSessionId);
|
||||
if (reusingLoadedSession) {
|
||||
incrementPerfCounter("runtime.connect_and_load.reused_session");
|
||||
} else {
|
||||
await withTimeout(client.start(), options.timeoutMs);
|
||||
}
|
||||
options.onClientAvailable?.(options.activeController);
|
||||
applyLifecycleSnapshotToRecord(record, client.getAgentLifecycleSnapshot());
|
||||
record.closed = false;
|
||||
record.closedAt = undefined;
|
||||
options.onConnectedRecord?.(record);
|
||||
|
||||
let resumed = false;
|
||||
let loadError: string | undefined;
|
||||
let sessionId = record.acpSessionId;
|
||||
let createdFreshSession = false;
|
||||
let pendingAgentSessionId = record.agentSessionId;
|
||||
let sessionModels: import("../transport/acp-client.js").SessionLoadResult["models"];
|
||||
|
||||
if (reusingLoadedSession) {
|
||||
resumed = true;
|
||||
} else if (client.supportsLoadSession()) {
|
||||
try {
|
||||
const loadResult = await withTimeout(
|
||||
client.loadSessionWithOptions(record.acpSessionId, record.cwd, {
|
||||
suppressReplayUpdates: true,
|
||||
}),
|
||||
options.timeoutMs,
|
||||
);
|
||||
reconcileAgentSessionId(record, loadResult.agentSessionId);
|
||||
sessionModels = loadResult.models;
|
||||
resumed = true;
|
||||
} catch (error) {
|
||||
loadError = formatErrorMessage(error);
|
||||
if (sameSessionOnly) {
|
||||
throw makeSessionResumeRequiredError({
|
||||
record,
|
||||
reason: loadError,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
if (!shouldFallbackToNewSession(error, record)) {
|
||||
throw error;
|
||||
}
|
||||
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
|
||||
sessionId = createdSession.sessionId;
|
||||
createdFreshSession = true;
|
||||
pendingAgentSessionId = createdSession.agentSessionId;
|
||||
sessionModels = createdSession.models;
|
||||
}
|
||||
} else {
|
||||
if (sameSessionOnly) {
|
||||
throw makeSessionResumeRequiredError({
|
||||
record,
|
||||
reason: "agent does not support session/load",
|
||||
});
|
||||
}
|
||||
const createdSession = await withTimeout(client.createSession(record.cwd), options.timeoutMs);
|
||||
sessionId = createdSession.sessionId;
|
||||
createdFreshSession = true;
|
||||
pendingAgentSessionId = createdSession.agentSessionId;
|
||||
sessionModels = createdSession.models;
|
||||
}
|
||||
|
||||
if (createdFreshSession && desiredModeId) {
|
||||
try {
|
||||
await withTimeout(client.setSessionMode(sessionId, desiredModeId), options.timeoutMs);
|
||||
if (options.verbose) {
|
||||
process.stderr.write(
|
||||
`[acpx] replayed desired mode ${desiredModeId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Failed to replay saved session mode ${desiredModeId} on fresh ACP session ${sessionId}: ` +
|
||||
formatErrorMessage(error);
|
||||
record.acpSessionId = originalSessionId;
|
||||
record.agentSessionId = originalAgentSessionId;
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[acpx] ${message}\n`);
|
||||
}
|
||||
throw new SessionModeReplayError(message, {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
createdFreshSession &&
|
||||
desiredModelId &&
|
||||
sessionModels &&
|
||||
desiredModelId !== sessionModels.currentModelId
|
||||
) {
|
||||
try {
|
||||
await withTimeout(client.setSessionModel(sessionId, desiredModelId), options.timeoutMs);
|
||||
setCurrentModelId(record, desiredModelId);
|
||||
if (options.verbose) {
|
||||
process.stderr.write(
|
||||
`[acpx] replayed desired model ${desiredModelId} on fresh ACP session ${sessionId} (previous ${originalSessionId})\n`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
`Failed to replay saved session model ${desiredModelId} on fresh ACP session ${sessionId}: ` +
|
||||
formatErrorMessage(error);
|
||||
record.acpSessionId = originalSessionId;
|
||||
record.agentSessionId = originalAgentSessionId;
|
||||
if (options.verbose) {
|
||||
process.stderr.write(`[acpx] ${message}\n`);
|
||||
}
|
||||
throw new SessionModelReplayError(message, {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (createdFreshSession) {
|
||||
record.acpSessionId = sessionId;
|
||||
reconcileAgentSessionId(record, pendingAgentSessionId);
|
||||
}
|
||||
|
||||
syncAdvertisedModelState(record, sessionModels);
|
||||
if (createdFreshSession && desiredModelId && sessionModels) {
|
||||
setCurrentModelId(record, desiredModelId);
|
||||
}
|
||||
|
||||
options.onSessionIdResolved?.(sessionId);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
agentSessionId: record.agentSessionId,
|
||||
resumed,
|
||||
loadError,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ResolvedAcpxPluginConfig } from "../config.js";
|
||||
import type { SessionRecord } from "../runtime-types.js";
|
||||
|
||||
export const SESSION_RECORD_SCHEMA = "openclaw.acpx.session.v1" as const;
|
||||
|
||||
function safeSessionId(sessionId: string): string {
|
||||
return encodeURIComponent(sessionId);
|
||||
}
|
||||
|
||||
export class SessionRepository {
|
||||
constructor(private readonly config: ResolvedAcpxPluginConfig) {}
|
||||
|
||||
get sessionDir(): string {
|
||||
return path.join(this.config.stateDir, "sessions");
|
||||
}
|
||||
|
||||
async ensureDir(): Promise<void> {
|
||||
await fs.mkdir(this.sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
filePath(sessionId: string): string {
|
||||
return path.join(this.sessionDir, `${safeSessionId(sessionId)}.json`);
|
||||
}
|
||||
|
||||
async load(sessionId: string): Promise<SessionRecord | null> {
|
||||
try {
|
||||
const payload = await fs.readFile(this.filePath(sessionId), "utf8");
|
||||
return JSON.parse(payload) as SessionRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async save(record: SessionRecord): Promise<void> {
|
||||
await this.ensureDir();
|
||||
const target = this.filePath(record.acpxRecordId);
|
||||
const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
await fs.writeFile(temp, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
||||
await fs.rename(temp, target);
|
||||
}
|
||||
|
||||
async close(sessionId: string): Promise<SessionRecord | null> {
|
||||
const record = await this.load(sessionId);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
record.closed = true;
|
||||
record.closedAt = new Date().toISOString();
|
||||
await this.save(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldReuseExistingRecord } from "./reuse-policy.js";
|
||||
|
||||
describe("shouldReuseExistingRecord", () => {
|
||||
it("rejects reuse when the requested cwd changes", () => {
|
||||
expect(
|
||||
shouldReuseExistingRecord(
|
||||
{
|
||||
cwd: "/workspace/one",
|
||||
agentCommand: "codex --acp",
|
||||
acpSessionId: "sid-1",
|
||||
},
|
||||
{
|
||||
cwd: "/workspace/two",
|
||||
agentCommand: "codex --acp",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reuse when a persisted backend session id changed", () => {
|
||||
expect(
|
||||
shouldReuseExistingRecord(
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "codex --acp",
|
||||
acpSessionId: "sid-1",
|
||||
},
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "codex --acp",
|
||||
resumeSessionId: "sid-2",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects reuse when the resolved agent command changes", () => {
|
||||
expect(
|
||||
shouldReuseExistingRecord(
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "codex --acp",
|
||||
acpSessionId: "sid-1",
|
||||
},
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "custom-codex --acp",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps reuse enabled only for compatible records", () => {
|
||||
expect(
|
||||
shouldReuseExistingRecord(
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "codex --acp",
|
||||
acpSessionId: "sid-1",
|
||||
},
|
||||
{
|
||||
cwd: "/workspace",
|
||||
agentCommand: "codex --acp",
|
||||
resumeSessionId: "sid-1",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import path from "node:path";
|
||||
import type { SessionRecord } from "../runtime-types.js";
|
||||
|
||||
export function shouldReuseExistingRecord(
|
||||
record: Pick<SessionRecord, "cwd" | "agentCommand" | "acpSessionId">,
|
||||
params: {
|
||||
cwd: string;
|
||||
agentCommand: string;
|
||||
resumeSessionId?: string;
|
||||
},
|
||||
): boolean {
|
||||
if (path.resolve(record.cwd) !== path.resolve(params.cwd)) {
|
||||
return false;
|
||||
}
|
||||
if (record.agentCommand !== params.agentCommand) {
|
||||
return false;
|
||||
}
|
||||
if (params.resumeSessionId && record.acpSessionId !== params.resumeSessionId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PermissionDeniedError, PermissionPromptUnavailableError } from "../errors.js";
|
||||
import type {
|
||||
ClientOperation,
|
||||
NonInteractivePermissionPolicy,
|
||||
PermissionMode,
|
||||
} from "../runtime-types.js";
|
||||
import { promptForPermission } from "./permission-prompt.js";
|
||||
|
||||
const WRITE_PREVIEW_MAX_LINES = 16;
|
||||
const WRITE_PREVIEW_MAX_CHARS = 1_200;
|
||||
|
||||
export type FileSystemHandlersOptions = {
|
||||
cwd: string;
|
||||
permissionMode: PermissionMode;
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
onOperation?: (operation: ClientOperation) => void;
|
||||
confirmWrite?: (filePath: string, preview: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function isWithinRoot(rootDir: string, targetPath: string): boolean {
|
||||
const relative = path.relative(rootDir, targetPath);
|
||||
return relative.length === 0 || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function toWritePreview(content: string): string {
|
||||
const normalized = content.replace(/\r\n/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
const visibleLines = lines.slice(0, WRITE_PREVIEW_MAX_LINES);
|
||||
let preview = visibleLines.join("\n");
|
||||
|
||||
if (lines.length > visibleLines.length) {
|
||||
preview += `\n... (${lines.length - visibleLines.length} more lines)`;
|
||||
}
|
||||
|
||||
if (preview.length > WRITE_PREVIEW_MAX_CHARS) {
|
||||
preview = `${preview.slice(0, WRITE_PREVIEW_MAX_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function defaultConfirmWrite(filePath: string, preview: string): Promise<boolean> {
|
||||
return await promptForPermission({
|
||||
header: `[permission] Allow write to ${filePath}?`,
|
||||
details: preview,
|
||||
prompt: "Allow write? (y/N) ",
|
||||
});
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
}
|
||||
|
||||
export class FileSystemHandlers {
|
||||
private readonly rootDir: string;
|
||||
private permissionMode: PermissionMode;
|
||||
private nonInteractivePermissions: NonInteractivePermissionPolicy;
|
||||
private readonly onOperation?: (operation: ClientOperation) => void;
|
||||
private readonly usesDefaultConfirmWrite: boolean;
|
||||
private readonly confirmWrite: (filePath: string, preview: string) => Promise<boolean>;
|
||||
|
||||
constructor(options: FileSystemHandlersOptions) {
|
||||
this.rootDir = path.resolve(options.cwd);
|
||||
this.permissionMode = options.permissionMode;
|
||||
this.nonInteractivePermissions = options.nonInteractivePermissions ?? "deny";
|
||||
this.onOperation = options.onOperation;
|
||||
this.usesDefaultConfirmWrite = options.confirmWrite == null;
|
||||
this.confirmWrite = options.confirmWrite ?? defaultConfirmWrite;
|
||||
}
|
||||
|
||||
updatePermissionPolicy(
|
||||
permissionMode: PermissionMode,
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy,
|
||||
): void {
|
||||
this.permissionMode = permissionMode;
|
||||
this.nonInteractivePermissions = nonInteractivePermissions ?? "deny";
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const filePath = this.resolvePathWithinRoot(params.path);
|
||||
const summary = `read_text_file: ${filePath}`;
|
||||
this.emitOperation({
|
||||
method: "fs/read_text_file",
|
||||
status: "running",
|
||||
summary,
|
||||
details: this.readWindowDetails(params.line, params.limit),
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.permissionMode === "deny-all") {
|
||||
throw new PermissionDeniedError("Permission denied for fs/read_text_file (--deny-all)");
|
||||
}
|
||||
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const sliced = this.sliceContent(content, params.line, params.limit);
|
||||
|
||||
this.emitOperation({
|
||||
method: "fs/read_text_file",
|
||||
status: "completed",
|
||||
summary,
|
||||
details: this.readWindowDetails(params.line, params.limit),
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return { content: sliced };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitOperation({
|
||||
method: "fs/read_text_file",
|
||||
status: "failed",
|
||||
summary,
|
||||
details: message,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
const filePath = this.resolvePathWithinRoot(params.path);
|
||||
const preview = toWritePreview(params.content);
|
||||
const summary = `write_text_file: ${filePath}`;
|
||||
|
||||
this.emitOperation({
|
||||
method: "fs/write_text_file",
|
||||
status: "running",
|
||||
summary,
|
||||
details: preview,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (!(await this.isWriteApproved(filePath, preview))) {
|
||||
throw new PermissionDeniedError("Permission denied for fs/write_text_file");
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, params.content, "utf8");
|
||||
|
||||
this.emitOperation({
|
||||
method: "fs/write_text_file",
|
||||
status: "completed",
|
||||
summary,
|
||||
details: preview,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitOperation({
|
||||
method: "fs/write_text_file",
|
||||
status: "failed",
|
||||
summary,
|
||||
details: message,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async isWriteApproved(filePath: string, preview: string): Promise<boolean> {
|
||||
if (this.permissionMode === "approve-all") {
|
||||
return true;
|
||||
}
|
||||
if (this.permissionMode === "deny-all") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.usesDefaultConfirmWrite &&
|
||||
this.nonInteractivePermissions === "fail" &&
|
||||
!canPromptForPermission()
|
||||
) {
|
||||
throw new PermissionPromptUnavailableError();
|
||||
}
|
||||
return await this.confirmWrite(filePath, preview);
|
||||
}
|
||||
|
||||
private resolvePathWithinRoot(rawPath: string): string {
|
||||
if (!path.isAbsolute(rawPath)) {
|
||||
throw new Error(`Path must be absolute: ${rawPath}`);
|
||||
}
|
||||
const resolved = path.resolve(rawPath);
|
||||
if (!isWithinRoot(this.rootDir, resolved)) {
|
||||
throw new Error(`Path is outside allowed cwd subtree: ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private sliceContent(
|
||||
content: string,
|
||||
line: number | null | undefined,
|
||||
limit: number | null | undefined,
|
||||
): string {
|
||||
if (line == null && limit == null) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const startLine = line == null ? 1 : Math.max(1, Math.trunc(line));
|
||||
const startIndex = Math.max(0, startLine - 1);
|
||||
const maxLines = limit == null ? undefined : Math.max(0, Math.trunc(limit));
|
||||
|
||||
if (maxLines === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const endIndex =
|
||||
maxLines == null ? lines.length : Math.min(lines.length, startIndex + maxLines);
|
||||
|
||||
return lines.slice(startIndex, endIndex).join("\n");
|
||||
}
|
||||
|
||||
private readWindowDetails(
|
||||
line: number | null | undefined,
|
||||
limit: number | null | undefined,
|
||||
): string | undefined {
|
||||
if (line == null && limit == null) {
|
||||
return undefined;
|
||||
}
|
||||
const start = line == null ? 1 : Math.max(1, Math.trunc(line));
|
||||
const max = limit == null ? "all" : Math.max(0, Math.trunc(limit));
|
||||
return `line=${start}, limit=${max}`;
|
||||
}
|
||||
|
||||
private emitOperation(operation: ClientOperation): void {
|
||||
this.onOperation?.(operation);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import readline from "node:readline/promises";
|
||||
|
||||
export type PermissionPromptOptions = {
|
||||
prompt: string;
|
||||
header?: string;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export async function promptForPermission(options: PermissionPromptOptions): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.header) {
|
||||
process.stderr.write(`\n${options.header}\n`);
|
||||
}
|
||||
if (options.details && options.details.trim().length > 0) {
|
||||
process.stderr.write(`${options.details}\n`);
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
});
|
||||
|
||||
try {
|
||||
const answer = await rl.question(options.prompt);
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
return normalized === "y" || normalized === "yes";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import {
|
||||
type PermissionOption,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PermissionPromptUnavailableError } from "../errors.js";
|
||||
import type { NonInteractivePermissionPolicy, PermissionMode } from "../runtime-types.js";
|
||||
import { promptForPermission } from "./permission-prompt.js";
|
||||
|
||||
type PermissionDecision = "approved" | "denied" | "cancelled";
|
||||
const PERMISSION_MODE_RANK: Record<PermissionMode, number> = {
|
||||
"deny-all": 0,
|
||||
"approve-reads": 1,
|
||||
"approve-all": 2,
|
||||
};
|
||||
|
||||
function selected(optionId: string): RequestPermissionResponse {
|
||||
return { outcome: { outcome: "selected", optionId } };
|
||||
}
|
||||
|
||||
function cancelled(): RequestPermissionResponse {
|
||||
return { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
|
||||
function pickOption(
|
||||
options: PermissionOption[],
|
||||
kinds: PermissionOption["kind"][],
|
||||
): PermissionOption | undefined {
|
||||
for (const kind of kinds) {
|
||||
const match = options.find((option) => option.kind === kind);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function inferToolKind(params: RequestPermissionRequest): ToolKind | undefined {
|
||||
if (params.toolCall.kind) {
|
||||
return params.toolCall.kind;
|
||||
}
|
||||
|
||||
const title = params.toolCall.title?.trim().toLowerCase();
|
||||
if (!title) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const head = title.split(":", 1)[0]?.trim();
|
||||
if (!head) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (head.includes("read") || head.includes("cat")) {
|
||||
return "read";
|
||||
}
|
||||
if (head.includes("search") || head.includes("find") || head.includes("grep")) {
|
||||
return "search";
|
||||
}
|
||||
if (head.includes("write") || head.includes("edit") || head.includes("patch")) {
|
||||
return "edit";
|
||||
}
|
||||
if (head.includes("delete") || head.includes("remove")) {
|
||||
return "delete";
|
||||
}
|
||||
if (head.includes("move") || head.includes("rename")) {
|
||||
return "move";
|
||||
}
|
||||
if (head.includes("run") || head.includes("execute") || head.includes("bash")) {
|
||||
return "execute";
|
||||
}
|
||||
if (head.includes("fetch") || head.includes("http") || head.includes("url")) {
|
||||
return "fetch";
|
||||
}
|
||||
if (head.includes("think")) {
|
||||
return "think";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
function isAutoApprovedReadKind(kind: ToolKind | undefined): boolean {
|
||||
return kind === "read" || kind === "search";
|
||||
}
|
||||
|
||||
async function promptForToolPermission(params: RequestPermissionRequest): Promise<boolean> {
|
||||
const toolName = params.toolCall.title ?? "tool";
|
||||
const toolKind = inferToolKind(params) ?? "other";
|
||||
return await promptForPermission({
|
||||
prompt: `\n[permission] Allow ${toolName} [${toolKind}]? (y/N) `,
|
||||
});
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
}
|
||||
|
||||
export function permissionModeSatisfies(actual: PermissionMode, required: PermissionMode): boolean {
|
||||
return PERMISSION_MODE_RANK[actual] >= PERMISSION_MODE_RANK[required];
|
||||
}
|
||||
|
||||
export async function resolvePermissionRequest(
|
||||
params: RequestPermissionRequest,
|
||||
mode: PermissionMode,
|
||||
nonInteractivePolicy: NonInteractivePermissionPolicy = "deny",
|
||||
): Promise<RequestPermissionResponse> {
|
||||
const options = params.options ?? [];
|
||||
if (options.length === 0) {
|
||||
return cancelled();
|
||||
}
|
||||
|
||||
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
|
||||
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
|
||||
|
||||
if (mode === "approve-all") {
|
||||
if (allowOption) {
|
||||
return selected(allowOption.optionId);
|
||||
}
|
||||
return selected(options[0].optionId);
|
||||
}
|
||||
|
||||
if (mode === "deny-all") {
|
||||
if (rejectOption) {
|
||||
return selected(rejectOption.optionId);
|
||||
}
|
||||
return cancelled();
|
||||
}
|
||||
|
||||
const kind = inferToolKind(params);
|
||||
if (isAutoApprovedReadKind(kind) && allowOption) {
|
||||
return selected(allowOption.optionId);
|
||||
}
|
||||
|
||||
if (!canPromptForPermission()) {
|
||||
if (nonInteractivePolicy === "fail") {
|
||||
throw new PermissionPromptUnavailableError();
|
||||
}
|
||||
if (rejectOption) {
|
||||
return selected(rejectOption.optionId);
|
||||
}
|
||||
return cancelled();
|
||||
}
|
||||
|
||||
const approved = await promptForToolPermission(params);
|
||||
if (approved && allowOption) {
|
||||
return selected(allowOption.optionId);
|
||||
}
|
||||
if (!approved && rejectOption) {
|
||||
return selected(rejectOption.optionId);
|
||||
}
|
||||
return cancelled();
|
||||
}
|
||||
|
||||
export function classifyPermissionDecision(
|
||||
params: RequestPermissionRequest,
|
||||
response: RequestPermissionResponse,
|
||||
): PermissionDecision {
|
||||
if (response.outcome.outcome !== "selected") {
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const selectedOptionId = response.outcome.optionId;
|
||||
const selectedOption = params.options.find((option) => option.optionId === selectedOptionId);
|
||||
|
||||
if (!selectedOption) {
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
if (selectedOption.kind === "allow_once" || selectedOption.kind === "allow_always") {
|
||||
return "approved";
|
||||
}
|
||||
|
||||
return "denied";
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
function readWindowsEnvValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
|
||||
const matchedKey = Object.keys(env).find((entry) => entry.toUpperCase() === key);
|
||||
return matchedKey ? env[matchedKey] : undefined;
|
||||
}
|
||||
|
||||
function resolveWindowsCommand(
|
||||
command: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const extensions = (readWindowsEnvValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD")
|
||||
.split(";")
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter((value) => value.length > 0);
|
||||
const commandExtension = path.extname(command);
|
||||
const candidates =
|
||||
commandExtension.length > 0
|
||||
? [command]
|
||||
: extensions.map((extension) => `${command}${extension}`);
|
||||
const hasPath = command.includes("/") || command.includes("\\") || path.isAbsolute(command);
|
||||
|
||||
if (hasPath) {
|
||||
return candidates.find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
const pathValue = readWindowsEnvValue(env, "PATH");
|
||||
if (!pathValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const directory of pathValue.split(";")) {
|
||||
const trimmedDirectory = directory.trim();
|
||||
if (trimmedDirectory.length === 0) {
|
||||
continue;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.join(trimmedDirectory, candidate);
|
||||
if (fs.existsSync(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldUseWindowsBatchShell(
|
||||
command: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
if (platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
const resolvedCommand = resolveWindowsCommand(command, env) ?? command;
|
||||
const ext = path.extname(resolvedCommand).toLowerCase();
|
||||
return ext === ".cmd" || ext === ".bat";
|
||||
}
|
||||
|
||||
export function buildSpawnCommandOptions(
|
||||
command: string,
|
||||
options: Parameters<typeof spawn>[2],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Parameters<typeof spawn>[2] {
|
||||
if (!shouldUseWindowsBatchShell(command, platform, env)) {
|
||||
return options;
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
shell: true,
|
||||
};
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Readable } from "node:stream";
|
||||
import type {
|
||||
CreateTerminalRequest,
|
||||
CreateTerminalResponse,
|
||||
KillTerminalRequest,
|
||||
KillTerminalResponse,
|
||||
ReleaseTerminalRequest,
|
||||
ReleaseTerminalResponse,
|
||||
TerminalOutputRequest,
|
||||
TerminalOutputResponse,
|
||||
WaitForTerminalExitRequest,
|
||||
WaitForTerminalExitResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { PermissionDeniedError, PermissionPromptUnavailableError } from "../errors.js";
|
||||
import type {
|
||||
ClientOperation,
|
||||
NonInteractivePermissionPolicy,
|
||||
PermissionMode,
|
||||
} from "../runtime-types.js";
|
||||
import { promptForPermission } from "./permission-prompt.js";
|
||||
import { buildSpawnCommandOptions } from "./spawn.js";
|
||||
|
||||
const DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES = 64 * 1024;
|
||||
const DEFAULT_KILL_GRACE_MS = 1_500;
|
||||
|
||||
type ManagedTerminal = {
|
||||
process: ChildProcessByStdio<null, Readable, Readable>;
|
||||
output: Buffer;
|
||||
truncated: boolean;
|
||||
outputByteLimit: number;
|
||||
exitCode: number | null | undefined;
|
||||
signal: NodeJS.Signals | null | undefined;
|
||||
exitPromise: Promise<WaitForTerminalExitResponse>;
|
||||
resolveExit: (response: WaitForTerminalExitResponse) => void;
|
||||
};
|
||||
|
||||
export type TerminalManagerOptions = {
|
||||
cwd: string;
|
||||
permissionMode: PermissionMode;
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy;
|
||||
onOperation?: (operation: ClientOperation) => void;
|
||||
confirmExecute?: (commandLine: string) => Promise<boolean>;
|
||||
killGraceMs?: number;
|
||||
};
|
||||
|
||||
type TerminalSpawnOptions = {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv | undefined;
|
||||
stdio: ["ignore", "pipe", "pipe"];
|
||||
shell?: true;
|
||||
windowsHide: true;
|
||||
};
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function toCommandLine(command: string, args: string[] | undefined): string {
|
||||
const renderedArgs = (args ?? []).map((arg) => JSON.stringify(arg)).join(" ");
|
||||
return renderedArgs.length > 0 ? `${command} ${renderedArgs}` : command;
|
||||
}
|
||||
|
||||
function toEnvObject(env: CreateTerminalRequest["env"]): NodeJS.ProcessEnv | undefined {
|
||||
if (!env || env.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const merged: NodeJS.ProcessEnv = { ...process.env };
|
||||
for (const entry of env) {
|
||||
merged[entry.name] = entry.value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildTerminalSpawnOptions(
|
||||
command: string,
|
||||
cwd: string,
|
||||
env: CreateTerminalRequest["env"],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): TerminalSpawnOptions {
|
||||
const resolvedEnv = toEnvObject(env);
|
||||
const options: TerminalSpawnOptions = {
|
||||
cwd,
|
||||
env: resolvedEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
};
|
||||
return buildSpawnCommandOptions(
|
||||
command,
|
||||
options,
|
||||
platform,
|
||||
resolvedEnv ?? process.env,
|
||||
) as TerminalSpawnOptions;
|
||||
}
|
||||
|
||||
function trimToUtf8Boundary(buffer: Buffer, limit: number): Buffer {
|
||||
if (limit <= 0) {
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
if (buffer.length <= limit) {
|
||||
return buffer;
|
||||
}
|
||||
|
||||
let start = buffer.length - limit;
|
||||
while (start < buffer.length && (buffer[start] & 0b1100_0000) === 0b1000_0000) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
if (start >= buffer.length) {
|
||||
start = buffer.length - limit;
|
||||
}
|
||||
return buffer.subarray(start);
|
||||
}
|
||||
|
||||
function waitForSpawn(process: ChildProcessByStdio<null, Readable, Readable>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onSpawn = () => {
|
||||
process.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
process.off("spawn", onSpawn);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
process.once("spawn", onSpawn);
|
||||
process.once("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultConfirmExecute(commandLine: string): Promise<boolean> {
|
||||
return await promptForPermission({
|
||||
prompt: `\n[permission] Allow terminal command "${commandLine}"? (y/N) `,
|
||||
});
|
||||
}
|
||||
|
||||
function canPromptForPermission(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
||||
}
|
||||
|
||||
function waitMs(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, Math.max(0, ms));
|
||||
});
|
||||
}
|
||||
|
||||
export class TerminalManager {
|
||||
private readonly cwd: string;
|
||||
private permissionMode: PermissionMode;
|
||||
private nonInteractivePermissions: NonInteractivePermissionPolicy;
|
||||
private readonly onOperation?: (operation: ClientOperation) => void;
|
||||
private readonly usesDefaultConfirmExecute: boolean;
|
||||
private readonly confirmExecute: (commandLine: string) => Promise<boolean>;
|
||||
private readonly killGraceMs: number;
|
||||
private readonly terminals = new Map<string, ManagedTerminal>();
|
||||
|
||||
constructor(options: TerminalManagerOptions) {
|
||||
this.cwd = options.cwd;
|
||||
this.permissionMode = options.permissionMode;
|
||||
this.nonInteractivePermissions = options.nonInteractivePermissions ?? "deny";
|
||||
this.onOperation = options.onOperation;
|
||||
this.usesDefaultConfirmExecute = options.confirmExecute == null;
|
||||
this.confirmExecute = options.confirmExecute ?? defaultConfirmExecute;
|
||||
this.killGraceMs = Math.max(0, Math.round(options.killGraceMs ?? DEFAULT_KILL_GRACE_MS));
|
||||
}
|
||||
|
||||
updatePermissionPolicy(
|
||||
permissionMode: PermissionMode,
|
||||
nonInteractivePermissions?: NonInteractivePermissionPolicy,
|
||||
): void {
|
||||
this.permissionMode = permissionMode;
|
||||
this.nonInteractivePermissions = nonInteractivePermissions ?? "deny";
|
||||
}
|
||||
|
||||
async createTerminal(params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
const commandLine = toCommandLine(params.command, params.args);
|
||||
const summary = `terminal/create: ${commandLine}`;
|
||||
|
||||
this.emitOperation({
|
||||
method: "terminal/create",
|
||||
status: "running",
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (!(await this.isExecuteApproved(commandLine))) {
|
||||
throw new PermissionDeniedError("Permission denied for terminal/create");
|
||||
}
|
||||
|
||||
const outputByteLimit = Math.max(
|
||||
0,
|
||||
Math.round(params.outputByteLimit ?? DEFAULT_TERMINAL_OUTPUT_LIMIT_BYTES),
|
||||
);
|
||||
const proc = spawn(
|
||||
params.command,
|
||||
params.args ?? [],
|
||||
buildTerminalSpawnOptions(params.command, params.cwd ?? this.cwd, params.env),
|
||||
);
|
||||
await waitForSpawn(proc);
|
||||
|
||||
let resolveExit: (response: WaitForTerminalExitResponse) => void = () => {};
|
||||
const exitPromise = new Promise<WaitForTerminalExitResponse>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const terminal: ManagedTerminal = {
|
||||
process: proc,
|
||||
output: Buffer.alloc(0),
|
||||
truncated: false,
|
||||
outputByteLimit,
|
||||
exitCode: undefined,
|
||||
signal: undefined,
|
||||
exitPromise,
|
||||
resolveExit,
|
||||
};
|
||||
|
||||
const appendOutput = (chunk: Buffer | string): void => {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
if (bytes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.output = Buffer.concat([terminal.output, bytes]);
|
||||
if (terminal.output.length > terminal.outputByteLimit) {
|
||||
terminal.output = trimToUtf8Boundary(terminal.output, terminal.outputByteLimit);
|
||||
terminal.truncated = true;
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", appendOutput);
|
||||
proc.stderr.on("data", appendOutput);
|
||||
proc.once("exit", (exitCode, signal) => {
|
||||
terminal.exitCode = exitCode;
|
||||
terminal.signal = signal;
|
||||
terminal.resolveExit({
|
||||
exitCode: exitCode ?? null,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const terminalId = randomUUID();
|
||||
this.terminals.set(terminalId, terminal);
|
||||
|
||||
this.emitOperation({
|
||||
method: "terminal/create",
|
||||
status: "completed",
|
||||
summary,
|
||||
details: `terminalId=${terminalId}`,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return { terminalId };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitOperation({
|
||||
method: "terminal/create",
|
||||
status: "failed",
|
||||
summary,
|
||||
details: message,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async terminalOutput(params: TerminalOutputRequest): Promise<TerminalOutputResponse> {
|
||||
const terminal = this.getTerminal(params.terminalId);
|
||||
if (!terminal) {
|
||||
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
||||
}
|
||||
|
||||
const hasExitStatus = terminal.exitCode !== undefined || terminal.signal !== undefined;
|
||||
|
||||
this.emitOperation({
|
||||
method: "terminal/output",
|
||||
status: "completed",
|
||||
summary: `terminal/output: ${params.terminalId}`,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
return {
|
||||
output: terminal.output.toString("utf8"),
|
||||
truncated: terminal.truncated,
|
||||
exitStatus: hasExitStatus
|
||||
? {
|
||||
exitCode: terminal.exitCode ?? null,
|
||||
signal: terminal.signal ?? null,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async waitForTerminalExit(
|
||||
params: WaitForTerminalExitRequest,
|
||||
): Promise<WaitForTerminalExitResponse> {
|
||||
const terminal = this.getTerminal(params.terminalId);
|
||||
if (!terminal) {
|
||||
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
||||
}
|
||||
|
||||
const response = await terminal.exitPromise;
|
||||
this.emitOperation({
|
||||
method: "terminal/wait_for_exit",
|
||||
status: "completed",
|
||||
summary: `terminal/wait_for_exit: ${params.terminalId}`,
|
||||
details: `exitCode=${response.exitCode ?? "null"}, signal=${response.signal ?? "null"}`,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async killTerminal(params: KillTerminalRequest): Promise<KillTerminalResponse> {
|
||||
const terminal = this.getTerminal(params.terminalId);
|
||||
if (!terminal) {
|
||||
throw new Error(`Unknown terminal: ${params.terminalId}`);
|
||||
}
|
||||
|
||||
const summary = `terminal/kill: ${params.terminalId}`;
|
||||
this.emitOperation({
|
||||
method: "terminal/kill",
|
||||
status: "running",
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
try {
|
||||
await this.killProcess(terminal);
|
||||
this.emitOperation({
|
||||
method: "terminal/kill",
|
||||
status: "completed",
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitOperation({
|
||||
method: "terminal/kill",
|
||||
status: "failed",
|
||||
summary,
|
||||
details: message,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async releaseTerminal(params: ReleaseTerminalRequest): Promise<ReleaseTerminalResponse> {
|
||||
const summary = `terminal/release: ${params.terminalId}`;
|
||||
this.emitOperation({
|
||||
method: "terminal/release",
|
||||
status: "running",
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
|
||||
const terminal = this.getTerminal(params.terminalId);
|
||||
if (!terminal) {
|
||||
this.emitOperation({
|
||||
method: "terminal/release",
|
||||
status: "completed",
|
||||
summary,
|
||||
details: "already released",
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.killProcess(terminal);
|
||||
await terminal.exitPromise.catch(() => {
|
||||
// ignore best-effort wait failures
|
||||
});
|
||||
terminal.output = Buffer.alloc(0);
|
||||
this.terminals.delete(params.terminalId);
|
||||
|
||||
this.emitOperation({
|
||||
method: "terminal/release",
|
||||
status: "completed",
|
||||
summary,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
return {};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emitOperation({
|
||||
method: "terminal/release",
|
||||
status: "failed",
|
||||
summary,
|
||||
details: message,
|
||||
timestamp: nowIso(),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
for (const terminalId of Array.from(this.terminals.keys())) {
|
||||
await this.releaseTerminal({ terminalId, sessionId: "shutdown" });
|
||||
}
|
||||
}
|
||||
|
||||
private getTerminal(terminalId: string): ManagedTerminal | undefined {
|
||||
return this.terminals.get(terminalId);
|
||||
}
|
||||
|
||||
private emitOperation(operation: ClientOperation): void {
|
||||
this.onOperation?.(operation);
|
||||
}
|
||||
|
||||
private async isExecuteApproved(commandLine: string): Promise<boolean> {
|
||||
if (this.permissionMode === "approve-all") {
|
||||
return true;
|
||||
}
|
||||
if (this.permissionMode === "deny-all") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.usesDefaultConfirmExecute &&
|
||||
this.nonInteractivePermissions === "fail" &&
|
||||
!canPromptForPermission()
|
||||
) {
|
||||
throw new PermissionPromptUnavailableError();
|
||||
}
|
||||
return await this.confirmExecute(commandLine);
|
||||
}
|
||||
|
||||
private isRunning(terminal: ManagedTerminal): boolean {
|
||||
return terminal.exitCode === undefined && terminal.signal === undefined;
|
||||
}
|
||||
|
||||
private async killProcess(terminal: ManagedTerminal): Promise<void> {
|
||||
if (!this.isRunning(terminal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.process.kill("SIGTERM");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const exitedAfterTerm = await Promise.race([
|
||||
terminal.exitPromise.then(() => true),
|
||||
waitMs(this.killGraceMs).then(() => false),
|
||||
]);
|
||||
|
||||
if (exitedAfterTerm || !this.isRunning(terminal)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.process.kill("SIGKILL");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.race([terminal.exitPromise.then(() => undefined), waitMs(this.killGraceMs)]);
|
||||
}
|
||||
}
|
||||
@@ -164,6 +164,15 @@ function resolveDiscordAttachedOutboundTarget(params: {
|
||||
return threadId ? `channel:${threadId}` : params.to;
|
||||
}
|
||||
|
||||
function shouldTreatDiscordDeliveredTextAsVisible(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
params.kind === "block" && typeof params.text === "string" && params.text.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeDiscordMessageActions() {
|
||||
try {
|
||||
return getDiscordRuntime().channel?.discord?.messageActions ?? null;
|
||||
@@ -842,6 +851,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
chunker: null,
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
shouldTreatDeliveredTextAsVisible: shouldTreatDiscordDeliveredTextAsVisible,
|
||||
shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) =>
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt({
|
||||
cfg,
|
||||
|
||||
@@ -738,6 +738,39 @@ describe("Discord native plugin command dispatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bypass configured ACP readiness for Discord /new", async () => {
|
||||
const { cfg, interaction } = createConfiguredAcpCase({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: "1478844424791396446",
|
||||
peerKind: "channel",
|
||||
guildId: "1459246755253325866",
|
||||
guildName: "Ops",
|
||||
});
|
||||
const resolveRouteState = vi.fn(async () =>
|
||||
createConfiguredRouteState({
|
||||
sessionKey: "agent:claude:acp:binding:discord:default:9373ab192b2317f4",
|
||||
agentId: "claude",
|
||||
}),
|
||||
);
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(resolveRouteState);
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const command = await createNativeCommand(cfg, {
|
||||
name: "new",
|
||||
description: "Start a new session.",
|
||||
acceptsArgs: true,
|
||||
});
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(resolveRouteState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enforceConfiguredBindingReadiness: true,
|
||||
}),
|
||||
);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
||||
const { cfg, interaction } = createConfiguredAcpCase({
|
||||
channelType: ChannelType.GuildText,
|
||||
|
||||
@@ -342,7 +342,9 @@ function buildDiscordCommandOptions(params: {
|
||||
|
||||
function shouldBypassConfiguredAcpEnsure(commandName: string): boolean {
|
||||
const normalized = commandName.trim().toLowerCase();
|
||||
return normalized === "acp" || normalized === "new" || normalized === "reset";
|
||||
// Recovery slash commands still need configured ACP readiness so stale dead
|
||||
// bindings are recreated before /new or /reset dispatches through them.
|
||||
return normalized === "acp";
|
||||
}
|
||||
|
||||
function resolveDiscordNativeGroupDmAccess(params: {
|
||||
|
||||
@@ -261,7 +261,7 @@ function matchTelegramAcpConversation(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function shouldTreatTelegramRoutedTextAsVisible(params: {
|
||||
function shouldTreatTelegramDeliveredTextAsVisible(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}): boolean {
|
||||
@@ -1021,7 +1021,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
}).catch(() => {});
|
||||
},
|
||||
shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData),
|
||||
shouldTreatRoutedTextAsVisible: shouldTreatTelegramRoutedTextAsVisible,
|
||||
shouldTreatDeliveredTextAsVisible: shouldTreatTelegramDeliveredTextAsVisible,
|
||||
targetsMatchForReplySuppression: targetsMatchTelegramReplySuppression,
|
||||
resolveEffectiveTextChunkLimit: ({ fallbackLimit }) =>
|
||||
typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096,
|
||||
|
||||
188
pnpm-lock.yaml
generated
188
pnpm-lock.yaml
generated
@@ -263,9 +263,9 @@ importers:
|
||||
|
||||
extensions/acpx:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk':
|
||||
specifier: 0.18.0
|
||||
version: 0.18.0(zod@4.3.6)
|
||||
acpx:
|
||||
specifier: 0.5.0
|
||||
version: 0.5.0
|
||||
|
||||
extensions/alibaba: {}
|
||||
|
||||
@@ -839,6 +839,11 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@agentclientprotocol/sdk@0.17.1':
|
||||
resolution: {integrity: sha512-yjyIn8POL18IOXioLySYiL0G44kZ/IZctAls7vS3AC3X+qLhFXbWmzABSZehwRnWFShMXT+ODa/HJG1+mGXZ1A==}
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
|
||||
'@agentclientprotocol/sdk@0.18.0':
|
||||
resolution: {integrity: sha512-JQGEi3EetQ38DLPpYxxnnz1fyo1/3qQEkKfUmj4JfiOJCEtjGWQ0nl54IH4LZceO7zIOrtUUxc+2cJRQbBOChA==}
|
||||
peerDependencies:
|
||||
@@ -3693,6 +3698,11 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acpx@0.5.0:
|
||||
resolution: {integrity: sha512-Yx52SiywF+1rO2oc+n87pSyBc2YJ8nPnFRCLK8CW3V3s1ZQ7o3B84FZ343AUafGLtXUAKXN9lm8i3RsGiFxRrw==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@6.0.2:
|
||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
@@ -3823,6 +3833,14 @@ packages:
|
||||
axios@1.13.6:
|
||||
resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==}
|
||||
|
||||
b4a@1.8.0:
|
||||
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
babel-walk@3.0.0-canary-5:
|
||||
resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -3834,6 +3852,47 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
bare-events@2.8.2:
|
||||
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.6.0:
|
||||
resolution: {integrity: sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-os@3.8.7:
|
||||
resolution: {integrity: sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==}
|
||||
engines: {bare: '>=1.14.0'}
|
||||
|
||||
bare-path@3.0.0:
|
||||
resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==}
|
||||
|
||||
bare-stream@2.12.0:
|
||||
resolution: {integrity: sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.4.0:
|
||||
resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==}
|
||||
|
||||
base-x@5.0.1:
|
||||
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
|
||||
|
||||
@@ -4321,6 +4380,9 @@ packages:
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@@ -4369,6 +4431,9 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -6022,6 +6087,11 @@ packages:
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
skillflag@0.1.4:
|
||||
resolution: {integrity: sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
sleep-promise@9.1.0:
|
||||
resolution: {integrity: sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==}
|
||||
|
||||
@@ -6122,6 +6192,9 @@ packages:
|
||||
resolution: {integrity: sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
streamx@2.25.0:
|
||||
resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6181,15 +6254,24 @@ packages:
|
||||
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
|
||||
engines: {node: '>=12.17'}
|
||||
|
||||
tar-stream@3.1.8:
|
||||
resolution: {integrity: sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==}
|
||||
|
||||
tar@7.5.13:
|
||||
resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
telegraf@4.16.3:
|
||||
resolution: {integrity: sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==}
|
||||
engines: {node: ^12.20.0 || >=14.13.1}
|
||||
hasBin: true
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -6665,6 +6747,10 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@agentclientprotocol/sdk@0.17.1(zod@4.3.6)':
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
'@agentclientprotocol/sdk@0.18.0(zod@4.3.6)':
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
@@ -10096,6 +10182,18 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
acpx@0.5.0:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.17.1(zod@4.3.6)
|
||||
commander: 14.0.3
|
||||
skillflag: 0.1.4
|
||||
tsx: 4.21.0
|
||||
zod: 4.3.6
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
agent-base@6.0.2:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -10227,6 +10325,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
b4a@1.8.0: {}
|
||||
|
||||
babel-walk@3.0.0-canary-5:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
@@ -10235,6 +10335,38 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
bare-events@2.8.2: {}
|
||||
|
||||
bare-fs@4.6.0:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
bare-path: 3.0.0
|
||||
bare-stream: 2.12.0(bare-events@2.8.2)
|
||||
bare-url: 2.4.0
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-os@3.8.7: {}
|
||||
|
||||
bare-path@3.0.0:
|
||||
dependencies:
|
||||
bare-os: 3.8.7
|
||||
|
||||
bare-stream@2.12.0(bare-events@2.8.2):
|
||||
dependencies:
|
||||
streamx: 2.25.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.4.0:
|
||||
dependencies:
|
||||
bare-path: 3.0.0
|
||||
|
||||
base-x@5.0.1: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
@@ -10698,6 +10830,12 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.8.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
eventsource-parser@3.0.6: {}
|
||||
@@ -10776,6 +10914,8 @@ snapshots:
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -12732,6 +12872,15 @@ snapshots:
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
skillflag@0.1.4:
|
||||
dependencies:
|
||||
'@clack/prompts': 1.2.0
|
||||
tar-stream: 3.1.8
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
sleep-promise@9.1.0: {}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
@@ -12820,6 +12969,15 @@ snapshots:
|
||||
|
||||
steno@4.0.2: {}
|
||||
|
||||
streamx@2.25.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -12882,6 +13040,17 @@ snapshots:
|
||||
array-back: 6.2.3
|
||||
wordwrapjs: 5.1.1
|
||||
|
||||
tar-stream@3.1.8:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
bare-fs: 4.6.0
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.25.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
tar@7.5.13:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
@@ -12890,6 +13059,13 @@ snapshots:
|
||||
minizlib: 3.1.0
|
||||
yallist: 5.0.0
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.25.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
telegraf@4.16.3:
|
||||
dependencies:
|
||||
'@telegraf/types': 7.1.0
|
||||
@@ -12905,6 +13081,12 @@ snapshots:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.8.0
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
|
||||
@@ -875,11 +875,14 @@ export class AcpSessionManager {
|
||||
? "ACP turn failed before completion."
|
||||
: "Could not initialize ACP session runtime.",
|
||||
});
|
||||
retryFreshHandle = this.shouldRetryTurnWithFreshHandle({
|
||||
retryFreshHandle = await this.prepareFreshHandleRetry({
|
||||
attempt,
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
error: acpError,
|
||||
sawTurnOutput,
|
||||
runtime,
|
||||
meta,
|
||||
});
|
||||
if (retryFreshHandle) {
|
||||
continue;
|
||||
@@ -1247,16 +1250,17 @@ export class AcpSessionManager {
|
||||
let runtimeClosed = false;
|
||||
let runtimeNotice: string | undefined;
|
||||
try {
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
const { runtime: ensuredRuntime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta,
|
||||
});
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
await runtime.close({
|
||||
await ensuredRuntime.close({
|
||||
handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
}),
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP close failed before completion.",
|
||||
@@ -1273,8 +1277,24 @@ export class AcpSessionManager {
|
||||
input.allowBackendUnavailable &&
|
||||
(acpError.code === "ACP_BACKEND_MISSING" ||
|
||||
acpError.code === "ACP_BACKEND_UNAVAILABLE" ||
|
||||
(input.discardPersistentState && acpError.code === "ACP_SESSION_INIT_FAILED") ||
|
||||
this.isRecoverableAcpxExitError(acpError.message))
|
||||
) {
|
||||
if (input.discardPersistentState) {
|
||||
const configuredBackend = (meta.backend || input.cfg.acp?.backend || "").trim();
|
||||
try {
|
||||
const runtimeBackend = this.deps.requireRuntimeBackend(
|
||||
configuredBackend || undefined,
|
||||
);
|
||||
await runtimeBackend.runtime.prepareFreshSession?.({
|
||||
sessionKey,
|
||||
});
|
||||
} catch (recoveryError) {
|
||||
logVerbose(
|
||||
`acp close recovery: unable to prepare fresh session for ${sessionKey}: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Treat unavailable backends as terminal for this cached handle so it
|
||||
// cannot continue counting against maxConcurrentSessions.
|
||||
this.clearCachedRuntimeState(sessionKey);
|
||||
@@ -1604,21 +1624,55 @@ export class AcpSessionManager {
|
||||
this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private shouldRetryTurnWithFreshHandle(params: {
|
||||
private async prepareFreshHandleRetry(params: {
|
||||
attempt: number;
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
error: AcpRuntimeError;
|
||||
sawTurnOutput: boolean;
|
||||
}): boolean {
|
||||
runtime?: AcpRuntime;
|
||||
meta?: SessionAcpMeta;
|
||||
}): Promise<boolean> {
|
||||
if (params.attempt > 0 || params.sawTurnOutput) {
|
||||
return false;
|
||||
}
|
||||
if (!this.isRecoverableAcpxExitError(params.error.message)) {
|
||||
if (this.isRecoverableAcpxExitError(params.error.message)) {
|
||||
this.clearCachedRuntimeState(params.sessionKey);
|
||||
logVerbose(
|
||||
`acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!params.runtime ||
|
||||
!params.meta ||
|
||||
params.meta.mode !== "persistent" ||
|
||||
!this.isRecoverableMissingPersistentSessionError(params.error.message)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const cleared = await this.clearPersistedRuntimeResumeState({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!cleared) {
|
||||
return false;
|
||||
}
|
||||
if (params.runtime.prepareFreshSession) {
|
||||
try {
|
||||
await params.runtime.prepareFreshSession({
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`acp-manager: failed preparing a fresh persistent session for ${params.sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.clearCachedRuntimeState(params.sessionKey);
|
||||
logVerbose(
|
||||
`acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`,
|
||||
`acp-manager: retrying ${params.sessionKey} with a fresh persistent session after missing backend resume target: ${params.error.message}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -1627,6 +1681,63 @@ export class AcpSessionManager {
|
||||
return /^acpx exited with (code \d+|signal [a-z0-9]+)/i.test(message.trim());
|
||||
}
|
||||
|
||||
private isRecoverableMissingPersistentSessionError(message: string): boolean {
|
||||
const normalized = message.trim();
|
||||
return (
|
||||
/persistent acp session .* could not be resumed/i.test(normalized) &&
|
||||
/(resource not found|no matching session)/i.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
private async clearPersistedRuntimeResumeState(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
const updated = await this.writeSessionMeta({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
mutate: (current, entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const base = current ?? entry.acp;
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
const currentIdentity = resolveSessionIdentityFromMeta(base);
|
||||
if (!currentIdentity?.acpxSessionId && !currentIdentity?.agentSessionId) {
|
||||
return base;
|
||||
}
|
||||
const nextIdentity = {
|
||||
state: "pending" as const,
|
||||
...(currentIdentity.acpxRecordId ? { acpxRecordId: currentIdentity.acpxRecordId } : {}),
|
||||
source: currentIdentity.source,
|
||||
lastUpdatedAt: now,
|
||||
};
|
||||
return {
|
||||
backend: base.backend,
|
||||
agent: base.agent,
|
||||
runtimeSessionName: base.runtimeSessionName,
|
||||
identity: nextIdentity,
|
||||
mode: base.mode,
|
||||
...(base.runtimeOptions ? { runtimeOptions: base.runtimeOptions } : {}),
|
||||
...(base.cwd ? { cwd: base.cwd } : {}),
|
||||
state: base.state,
|
||||
lastActivityAt: now,
|
||||
...(base.lastError ? { lastError: base.lastError } : {}),
|
||||
};
|
||||
},
|
||||
});
|
||||
if (!updated) {
|
||||
logVerbose(
|
||||
`acp-manager: unable to clear persisted runtime resume state for ${params.sessionKey}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise<void> {
|
||||
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
|
||||
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {
|
||||
|
||||
@@ -91,6 +91,7 @@ function createRuntime(): {
|
||||
runtime: AcpRuntime;
|
||||
ensureSession: ReturnType<typeof vi.fn>;
|
||||
runTurn: ReturnType<typeof vi.fn>;
|
||||
prepareFreshSession: ReturnType<typeof vi.fn>;
|
||||
cancel: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
getCapabilities: ReturnType<typeof vi.fn>;
|
||||
@@ -108,6 +109,7 @@ function createRuntime(): {
|
||||
const runTurn = vi.fn(async function* () {
|
||||
yield { type: "done" as const };
|
||||
});
|
||||
const prepareFreshSession = vi.fn(async () => {});
|
||||
const cancel = vi.fn(async () => {});
|
||||
const close = vi.fn(async () => {});
|
||||
const getCapabilities = vi.fn(
|
||||
@@ -129,11 +131,13 @@ function createRuntime(): {
|
||||
getStatus,
|
||||
setMode,
|
||||
setConfigOption,
|
||||
prepareFreshSession,
|
||||
cancel,
|
||||
close,
|
||||
},
|
||||
ensureSession,
|
||||
runTurn,
|
||||
prepareFreshSession,
|
||||
cancel,
|
||||
close,
|
||||
getCapabilities,
|
||||
@@ -1288,6 +1292,39 @@ describe("AcpSessionManager", () => {
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("treats stale session init failures as recoverable during discard resets", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
runtimeState.ensureSession.mockRejectedValueOnce(
|
||||
new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Could not initialize ACP session runtime."),
|
||||
);
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:claude:acp:session-1",
|
||||
storeSessionKey: "agent:claude:acp:session-1",
|
||||
acp: readySessionMeta({
|
||||
agent: "claude",
|
||||
}),
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
const closeResult = await manager.closeSession({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:claude:acp:session-1",
|
||||
reason: "new-in-place-reset",
|
||||
allowBackendUnavailable: true,
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(closeResult.runtimeClosed).toBe(false);
|
||||
expect(closeResult.runtimeNotice).toBe("Could not initialize ACP session runtime.");
|
||||
expect(runtimeState.prepareFreshSession).toHaveBeenCalledWith({
|
||||
sessionKey: "agent:claude:acp:session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts idle cached runtimes before enforcing max concurrent limits", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -1681,6 +1718,112 @@ describe("AcpSessionManager", () => {
|
||||
expect(states).not.toContain("error");
|
||||
});
|
||||
|
||||
it("retries once with a fresh persistent session after an early missing-session turn failure", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
const sessionKey = "agent:claude:acp:binding:discord:default:retry-no-session";
|
||||
let currentMeta: SessionAcpMeta = {
|
||||
...readySessionMeta({
|
||||
agent: "claude",
|
||||
}),
|
||||
runtimeSessionName: sessionKey,
|
||||
identity: {
|
||||
state: "resolved",
|
||||
source: "status",
|
||||
acpxSessionId: "acpx-sid-stale",
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey;
|
||||
return {
|
||||
sessionKey: key,
|
||||
storeSessionKey: key,
|
||||
acp: currentMeta,
|
||||
};
|
||||
});
|
||||
hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => {
|
||||
const params = paramsUnknown as {
|
||||
mutate: (
|
||||
current: SessionAcpMeta | undefined,
|
||||
entry: { acp?: SessionAcpMeta } | undefined,
|
||||
) => SessionAcpMeta | null | undefined;
|
||||
};
|
||||
const next = params.mutate(currentMeta, { acp: currentMeta });
|
||||
if (next) {
|
||||
currentMeta = next;
|
||||
}
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
updatedAt: Date.now(),
|
||||
acp: currentMeta,
|
||||
};
|
||||
});
|
||||
runtimeState.ensureSession.mockImplementation(async (inputUnknown: unknown) => {
|
||||
const input = inputUnknown as {
|
||||
sessionKey: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
resumeSessionId?: string;
|
||||
};
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: "acpx",
|
||||
runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`,
|
||||
backendSessionId: input.resumeSessionId ? "acpx-sid-stale" : "acpx-sid-fresh",
|
||||
};
|
||||
});
|
||||
runtimeState.getStatus.mockResolvedValue({
|
||||
summary: "status=alive",
|
||||
backendSessionId: "acpx-sid-fresh",
|
||||
details: { status: "alive" },
|
||||
});
|
||||
runtimeState.runTurn
|
||||
.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: "error" as const,
|
||||
code: "NO_SESSION",
|
||||
message:
|
||||
"Persistent ACP session acpx-sid-stale could not be resumed: Resource not found: acpx-sid-stale",
|
||||
};
|
||||
})
|
||||
.mockImplementationOnce(async function* () {
|
||||
yield { type: "done" as const };
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await expect(
|
||||
manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
text: "do work",
|
||||
mode: "prompt",
|
||||
requestId: "run-no-session",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(runtimeState.prepareFreshSession).toHaveBeenCalledWith({
|
||||
sessionKey,
|
||||
});
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeState.ensureSession.mock.calls[0]?.[0]).toMatchObject({
|
||||
sessionKey,
|
||||
resumeSessionId: "acpx-sid-stale",
|
||||
});
|
||||
const retryInput = runtimeState.ensureSession.mock.calls[1]?.[0] as
|
||||
| { resumeSessionId?: string }
|
||||
| undefined;
|
||||
expect(retryInput?.resumeSessionId).toBeUndefined();
|
||||
expect(currentMeta.identity?.acpxSessionId).toBe("acpx-sid-fresh");
|
||||
expect(currentMeta.identity?.state).toBe("resolved");
|
||||
const states = extractStatesFromUpserts();
|
||||
expect(states).toContain("running");
|
||||
expect(states).toContain("idle");
|
||||
expect(states).not.toContain("error");
|
||||
});
|
||||
|
||||
it("persists runtime mode changes through setSessionRuntimeMode", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
@@ -2300,6 +2443,34 @@ describe("AcpSessionManager", () => {
|
||||
expect(hoisted.upsertAcpSessionMetaMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fail reset close recovery when backend lookup also throws", async () => {
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
storeSessionKey: "agent:codex:acp:session-1",
|
||||
acp: readySessionMeta(),
|
||||
});
|
||||
hoisted.requireAcpRuntimeBackendMock.mockImplementation(() => {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||
);
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
const result = await manager.closeSession({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
allowBackendUnavailable: true,
|
||||
clearMeta: false,
|
||||
});
|
||||
|
||||
expect(result.runtimeClosed).toBe(false);
|
||||
expect(result.runtimeNotice).toContain("not configured");
|
||||
expect(result.metaCleared).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces metadata clear errors during closeSession", async () => {
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
|
||||
@@ -68,6 +68,7 @@ export type AcpCloseSessionInput = {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
reason: string;
|
||||
discardPersistentState?: boolean;
|
||||
clearMeta?: boolean;
|
||||
allowBackendUnavailable?: boolean;
|
||||
requireAcpSession?: boolean;
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("resetAcpSessionInPlace", () => {
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
discardPersistentState: true,
|
||||
clearMeta: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,10 @@ function sessionMatchesConfiguredBinding(params: {
|
||||
spec: ConfiguredAcpBindingSpec;
|
||||
meta: SessionAcpMeta;
|
||||
}): boolean {
|
||||
if (params.meta.state === "error") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const desiredAgent = (params.spec.acpAgentId ?? params.spec.agentId).trim().toLowerCase();
|
||||
const currentAgent = (params.meta.agent ?? "").trim().toLowerCase();
|
||||
if (!currentAgent || currentAgent !== desiredAgent) {
|
||||
@@ -186,6 +190,7 @@ export async function resetAcpSessionInPlace(params: {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
reason: `${params.reason}-in-place-reset`,
|
||||
discardPersistentState: true,
|
||||
clearMeta: false,
|
||||
allowBackendUnavailable: true,
|
||||
requireAcpSession: false,
|
||||
|
||||
@@ -926,7 +926,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a matching ready session even when the stored ACP session is in error state", async () => {
|
||||
it("reinitializes a matching session when the stored ACP session is in error state", async () => {
|
||||
const spec = createDiscordPersistentSpec({
|
||||
cwd: "/home/bob/clawd",
|
||||
});
|
||||
@@ -942,8 +942,8 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
});
|
||||
|
||||
expect(ensured).toEqual({ ok: true, sessionKey });
|
||||
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
||||
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledTimes(1);
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initializes ACP session with runtime agent override when provided", async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js";
|
||||
import { AcpRuntimeError, isAcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js";
|
||||
|
||||
describe("withAcpRuntimeErrorBoundary", () => {
|
||||
it("wraps generic errors with fallback code and source message", async () => {
|
||||
@@ -30,4 +30,29 @@ describe("withAcpRuntimeErrorBoundary", () => {
|
||||
}),
|
||||
).rejects.toBe(existing);
|
||||
});
|
||||
|
||||
it("preserves ACP runtime codes from foreign package errors", async () => {
|
||||
class ForeignAcpRuntimeError extends Error {
|
||||
readonly code = "ACP_BACKEND_MISSING" as const;
|
||||
}
|
||||
|
||||
const foreignError = new ForeignAcpRuntimeError("backend missing");
|
||||
|
||||
await expect(
|
||||
withAcpRuntimeErrorBoundary({
|
||||
run: async () => {
|
||||
throw foreignError;
|
||||
},
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "fallback",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "AcpRuntimeError",
|
||||
code: "ACP_BACKEND_MISSING",
|
||||
message: "backend missing",
|
||||
cause: foreignError,
|
||||
});
|
||||
|
||||
expect(isAcpRuntimeError(foreignError)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ACP_ERROR_CODES = [
|
||||
] as const;
|
||||
|
||||
export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number];
|
||||
const ACP_ERROR_CODE_SET = new Set<AcpRuntimeErrorCode>(ACP_ERROR_CODES);
|
||||
|
||||
export class AcpRuntimeError extends Error {
|
||||
readonly code: AcpRuntimeErrorCode;
|
||||
@@ -22,8 +23,25 @@ export class AcpRuntimeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function getForeignAcpRuntimeError(value: unknown): {
|
||||
code: AcpRuntimeErrorCode;
|
||||
message: string;
|
||||
} | null {
|
||||
if (!(value instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
const code = (value as { code?: unknown }).code;
|
||||
if (typeof code !== "string" || !ACP_ERROR_CODE_SET.has(code as AcpRuntimeErrorCode)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: code as AcpRuntimeErrorCode,
|
||||
message: value.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
|
||||
return value instanceof AcpRuntimeError;
|
||||
return value instanceof AcpRuntimeError || getForeignAcpRuntimeError(value) !== null;
|
||||
}
|
||||
|
||||
export function toAcpRuntimeError(params: {
|
||||
@@ -34,6 +52,12 @@ export function toAcpRuntimeError(params: {
|
||||
if (params.error instanceof AcpRuntimeError) {
|
||||
return params.error;
|
||||
}
|
||||
const foreignAcpRuntimeError = getForeignAcpRuntimeError(params.error);
|
||||
if (foreignAcpRuntimeError) {
|
||||
return new AcpRuntimeError(foreignAcpRuntimeError.code, foreignAcpRuntimeError.message, {
|
||||
cause: params.error,
|
||||
});
|
||||
}
|
||||
if (params.error instanceof Error) {
|
||||
return new AcpRuntimeError(params.fallbackCode, params.error.message, {
|
||||
cause: params.error,
|
||||
|
||||
@@ -132,7 +132,21 @@ export interface AcpRuntime {
|
||||
|
||||
doctor?(): Promise<AcpRuntimeDoctorReport>;
|
||||
|
||||
/**
|
||||
* Prepare the next ensureSession for this session key to start fresh instead
|
||||
* of reopening backend-owned persistent state.
|
||||
*/
|
||||
prepareFreshSession?(input: { sessionKey: string }): Promise<void>;
|
||||
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason: string;
|
||||
/**
|
||||
* Discard backend-owned persistent session state so the next ensureSession
|
||||
* starts fresh instead of reopening the same conversation.
|
||||
*/
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("shouldBypassAcpDispatchForCommand", () => {
|
||||
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for ACP reset-tail slash commands", () => {
|
||||
it("returns true for ACP reset-tail slash commands", () => {
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
@@ -37,7 +37,19 @@ describe("shouldBypassAcpDispatchForCommand", () => {
|
||||
BodyForAgent: "/new continue with deployment",
|
||||
});
|
||||
|
||||
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(false);
|
||||
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for bare ACP reset slash commands", () => {
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
CommandBody: "/reset",
|
||||
BodyForCommands: "/reset",
|
||||
BodyForAgent: "/reset",
|
||||
});
|
||||
|
||||
expect(shouldBypassAcpDispatchForCommand(ctx, {} as OpenClawConfig)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for slash commands when text commands are disabled", () => {
|
||||
|
||||
@@ -23,6 +23,10 @@ function resolveCommandCandidateText(ctx: FinalizedMsgContext): string {
|
||||
return resolveFirstContextText(ctx, ["CommandBody", "BodyForCommands", "RawBody", "Body"]).trim();
|
||||
}
|
||||
|
||||
function isResetCommandCandidate(text: string): boolean {
|
||||
return /^\/(?:new|reset)(?:\s|$)/i.test(text);
|
||||
}
|
||||
|
||||
export function shouldBypassAcpDispatchForCommand(
|
||||
ctx: FinalizedMsgContext,
|
||||
cfg: OpenClawConfig,
|
||||
@@ -41,6 +45,10 @@ export function shouldBypassAcpDispatchForCommand(
|
||||
return allowTextCommands;
|
||||
}
|
||||
|
||||
if (isResetCommandCandidate(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("!")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,32 @@ const deliveryMocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })),
|
||||
}));
|
||||
|
||||
const channelPluginMocks = vi.hoisted(() => ({
|
||||
shouldTreatDeliveredTextAsVisible: (({
|
||||
kind,
|
||||
text,
|
||||
}: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => kind === "block" && typeof text === "string" && text.trim().length > 0) as
|
||||
| ((params: { kind: "tool" | "block" | "final"; text?: string }) => boolean)
|
||||
| undefined,
|
||||
shouldTreatRoutedTextAsVisible: undefined as
|
||||
| ((params: { kind: "tool" | "block" | "final"; text?: string }) => boolean)
|
||||
| undefined,
|
||||
getChannelPlugin: vi.fn((channelId: string) => {
|
||||
if (channelId !== "discord" && channelId !== "telegram") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
outbound: {
|
||||
shouldTreatDeliveredTextAsVisible: channelPluginMocks.shouldTreatDeliveredTextAsVisible,
|
||||
shouldTreatRoutedTextAsVisible: channelPluginMocks.shouldTreatRoutedTextAsVisible,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../tts/tts.js", () => ({
|
||||
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
|
||||
}));
|
||||
@@ -25,6 +51,14 @@ vi.mock("./route-reply.js", () => ({
|
||||
routeReply: (params: unknown) => deliveryMocks.routeReply(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
|
||||
runMessageAction: (params: unknown) => deliveryMocks.runMessageAction(params),
|
||||
}));
|
||||
@@ -62,6 +96,15 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
deliveryMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock-message" });
|
||||
deliveryMocks.runMessageAction.mockClear();
|
||||
deliveryMocks.runMessageAction.mockResolvedValue({ ok: true as const });
|
||||
channelPluginMocks.getChannelPlugin.mockClear();
|
||||
channelPluginMocks.shouldTreatDeliveredTextAsVisible = ({
|
||||
kind,
|
||||
text,
|
||||
}: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => kind === "block" && typeof text === "string" && text.trim().length > 0;
|
||||
channelPluginMocks.shouldTreatRoutedTextAsVisible = undefined;
|
||||
});
|
||||
|
||||
it("bypasses TTS when skipTts is requested", async () => {
|
||||
@@ -143,8 +186,18 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat non-telegram direct block text as visible", async () => {
|
||||
const coordinator = createCoordinator();
|
||||
it("does not treat channels without a visibility override as visible for direct block delivery", async () => {
|
||||
const coordinator = createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: false,
|
||||
});
|
||||
|
||||
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
await coordinator.settleVisibleText();
|
||||
@@ -155,6 +208,34 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
expect(coordinator.getRoutedCounts().block).toBe(0);
|
||||
});
|
||||
|
||||
it("treats direct discord block text as visible", async () => {
|
||||
const coordinator = createCoordinator();
|
||||
|
||||
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
await coordinator.settleVisibleText();
|
||||
|
||||
expect(coordinator.hasDeliveredVisibleText()).toBe(true);
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
|
||||
});
|
||||
|
||||
it("honors the legacy routed visibility hook name for plugin compatibility", async () => {
|
||||
channelPluginMocks.shouldTreatDeliveredTextAsVisible = undefined;
|
||||
channelPluginMocks.shouldTreatRoutedTextAsVisible = ({
|
||||
kind,
|
||||
text,
|
||||
}: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => kind === "block" && typeof text === "string" && text.trim().length > 0;
|
||||
const coordinator = createCoordinator();
|
||||
|
||||
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
await coordinator.settleVisibleText();
|
||||
|
||||
expect(coordinator.hasDeliveredVisibleText()).toBe(true);
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks failed visible telegram block delivery separately", async () => {
|
||||
const dispatcher: ReplyDispatcher = {
|
||||
sendToolResult: vi.fn(() => true),
|
||||
@@ -300,4 +381,26 @@ describe("createAcpDispatchDeliveryCoordinator", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats routed discord block text as visible", async () => {
|
||||
const coordinator = createAcpDispatchDeliveryCoordinator({
|
||||
cfg: createAcpTestConfig(),
|
||||
ctx: buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
SessionKey: "agent:codex-acp:session-1",
|
||||
}),
|
||||
dispatcher: createDispatcher(),
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: true,
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:thread-1",
|
||||
});
|
||||
|
||||
await coordinator.deliver("block", { text: "hello" }, { skipTts: true });
|
||||
|
||||
expect(coordinator.hasDeliveredVisibleText()).toBe(true);
|
||||
expect(coordinator.hasFailedVisibleTextDelivery()).toBe(false);
|
||||
expect(coordinator.getRoutedCounts().block).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ async function shouldTreatDeliveredTextAsVisible(params: {
|
||||
channel: string | undefined;
|
||||
kind: ReplyDispatchKind;
|
||||
text: string | undefined;
|
||||
routed: boolean;
|
||||
}): Promise<boolean> {
|
||||
if (!params.text?.trim()) {
|
||||
return false;
|
||||
@@ -93,19 +94,20 @@ async function shouldTreatDeliveredTextAsVisible(params: {
|
||||
if (!channelId) {
|
||||
return false;
|
||||
}
|
||||
// Only Telegram currently overrides block/tool visibility via channel runtime.
|
||||
// Keep other channels on the fast path so ACP local delivery does not pay the
|
||||
// broader channel-registry import cost on every streamed turn.
|
||||
if (channelId !== "telegram") {
|
||||
return false;
|
||||
}
|
||||
const { getChannelPlugin } = await loadChannelPluginRuntime();
|
||||
return (
|
||||
getChannelPlugin(channelId)?.outbound?.shouldTreatRoutedTextAsVisible?.({
|
||||
const outbound = getChannelPlugin(channelId)?.outbound;
|
||||
const visibilityOverride =
|
||||
outbound?.shouldTreatDeliveredTextAsVisible ?? outbound?.shouldTreatRoutedTextAsVisible;
|
||||
if (visibilityOverride) {
|
||||
return visibilityOverride({
|
||||
kind: params.kind,
|
||||
text: params.text,
|
||||
}) === true
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!params.routed) {
|
||||
return channelId === "telegram";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function maybeApplyAcpTts(params: {
|
||||
@@ -320,6 +322,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
channel: routedChannel,
|
||||
kind,
|
||||
text: ttsPayload.text,
|
||||
routed: true,
|
||||
});
|
||||
const { routeReply } = await loadRouteReplyRuntime();
|
||||
const result = await routeReply({
|
||||
@@ -363,6 +366,7 @@ export function createAcpDispatchDeliveryCoordinator(params: {
|
||||
channel: directChannel,
|
||||
kind,
|
||||
text: ttsPayload.text,
|
||||
routed: false,
|
||||
});
|
||||
const delivered =
|
||||
kind === "tool"
|
||||
|
||||
@@ -33,6 +33,25 @@ const routeMocks = vi.hoisted(() => ({
|
||||
routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })),
|
||||
}));
|
||||
|
||||
const channelPluginMocks = vi.hoisted(() => ({
|
||||
getChannelPlugin: vi.fn((channelId: string) => {
|
||||
if (channelId !== "discord" && channelId !== "telegram") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
outbound: {
|
||||
shouldTreatDeliveredTextAsVisible: ({
|
||||
kind,
|
||||
text,
|
||||
}: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => kind === "block" && typeof text === "string" && text.trim().length > 0,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const messageActionMocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(async (_params: unknown) => ({ ok: true as const })),
|
||||
}));
|
||||
@@ -109,6 +128,8 @@ async function runDispatch(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
dispatcher?: ReplyDispatcher;
|
||||
shouldRouteToOriginating?: boolean;
|
||||
originatingChannel?: string;
|
||||
originatingTo?: string;
|
||||
onReplyStart?: () => void;
|
||||
ctxOverrides?: Record<string, unknown>;
|
||||
sessionKeyOverride?: string;
|
||||
@@ -128,7 +149,10 @@ async function runDispatch(params: {
|
||||
inboundAudio: false,
|
||||
shouldRouteToOriginating: params.shouldRouteToOriginating ?? false,
|
||||
...(params.shouldRouteToOriginating
|
||||
? { originatingChannel: "telegram", originatingTo: "telegram:thread-1" }
|
||||
? {
|
||||
originatingChannel: params.originatingChannel ?? "telegram",
|
||||
originatingTo: params.originatingTo ?? "telegram:thread-1",
|
||||
}
|
||||
: {}),
|
||||
shouldSendToolSummaries: true,
|
||||
bypassForCommand: false,
|
||||
@@ -236,6 +260,13 @@ describe("tryDispatchAcpReply", () => {
|
||||
vi.doMock("./route-reply.js", () => ({
|
||||
routeReply: (params: unknown) => routeMocks.routeReply(params),
|
||||
}));
|
||||
vi.doMock("../../channels/plugins/index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
|
||||
};
|
||||
});
|
||||
vi.doMock("../../infra/outbound/message-action-runner.js", () => ({
|
||||
runMessageAction: (params: unknown) => messageActionMocks.runMessageAction(params),
|
||||
}));
|
||||
@@ -295,6 +326,7 @@ describe("tryDispatchAcpReply", () => {
|
||||
policyMocks.resolveAcpAgentPolicyError.mockReturnValue(null);
|
||||
routeMocks.routeReply.mockReset();
|
||||
routeMocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" });
|
||||
channelPluginMocks.getChannelPlugin.mockClear();
|
||||
messageActionMocks.runMessageAction.mockReset();
|
||||
messageActionMocks.runMessageAction.mockResolvedValue({ ok: true as const });
|
||||
ttsMocks.maybeApplyTtsToPayload.mockClear();
|
||||
@@ -957,6 +989,36 @@ describe("tryDispatchAcpReply", () => {
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not deliver final fallback text when routed discord block text was already visible", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
queueTtsReplies(
|
||||
{ text: "Received your test message." },
|
||||
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
||||
);
|
||||
mockRoutedTextTurn("Received your test message.");
|
||||
|
||||
const { dispatcher } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "run acp",
|
||||
dispatcher,
|
||||
shouldRouteToOriginating: true,
|
||||
originatingChannel: "discord",
|
||||
originatingTo: "channel:1478836151241412759",
|
||||
});
|
||||
|
||||
expect(result?.counts.block).toBe(1);
|
||||
expect(result?.counts.final).toBe(0);
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.routeReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "discord",
|
||||
to: "channel:1478836151241412759",
|
||||
payload: expect.objectContaining({ text: "Received your test message." }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not deliver final fallback text when direct block text was already visible", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
@@ -983,6 +1045,35 @@ describe("tryDispatchAcpReply", () => {
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not deliver final fallback text when direct discord block text was already visible", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
queueTtsReplies(
|
||||
{ text: "Received." },
|
||||
{} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>,
|
||||
);
|
||||
mockVisibleTextTurn("Received.");
|
||||
|
||||
const { dispatcher, counts } = createDispatcher();
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "reply",
|
||||
dispatcher,
|
||||
ctxOverrides: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.counts.block).toBe(0);
|
||||
expect(result?.counts.final).toBe(0);
|
||||
expect(counts.block).toBe(0);
|
||||
expect(counts.final).toBe(0);
|
||||
expect(dispatcher.sendBlockReply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: "Received." }),
|
||||
);
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats visible telegram ACP block delivery as a successful final response", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
@@ -1006,7 +1097,7 @@ describe("tryDispatchAcpReply", () => {
|
||||
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves final fallback when direct block text is filtered by non-telegram channels", async () => {
|
||||
it("preserves final fallback when direct block text is filtered by channels without a visibility override", async () => {
|
||||
setReadyAcpResolution();
|
||||
ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final" });
|
||||
queueTtsReplies({ text: "CODEX_OK" }, {} as ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>);
|
||||
@@ -1016,6 +1107,10 @@ describe("tryDispatchAcpReply", () => {
|
||||
const result = await runDispatch({
|
||||
bodyForAgent: "reply",
|
||||
dispatcher,
|
||||
ctxOverrides: {
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.counts.block).toBe(0);
|
||||
|
||||
@@ -616,7 +616,7 @@ describe("initSessionState RawBody", () => {
|
||||
expect(result.triggerBodyNormalized).toBe("/NEW KeepThisCase");
|
||||
});
|
||||
|
||||
it("does not rotate local session state for /new on bound ACP sessions", async () => {
|
||||
it("rotates local session state for /new on bound ACP sessions", async () => {
|
||||
const root = await makeCaseDir("openclaw-rawbody-acp-reset-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||
@@ -667,9 +667,9 @@ describe("initSessionState RawBody", () => {
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.resetTriggered).toBe(false);
|
||||
expect(result.sessionId).toBe(existingSessionId);
|
||||
expect(result.isNewSession).toBe(false);
|
||||
expect(result.resetTriggered).toBe(true);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.isNewSession).toBe(true);
|
||||
});
|
||||
|
||||
it("rotates local session state for ACP /new when no matching conversation binding exists", async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { normalizeConversationText } from "../../acp/conversation-id.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
|
||||
import { disposeSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js";
|
||||
@@ -38,7 +37,6 @@ import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.js"
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
|
||||
import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js";
|
||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
@@ -180,32 +178,6 @@ function resolveSessionConversationBindingContext(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBoundAcpSessionForReset(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: MsgContext;
|
||||
bindingContext?: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
}): string | undefined {
|
||||
const activeSessionKey = normalizeConversationText(params.ctx.SessionKey);
|
||||
const bindingContext =
|
||||
params.bindingContext ?? resolveSessionConversationBindingContext(params.cfg, params.ctx);
|
||||
return resolveEffectiveResetTargetSessionKey({
|
||||
cfg: params.cfg,
|
||||
channel: bindingContext?.channel,
|
||||
accountId: bindingContext?.accountId,
|
||||
conversationId: bindingContext?.conversationId,
|
||||
parentConversationId: bindingContext?.parentConversationId,
|
||||
activeSessionKey,
|
||||
allowNonAcpBindingSessionKey: false,
|
||||
skipConfiguredFallbackWhenActiveSessionNonAcp: true,
|
||||
fallbackToActiveAcpWhenUnbound: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBoundConversationSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: MsgContext;
|
||||
@@ -340,17 +312,6 @@ export async function initSessionState(params: {
|
||||
const strippedForReset = isGroup
|
||||
? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
|
||||
: triggerBodyNormalized;
|
||||
const shouldUseAcpInPlaceReset = Boolean(
|
||||
resolveBoundAcpSessionForReset({
|
||||
cfg,
|
||||
ctx: sessionCtxForState,
|
||||
bindingContext: conversationBindingContext,
|
||||
}),
|
||||
);
|
||||
const shouldBypassAcpResetForTrigger = (triggerLower: string): boolean =>
|
||||
shouldUseAcpInPlaceReset &&
|
||||
DEFAULT_RESET_TRIGGERS.some((defaultTrigger) => defaultTrigger.toLowerCase() === triggerLower);
|
||||
|
||||
// Reset triggers are configured as lowercased commands (e.g. "/new"), but users may type
|
||||
// "/NEW" etc. Match case-insensitively while keeping the original casing for any stripped body.
|
||||
const trimmedBodyLower = trimmedBody.toLowerCase();
|
||||
@@ -366,12 +327,6 @@ export async function initSessionState(params: {
|
||||
}
|
||||
const triggerLower = trigger.toLowerCase();
|
||||
if (trimmedBodyLower === triggerLower || strippedForResetLower === triggerLower) {
|
||||
if (shouldBypassAcpResetForTrigger(triggerLower)) {
|
||||
// ACP-bound conversations handle /new and /reset in command handling
|
||||
// so the bound ACP runtime can be reset in place without rotating the
|
||||
// normal OpenClaw session/transcript.
|
||||
break;
|
||||
}
|
||||
isNewSession = true;
|
||||
bodyStripped = "";
|
||||
resetTriggered = true;
|
||||
@@ -383,9 +338,6 @@ export async function initSessionState(params: {
|
||||
trimmedBodyLower.startsWith(triggerPrefixLower) ||
|
||||
strippedForResetLower.startsWith(triggerPrefixLower)
|
||||
) {
|
||||
if (shouldBypassAcpResetForTrigger(triggerLower)) {
|
||||
break;
|
||||
}
|
||||
isNewSession = true;
|
||||
bodyStripped = strippedForReset.slice(trigger.length).trimStart();
|
||||
resetTriggered = true;
|
||||
|
||||
@@ -225,10 +225,17 @@ export type ChannelOutboundAdapter = {
|
||||
payload: ReplyPayload;
|
||||
hint?: ChannelOutboundPayloadHint;
|
||||
}) => Promise<void> | void;
|
||||
/**
|
||||
* @deprecated Use shouldTreatDeliveredTextAsVisible instead.
|
||||
*/
|
||||
shouldTreatRoutedTextAsVisible?: (params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => boolean;
|
||||
shouldTreatDeliveredTextAsVisible?: (params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
text?: string;
|
||||
}) => boolean;
|
||||
targetsMatchForReplySuppression?: (params: {
|
||||
originTarget: string;
|
||||
targetKey: string;
|
||||
|
||||
@@ -116,4 +116,29 @@ describe("tryDispatchAcpReplyHook", () => {
|
||||
expect(result).toBeUndefined();
|
||||
expect(dispatchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not let ACP claim reset commands before local command handling", async () => {
|
||||
bypassMock.mockResolvedValue(true);
|
||||
dispatchMock.mockResolvedValue(undefined);
|
||||
|
||||
const result = await tryDispatchAcpReplyHook(
|
||||
{
|
||||
...event,
|
||||
ctx: buildTestCtx({
|
||||
SessionKey: "agent:test:session",
|
||||
CommandBody: "/new",
|
||||
BodyForCommands: "/new",
|
||||
BodyForAgent: "/new",
|
||||
}),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(dispatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bypassForCommand: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user