Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
// Error-format helper tests cover the non-Error cause stringifier contract.
import { describe, expect, it } from "vitest";
import {
configureAcpErrorRedactor,
redactSensitiveText,
stringifyNonErrorCause,
} from "./error-format.js";
describe("stringifyNonErrorCause", () => {
it("returns a string for values JSON.stringify serializes to undefined", () => {
// JSON.stringify(fn|symbol|undefined) is undefined; the `string`-typed helper must not leak it.
expect(stringifyNonErrorCause(() => {})).toBe("[object Function]");
expect(stringifyNonErrorCause(Symbol("x"))).toBe("[object Symbol]");
expect(stringifyNonErrorCause(undefined)).toBe("[object Undefined]");
});
it("stringifies ordinary scalar and object causes", () => {
expect(stringifyNonErrorCause({ a: 1 })).toBe('{"a":1}');
expect(stringifyNonErrorCause("hi")).toBe("hi");
expect(stringifyNonErrorCause(42)).toBe("42");
expect(stringifyNonErrorCause(null)).toBe("null");
});
});
describe("redactSensitiveText", () => {
it("applies fallback secret redaction after a configured redactor", () => {
configureAcpErrorRedactor((value) => value.replace("prefix", "host-redacted"));
try {
expect(redactSensitiveText("prefix ghp_123456789012345678901234")).toBe(
"host-redacted [REDACTED]",
);
} finally {
configureAcpErrorRedactor(undefined);
}
});
});

View File

@@ -0,0 +1,81 @@
// ACP Core helper module supports error format behavior.
const SECRET_PATTERNS: RegExp[] = [
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g,
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*\\+(["'])([^\s"'\\]+)\\+\1/g,
/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)=([^&\s"'<>]+)/gi,
/"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token)"\s*:\s*"([^"]+)"/g,
/(^|[\s,{])["']?(?:api[-_]key|access[-_]token|refresh[-_]token|authToken|auth[-_]token|clientSecret|client[-_]secret|appSecret|app[-_]secret)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
/(^|[\s,{])["']?(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
/--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)\s+(["']?)([^\s"']+)\1/gi,
/Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
/Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]+)/gi,
/(?:X-OpenClaw-Token|x-pomerium-jwt-assertion|X-Api-Key|X-Auth-Token)\s*[:=]\s*([^\s"',;]+)/gi,
/\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
/(^|[\s,;])(?:access_token|refresh_token|auth[-_]?token|api[-_]?key|client[-_]?secret|app[-_]?secret|token|secret|password|passwd|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)=([^\s&#]+)/gi,
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
/(ghp_[A-Za-z0-9]{20,})/g,
/(github_pat_[A-Za-z0-9_]{20,})/g,
/(xox[baprs]-[A-Za-z0-9-]{10,})/g,
/(xapp-[A-Za-z0-9-]{10,})/g,
/(gsk_[A-Za-z0-9_-]{10,})/g,
/(AIza[0-9A-Za-z\-_]{20,})/g,
/(ya29\.[0-9A-Za-z_\-./+=]{10,})/g,
/(1\/\/0[0-9A-Za-z_\-./+=]{10,})/g,
/(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g,
/(pplx-[A-Za-z0-9_-]{10,})/g,
/(npm_[A-Za-z0-9]{10,})/g,
/(AKID[A-Za-z0-9]{10,})/g,
/(LTAI[A-Za-z0-9]{10,})/g,
/(hf_[A-Za-z0-9]{10,})/g,
/(r8_[A-Za-z0-9]{10,})/g,
/\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
/\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
];
let configuredRedactor: ((value: string) => string) | undefined;
/** Installs a host-provided redactor used before ACP fallback secret-pattern redaction. */
export function configureAcpErrorRedactor(redactor: ((value: string) => string) | undefined): void {
configuredRedactor = redactor;
}
/** Redacts common provider, GitHub, HTTP, payment, bot, and private-key secrets from error text. */
export function redactSensitiveText(value: string): string {
let redacted = configuredRedactor ? configuredRedactor(value) : value;
for (const pattern of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, (match, ...args: string[]) => {
if (match.includes("PRIVATE KEY-----")) {
return "[REDACTED_PRIVATE_KEY]";
}
const groups = args.slice(0, -2);
// Replace only the captured secret when possible so surrounding diagnostics stay useful.
const token = groups.findLast((group) => typeof group === "string" && group.length > 0);
return token ? match.replace(token, "[REDACTED]") : "[REDACTED]";
});
}
return redacted;
}
/**
* Render a non-Error `cause` value without leaking `[object Object]` or throwing
* while formatting nested ACP runtime failures.
*/
export function stringifyNonErrorCause(value: unknown): string {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
// JSON.stringify returns undefined (not a string) for functions/symbols/undefined; fall back to
// a tag string so this `string`-typed helper never leaks undefined (matches src/infra/errors.ts).
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}

View File

@@ -0,0 +1,16 @@
// Public barrel for shared ACP session, metadata, and runtime helper contracts.
export * from "./error-format.js";
export * from "./meta.js";
export * from "./normalize-text.js";
export * from "./numeric-options.js";
export * from "./record-shared.js";
export * from "./session-interaction-mode.js";
export * from "./session-lineage-meta.js";
export * from "./session.js";
export * from "./types.js";
export * from "./runtime/error-text.js";
export * from "./runtime/errors.js";
export * from "./runtime/session-identifiers.js";
export * from "./runtime/session-identity.js";
export * from "./runtime/types.js";

View File

@@ -0,0 +1,25 @@
// ACP Core tests cover meta behavior.
import { describe, expect, it } from "vitest";
import { readBool, readNonNegativeInteger, readNumber, readString } from "./meta.js";
describe("ACP metadata readers", () => {
it("returns the first normalized string value", () => {
expect(readString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe("session-1");
});
it("preserves false boolean values", () => {
expect(readBool({ enabled: false, fallback: true }, ["enabled", "fallback"])).toBe(false);
});
it("accepts finite numbers and rejects non-numeric values", () => {
expect(readNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0);
expect(readNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined();
});
it("accepts zero as a non-negative integer", () => {
expect(readNonNegativeInteger({ count: 0, fallback: 2 }, ["count", "fallback"])).toBe(0);
expect(
readNonNegativeInteger({ count: -1, fallback: 2.5 }, ["count", "fallback"]),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,55 @@
// ACP Core module implements meta behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
function readMetaValue<T>(
meta: Record<string, unknown> | null | undefined,
keys: string[],
normalize: (value: unknown) => T | undefined,
): T | undefined {
if (!meta) {
return undefined;
}
for (const key of keys) {
const normalized = normalize(meta[key]);
if (normalized !== undefined) {
return normalized;
}
}
return undefined;
}
/** Reads the first present string metadata value from a current-to-legacy key list. */
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
return readMetaValue(meta, keys, normalizeOptionalString);
}
/** Reads the first boolean metadata value without dropping false. */
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
return readMetaValue(meta, keys, (value) => (typeof value === "boolean" ? value : undefined));
}
/** Reads the first finite numeric metadata value from a current-to-legacy key list. */
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
return readMetaValue(meta, keys, (value) =>
typeof value === "number" && Number.isFinite(value) ? value : undefined,
);
}
/** Reads the first safe non-negative integer metadata value, preserving zero. */
export function readNonNegativeInteger(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
return readMetaValue(meta, keys, (value) =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined,
);
}

View File

@@ -0,0 +1,3 @@
// ACP text normalization facade shared with older imports.
export { normalizeOptionalString as normalizeText } from "@openclaw/normalization-core/string-coerce";

View File

@@ -0,0 +1,11 @@
// ACP Core module implements numeric options behavior.
import { resolveIntegerOption as resolveSharedIntegerOption } from "@openclaw/normalization-core/number-coercion";
/** Resolves ACP integer options through the shared normalization contract. */
export function resolveIntegerOption(
value: number | undefined,
fallback: number,
params: { min: number },
): number {
return resolveSharedIntegerOption(value, fallback, params);
}

View File

@@ -0,0 +1,3 @@
// ACP record normalization facade shared with older imports.
export { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce";

View File

@@ -0,0 +1,67 @@
// ACP Core tests cover error text behavior.
import { describe, expect, it } from "vitest";
import { formatAcpRuntimeErrorText, toAcpRuntimeErrorText } from "./error-text.js";
import { AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
describe("formatAcpRuntimeErrorText", () => {
it("adds actionable next steps for known ACP runtime error codes", () => {
const text = formatAcpRuntimeErrorText(
new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"),
);
expect(text).toBe(
"ACP error (ACP_BACKEND_MISSING): backend missing\nnext: Run `/acp doctor`, install/enable the backend plugin, then retry.",
);
});
it("returns consistent ACP error envelope for runtime failures", () => {
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
expect(text).toBe(
"ACP error (ACP_TURN_FAILED): turn failed\nnext: Retry, or use `/acp cancel` and send the message again.",
);
});
it("surfaces redacted numeric RequestError details in runtime failure text", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: `Unknown config option: timeout; token=${token}`,
},
});
const text = formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
);
expect(text).toContain("next: Retry");
expect(text).not.toContain(token);
});
it("applies the same RequestError details normalization through text conversion", () => {
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: "Unknown config option: timeout",
},
});
const text = toAcpRuntimeErrorText({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
);
});
});

View File

@@ -0,0 +1,48 @@
// ACP Core module implements error text behavior.
import { type AcpRuntimeErrorCode, AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefined {
if (error.code === "ACP_BACKEND_MISSING" || error.code === "ACP_BACKEND_UNAVAILABLE") {
return "Run `/acp doctor`, install/enable the backend plugin, then retry.";
}
if (error.code === "ACP_DISPATCH_DISABLED") {
return "Enable `acp.dispatch.enabled=true` to allow thread-message ACP turns.";
}
if (error.code === "ACP_SESSION_INIT_FAILED") {
return "If this session is stale, recreate it with `/acp spawn` and rebind the thread.";
}
if (error.code === "ACP_INVALID_RUNTIME_OPTION") {
return "Use `/acp status` to inspect options and pass valid values.";
}
if (error.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") {
return "This backend does not support that control; use a supported command.";
}
if (error.code === "ACP_TURN_FAILED") {
return "Retry, or use `/acp cancel` and send the message again.";
}
return undefined;
}
/** Formats ACP runtime errors with the operator next-step hint attached when known. */
export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string {
const next = resolveAcpRuntimeErrorNextStep(error);
if (!next) {
return `ACP error (${error.code}): ${error.message}`;
}
return `ACP error (${error.code}): ${error.message}\nnext: ${next}`;
}
/** Normalizes unknown failures into ACP runtime error text for user-facing surfaces. */
export function toAcpRuntimeErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): string {
return formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}

View File

@@ -0,0 +1,192 @@
// ACP Core tests cover errors behavior.
import { afterEach, describe, expect, it } from "vitest";
import { configureAcpErrorRedactor } from "../error-format.js";
import {
AcpRuntimeError,
formatAcpErrorChain,
isAcpRuntimeError,
toAcpRuntimeError,
withAcpRuntimeErrorBoundary,
} from "./errors.js";
async function expectRejectedAcpRuntimeError(promise: Promise<unknown>): Promise<AcpRuntimeError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(AcpRuntimeError);
return error as AcpRuntimeError;
}
throw new Error("expected ACP runtime error rejection");
}
afterEach(() => {
configureAcpErrorRedactor(undefined);
});
describe("withAcpRuntimeErrorBoundary", () => {
it("wraps generic errors with fallback code and source message", async () => {
const sourceError = new Error("boom");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw sourceError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toBe("boom");
expect(error.cause).toBe(sourceError);
});
it("passes through existing ACP runtime errors", async () => {
const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing");
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw existing;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).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");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw foreignError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_BACKEND_MISSING");
expect(error.message).toBe("backend missing");
expect(error.cause).toBe(foreignError);
expect(isAcpRuntimeError(foreignError)).toBe(true);
});
it("preserves redacted RequestError details from numeric ACP errors", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: `unknown config option: timeout; token=${token}`,
},
});
const error = toAcpRuntimeError({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toContain("Internal error: unknown config option: timeout");
expect(error.message).not.toContain(token);
expect(error.cause).toBe(requestError);
});
it("keeps foreign OpenClaw ACP string code behavior unchanged", () => {
const foreignError = Object.assign(new Error("backend missing"), {
code: "ACP_BACKEND_MISSING",
data: {
details: "extra backend diagnostic",
},
});
const error = toAcpRuntimeError({
error: foreignError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_BACKEND_MISSING");
expect(error.message).toBe("backend missing");
expect(error.cause).toBe(foreignError);
});
it("keeps generic non-RequestError messages unchanged", () => {
const sourceError = Object.assign(new Error("boom"), {
data: {
details: "extra diagnostic",
},
});
const error = toAcpRuntimeError({
error: sourceError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toBe("boom");
expect(error.cause).toBe(sourceError);
});
});
describe("formatAcpErrorChain redaction", () => {
it("redacts secret-shaped tokens that arrive as top-level non-Error values", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const out = formatAcpErrorChain(`upstream rejected token=${token}`);
expect(out).toMatch(/upstream rejected/);
expect(out).not.toContain(token);
});
it("redacts secret-shaped tokens that arrive in nested cause messages", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const inner = new Error(`upstream rejected token=${token}`);
const acp = new AcpRuntimeError("ACP_TURN_FAILED", "ACP turn failed", { cause: inner });
const out = formatAcpErrorChain(acp);
expect(out).toMatch(/ACP_TURN_FAILED/);
expect(out).toMatch(/upstream rejected/);
expect(out).not.toContain(token);
});
it("redacts common HTTP, provider, and private-key credentials in ACP error text", () => {
const secrets = [
"Authorization: Basic dXNlcjpwYXNzd29yZGFiY2RlZg==",
"Bearer eyJabcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz",
"github_pat_abcdefghijklmnopqrstuvwxyz123456",
["xoxb", "1234567890", "abcdefghijklmnop"].join("-"),
"bot123456789:abcdefghijklmnopqrstuvwxyz123456",
"-----BEGIN PRIVATE KEY-----\nabcdefghijklmnopqrstuvwxyz\n-----END PRIVATE KEY-----",
];
const out = formatAcpErrorChain(
new AcpRuntimeError("ACP_TURN_FAILED", `backend failed: ${secrets.join(" ")}`),
);
for (const secret of secrets) {
expect(out).not.toContain(secret);
}
expect(out).toContain("backend failed");
});
it("uses a configured host redactor before rendering ACP error text", () => {
configureAcpErrorRedactor((value) => value.replaceAll("custom-secret", "[CUSTOM]"));
const out = formatAcpErrorChain(new AcpRuntimeError("ACP_TURN_FAILED", "custom-secret"));
expect(out).toContain("[CUSTOM]");
expect(out).not.toContain("custom-secret");
});
});

View File

@@ -0,0 +1,168 @@
// ACP Core module implements errors behavior.
import { redactSensitiveText, stringifyNonErrorCause } from "../error-format.js";
export const ACP_ERROR_CODES = [
"ACP_BACKEND_MISSING",
"ACP_BACKEND_UNAVAILABLE",
"ACP_BACKEND_UNSUPPORTED_CONTROL",
"ACP_DISPATCH_DISABLED",
"ACP_INVALID_RUNTIME_OPTION",
"ACP_SESSION_INIT_FAILED",
"ACP_TURN_FAILED",
] as const;
export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number];
const ACP_ERROR_CODE_SET = new Set<AcpRuntimeErrorCode>(ACP_ERROR_CODES);
/** Error type used at ACP runtime boundaries so callers can preserve structured failure codes. */
export class AcpRuntimeError extends Error {
readonly code: AcpRuntimeErrorCode;
/**
* Backend-specific structured failure code (e.g. acpx "SESSION_RESUME_REQUIRED"),
* preserved so recovery decisions key on the failure kind rather than parsing
* the human-readable message.
*/
readonly detailCode?: string;
override readonly cause?: unknown;
constructor(
code: AcpRuntimeErrorCode,
message: string,
options?: { cause?: unknown; detailCode?: string },
) {
super(message);
this.name = "AcpRuntimeError";
this.code = code;
this.detailCode = options?.detailCode;
this.cause = options?.cause;
}
}
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,
};
}
function readAcpRequestErrorDetails(value: Error): string | undefined {
const code = (value as { code?: unknown }).code;
if (typeof code !== "number") {
return undefined;
}
const data = (value as { data?: unknown }).data;
if (!data || typeof data !== "object") {
return undefined;
}
const details = (data as { details?: unknown }).details;
if (details === undefined || details === null) {
return undefined;
}
const rendered = redactSensitiveText(stringifyNonErrorCause(details)).trim();
return rendered.length > 0 ? rendered : undefined;
}
function messageWithAcpRequestErrorDetails(error: Error): string {
const details = readAcpRequestErrorDetails(error);
if (!details || error.message.includes(details)) {
return error.message;
}
return `${error.message}: ${details}`;
}
/** Recognizes local and cross-realm ACP runtime errors by their stable error code. */
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
return value instanceof AcpRuntimeError || getForeignAcpRuntimeError(value) !== null;
}
/** Converts arbitrary thrown values into ACP runtime errors with redacted request details. */
export function toAcpRuntimeError(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): AcpRuntimeError {
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,
messageWithAcpRequestErrorDetails(params.error),
{
cause: params.error,
},
);
}
return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, {
cause: params.error,
});
}
/**
* Render an error and its `.cause` chain as a single human-readable line for
* logs, lifecycle events, and tool results. Format is
* `Name [code]: message <- Name [code]: message <- ...`. Number codes also
* appear, so JSON-RPC error codes like `-32603` survive into surfaces that
* downstream consumers see (gateway logs, telegram replies, tool_result text).
*
* Depth is capped to defend against self-referential `.cause` cycles.
*/
export function formatAcpErrorChain(error: unknown): string {
if (!(error instanceof Error)) {
return redactSensitiveText(String(error));
}
const segments: string[] = [renderSingleError(error)];
let current: unknown = (error as unknown as { cause?: unknown }).cause;
let depth = 0;
while (current !== undefined && current !== null && depth < 8) {
if (current instanceof Error) {
segments.push(renderSingleError(current));
current = (current as unknown as { cause?: unknown }).cause;
} else {
segments.push(stringifyNonErrorCause(current));
current = undefined;
}
depth += 1;
}
return redactSensitiveText(segments.join(" <- "));
}
function renderSingleError(error: Error): string {
const codeValue = (error as unknown as { code?: unknown }).code;
const codeSuffix =
typeof codeValue === "string" || typeof codeValue === "number" ? ` [${codeValue}]` : "";
return `${error.name}${codeSuffix}: ${error.message}`;
}
/** Wraps async runtime work and rethrows failures as ACP runtime errors. */
export async function withAcpRuntimeErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): Promise<T> {
try {
return await params.run();
} catch (error) {
throw toAcpRuntimeError({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
}

View File

@@ -0,0 +1,117 @@
// ACP Core tests cover session identifiers behavior.
import { describe, expect, it } from "vitest";
import {
resolveAcpSessionCwd,
resolveAcpSessionIdentifierLinesFromIdentity,
resolveAcpThreadSessionDetailLines,
} from "./session-identifiers.js";
describe("session identifier helpers", () => {
it("hides unresolved identifiers from thread intro details while pending", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:pending-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "pending",
source: "ensure",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([]);
});
it("adds a Codex resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:resolved-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: inner-123",
"acpx session id: acpx-123",
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
]);
});
it("adds a Kimi resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:kimi:acp:resolved-1",
meta: {
backend: "acpx",
agent: "kimi",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-kimi-123",
agentSessionId: "kimi-inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: kimi-inner-123",
"acpx session id: acpx-kimi-123",
"resume in Kimi CLI: `kimi resume kimi-inner-123` (continues this conversation).",
]);
});
it("shows pending identity text for status rendering", () => {
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: Date.now(),
agentSessionId: "inner-123",
},
});
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
});
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
const cwd = resolveAcpSessionCwd({
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
runtimeOptions: {
cwd: "/repo/new",
},
cwd: "/repo/old",
state: "idle",
lastActivityAt: Date.now(),
});
expect(cwd).toBe("/repo/new");
});
});

View File

@@ -0,0 +1,142 @@
// ACP Core module implements session identifiers behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeText } from "../normalize-text.js";
import type { SessionAcpIdentity, SessionAcpMeta } from "../types.js";
import { isSessionIdentityPending, resolveSessionIdentityFromMeta } from "./session-identity.js";
export const ACP_SESSION_IDENTITY_RENDERER_VERSION = "v1";
export type AcpSessionIdentifierRenderMode = "status" | "thread";
type SessionResumeHintResolver = (params: { agentSessionId: string }) => string;
const ACP_AGENT_RESUME_HINT_BY_KEY = new Map<string, SessionResumeHintResolver>([
[
"codex",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"openai",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"codex-cli",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"kimi",
({ agentSessionId }) =>
`resume in Kimi CLI: \`kimi resume ${agentSessionId}\` (continues this conversation).`,
],
[
"moonshot-kimi",
({ agentSessionId }) =>
`resume in Kimi CLI: \`kimi resume ${agentSessionId}\` (continues this conversation).`,
],
]);
function normalizeAgentHintKey(value: unknown): string | undefined {
const normalized = normalizeText(value);
if (!normalized) {
return undefined;
}
return normalizeLowercaseStringOrEmpty(normalized).replace(/[\s_]+/g, "-");
}
function resolveAcpAgentResumeHintLine(params: {
agentId?: string;
agentSessionId?: string;
}): string | undefined {
const agentSessionId = normalizeText(params.agentSessionId);
const agentKey = normalizeAgentHintKey(params.agentId);
if (!agentSessionId || !agentKey) {
return undefined;
}
const resolver = ACP_AGENT_RESUME_HINT_BY_KEY.get(agentKey);
return resolver ? resolver({ agentSessionId }) : undefined;
}
/** Renders status-safe ACP session identifier lines from persisted session metadata. */
export function resolveAcpSessionIdentifierLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const backend = normalizeText(params.meta?.backend) ?? "backend";
const identity = resolveSessionIdentityFromMeta(params.meta);
return resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "status",
});
}
/** Renders resolved ACP backend/agent ids, hiding pending ids from thread intros. */
export function resolveAcpSessionIdentifierLinesFromIdentity(params: {
backend: string;
identity?: SessionAcpIdentity;
mode?: AcpSessionIdentifierRenderMode;
}): string[] {
const backend = normalizeText(params.backend) ?? "backend";
const mode = params.mode ?? "status";
const identity = params.identity;
const agentSessionId = normalizeText(identity?.agentSessionId);
const acpxSessionId = normalizeText(identity?.acpxSessionId);
const acpxRecordId = normalizeText(identity?.acpxRecordId);
const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId);
if (isSessionIdentityPending(identity) && hasIdentifier) {
// Status views explain that ids are still settling; thread intros stay quiet so
// users do not copy provisional backend ids before the first reply resolves them.
if (mode === "status") {
return ["session ids: pending (available after the first reply)"];
}
return [];
}
const lines: string[] = [];
if (agentSessionId) {
lines.push(`agent session id: ${agentSessionId}`);
}
if (acpxSessionId) {
lines.push(`${backend} session id: ${acpxSessionId}`);
}
if (acpxRecordId) {
lines.push(`${backend} record id: ${acpxRecordId}`);
}
return lines;
}
/** Resolves the runtime cwd, preferring modern runtimeOptions over legacy metadata. */
export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined {
const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd);
if (runtimeCwd) {
return runtimeCwd;
}
return normalizeText(meta?.cwd);
}
/** Renders thread-detail identifier lines plus a backend-specific resume hint when stable. */
export function resolveAcpThreadSessionDetailLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const meta = params.meta;
const identity = resolveSessionIdentityFromMeta(meta);
const backend = normalizeText(meta?.backend) ?? "backend";
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "thread",
});
if (lines.length === 0) {
return lines;
}
const hint = resolveAcpAgentResumeHintLine({
agentId: meta?.agent,
agentSessionId: identity?.agentSessionId,
});
if (hint) {
lines.push(hint);
}
return lines;
}

View File

@@ -0,0 +1,261 @@
// ACP Core module implements session identity behavior.
import { normalizeText } from "../normalize-text.js";
import type { SessionAcpIdentity, SessionAcpIdentitySource, SessionAcpMeta } from "../types.js";
import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js";
// ACP session identity merge and extraction helpers for resume-safe runtime state.
/** Normalize a stored identity state value from metadata. */
function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined {
if (value !== "pending" && value !== "resolved") {
return undefined;
}
return value;
}
/** Normalize where an ACP identity observation came from. */
function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined {
if (value !== "ensure" && value !== "status" && value !== "event") {
return undefined;
}
return value;
}
/** Normalize an identity object and infer pending/resolved state from stable ids. */
function normalizeIdentity(
identity: SessionAcpIdentity | undefined,
): SessionAcpIdentity | undefined {
if (!identity) {
return undefined;
}
const state = normalizeIdentityState(identity.state);
const source = normalizeIdentitySource(identity.source);
const acpxRecordId = normalizeText(identity.acpxRecordId);
const acpxSessionId = normalizeText(identity.acpxSessionId);
const agentSessionId = normalizeText(identity.agentSessionId);
const lastUpdatedAt =
typeof identity.lastUpdatedAt === "number" && Number.isFinite(identity.lastUpdatedAt)
? identity.lastUpdatedAt
: undefined;
const hasAnyId = Boolean(acpxRecordId || acpxSessionId || agentSessionId);
if (!state && !source && !hasAnyId && lastUpdatedAt === undefined) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
const normalizedState = state ?? (resolved ? "resolved" : "pending");
return {
state: normalizedState,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: source ?? "status",
lastUpdatedAt: lastUpdatedAt ?? Date.now(),
};
}
type IdentityIds = Pick<SessionAcpIdentity, "acpxRecordId" | "acpxSessionId" | "agentSessionId">;
/** Read identity ids from a runtime handle shape. */
function readIdentityIdsFromHandle(handle: AcpRuntimeHandle): IdentityIds {
return {
acpxRecordId: normalizeText((handle as { acpxRecordId?: unknown }).acpxRecordId),
acpxSessionId: normalizeText(handle.backendSessionId),
agentSessionId: normalizeText(handle.agentSessionId),
};
}
/** Build an identity only when at least one stable id is known. */
function buildSessionIdentity(params: {
ids: IdentityIds;
state: SessionAcpIdentity["state"];
source: SessionAcpIdentitySource;
now: number;
}): SessionAcpIdentity | undefined {
const { acpxRecordId, acpxSessionId, agentSessionId } = params.ids;
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
return {
state: params.state,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: params.source,
lastUpdatedAt: params.now,
};
}
/** Resolve normalized ACP identity from persisted session metadata. */
export function resolveSessionIdentityFromMeta(
meta: SessionAcpMeta | undefined,
): SessionAcpIdentity | undefined {
if (!meta) {
return undefined;
}
return normalizeIdentity(meta.identity);
}
/** Return true when an identity has a backend or agent session id. */
export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean {
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
}
/** Resolve the runtime resume id, preferring agent session id over ACP backend id. */
export function resolveRuntimeResumeSessionId(
identity: SessionAcpIdentity | undefined,
): string | undefined {
if (!identity) {
return undefined;
}
return normalizeText(identity.agentSessionId) ?? normalizeText(identity.acpxSessionId);
}
/** Return true when identity is absent or still pending. */
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
if (!identity) {
return true;
}
return identity.state === "pending";
}
/** Compare identities ignoring lastUpdatedAt timestamp churn. */
export function identityEquals(
left: SessionAcpIdentity | undefined,
right: SessionAcpIdentity | undefined,
): boolean {
const a = normalizeIdentity(left);
const b = normalizeIdentity(right);
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.state === b.state &&
a.acpxRecordId === b.acpxRecordId &&
a.acpxSessionId === b.acpxSessionId &&
a.agentSessionId === b.agentSessionId &&
a.source === b.source
);
}
/** Merge current and incoming identity observations without downgrading resolved ids. */
export function mergeSessionIdentity(params: {
current: SessionAcpIdentity | undefined;
incoming: SessionAcpIdentity | undefined;
now: number;
}): SessionAcpIdentity | undefined {
const current = normalizeIdentity(params.current);
const incoming = normalizeIdentity(params.incoming);
if (!current) {
if (!incoming) {
return undefined;
}
return { ...incoming, lastUpdatedAt: params.now };
}
if (!incoming) {
return current;
}
const currentResolved = current.state === "resolved";
const incomingResolved = incoming.state === "resolved";
const allowIncomingValue = !currentResolved || incomingResolved;
const nextRecordId =
allowIncomingValue && incoming.acpxRecordId ? incoming.acpxRecordId : current.acpxRecordId;
const nextAcpxSessionId =
allowIncomingValue && incoming.acpxSessionId ? incoming.acpxSessionId : current.acpxSessionId;
const nextAgentSessionId =
allowIncomingValue && incoming.agentSessionId
? incoming.agentSessionId
: current.agentSessionId;
const nextResolved = Boolean(nextAcpxSessionId || nextAgentSessionId);
const nextState: SessionAcpIdentity["state"] = nextResolved
? "resolved"
: currentResolved
? "resolved"
: incoming.state;
const nextSource = allowIncomingValue ? incoming.source : current.source;
const next: SessionAcpIdentity = {
state: nextState,
...(nextRecordId ? { acpxRecordId: nextRecordId } : {}),
...(nextAcpxSessionId ? { acpxSessionId: nextAcpxSessionId } : {}),
...(nextAgentSessionId ? { agentSessionId: nextAgentSessionId } : {}),
source: nextSource,
lastUpdatedAt: params.now,
};
return next;
}
/** Create a pending identity from an ensure-session handle. */
export function createIdentityFromEnsure(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
return buildSessionIdentity({
ids: readIdentityIdsFromHandle(params.handle),
state: "pending",
source: "ensure",
now: params.now,
});
}
/** Create an identity from a runtime event handle. */
export function createIdentityFromHandleEvent(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
const ids = readIdentityIdsFromHandle(params.handle);
return buildSessionIdentity({
ids,
state: ids.agentSessionId ? "resolved" : "pending",
source: "event",
now: params.now,
});
}
/** Create an identity from runtime status output. */
export function createIdentityFromStatus(params: {
status: AcpRuntimeStatus | undefined;
now: number;
}): SessionAcpIdentity | undefined {
if (!params.status) {
return undefined;
}
const details = params.status.details;
const acpxRecordId =
normalizeText((params.status as { acpxRecordId?: unknown }).acpxRecordId) ??
normalizeText(details?.acpxRecordId);
const acpxSessionId =
normalizeText(params.status.backendSessionId) ??
normalizeText(details?.backendSessionId) ??
normalizeText(details?.acpxSessionId);
const agentSessionId =
normalizeText(params.status.agentSessionId) ?? normalizeText(details?.agentSessionId);
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
return {
state: resolved ? "resolved" : "pending",
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: "status",
lastUpdatedAt: params.now,
};
}
/** Convert ACP identity ids into runtime handle resume identifiers. */
export function resolveRuntimeHandleIdentifiersFromIdentity(
identity: SessionAcpIdentity | undefined,
): { backendSessionId?: string; agentSessionId?: string } {
if (!identity) {
return {};
}
return {
...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}),
...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}),
};
}

View File

@@ -0,0 +1,203 @@
// ACP Core type module defines shared TypeScript contracts.
export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeSessionMode = "persistent" | "oneshot";
/** Runtime update tags emitted by ACP adapters; unknown backend tags are passed through. */
export type AcpSessionUpdateTag =
| "agent_message_chunk"
| "agent_thought_chunk"
| "tool_call"
| "tool_call_update"
| "usage_update"
| "available_commands_update"
| "current_mode_update"
| "config_option_update"
| "session_info_update"
| "plan"
| (string & {});
export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status";
/** Stable handle returned by ensureSession and passed back into all ACP runtime operations. */
export type AcpRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
/** Effective runtime working directory for this ACP session, if exposed by adapter/runtime. */
cwd?: string;
/** Backend-local record identifier, if exposed by adapter/runtime (for example acpx record id). */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if exposed by adapter/runtime. */
backendSessionId?: string;
/** Upstream harness session identifier, if exposed by adapter/runtime. */
agentSessionId?: string;
};
export type AcpRuntimeEnsureInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
/** Backend or agent session id to resume when reopening an existing conversation. */
resumeSessionId?: string;
/** Optional runtime model override that must be available during session creation. */
model?: string;
/** Optional runtime thinking/reasoning override that must be available during session creation. */
thinking?: string;
cwd?: string;
env?: Record<string, string>;
};
export type AcpRuntimeTurnAttachment = {
mediaType: string;
data: string;
};
/** Per-turn payload delivered to ACP adapters. */
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;
};
export type AcpRuntimeCapabilities = {
controls: AcpRuntimeControl[];
/**
* Optional backend-advertised option keys for session/set_config_option.
* Empty/undefined means "backend accepts keys, but did not advertise a strict list".
*/
configOptionKeys?: string[];
};
export type AcpRuntimeStatus = {
summary?: string;
/** Backend-local record identifier, if exposed by adapter/runtime. */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if known at status time. */
backendSessionId?: string;
/** Upstream harness session identifier, if known at status time. */
agentSessionId?: string;
details?: Record<string, unknown>;
};
export type AcpRuntimeDoctorReport = {
ok: boolean;
code?: string;
message: string;
installCommand?: string;
details?: string[];
};
/** Streaming event union produced by ACP adapters while a turn is running. */
export type AcpRuntimeEvent =
| {
type: "text_delta";
text: string;
stream?: "output" | "thought";
tag?: AcpSessionUpdateTag;
}
| {
type: "status";
text: string;
tag?: AcpSessionUpdateTag;
used?: number;
size?: number;
}
| {
type: "tool_call";
text: string;
tag?: AcpSessionUpdateTag;
toolCallId?: string;
status?: string;
title?: string;
}
| {
type: "done";
stopReason?: string;
}
| {
type: "error";
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};
export type AcpRuntimeTurnResultError = {
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};
/** Terminal turn result, separated from the live event stream for reliable failure handling. */
export type AcpRuntimeTurnResult =
| {
status: "completed";
stopReason?: string;
}
| {
status: "cancelled";
stopReason?: string;
}
| {
status: "failed";
error: AcpRuntimeTurnResultError;
};
export interface AcpRuntimeTurn {
readonly requestId: string;
readonly events: AsyncIterable<AcpRuntimeEvent>;
readonly result: Promise<AcpRuntimeTurnResult>;
/** Requests backend cancellation while keeping result/error reporting adapter-owned. */
cancel(input?: { reason?: string }): Promise<void>;
/** Closes the event stream when the caller stops listening before terminal result. */
closeStream(input?: { reason?: string }): Promise<void>;
}
/** ACP adapter contract implemented by backend plugins and consumed by gateway/session flows. */
export interface AcpRuntime {
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
/**
* Preferred turn API. Live events are streamed separately from the terminal
* result so adapters can report failures without relying on legacy done/error
* events in the stream.
*/
startTurn?(input: AcpRuntimeTurnInput): AcpRuntimeTurn;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities?(input: {
handle?: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> | AcpRuntimeCapabilities;
getStatus?(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
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;
/**
* Discard backend-owned persistent session state so the next ensureSession
* starts fresh instead of reopening the same conversation.
*/
discardPersistentState?: boolean;
}): Promise<void>;
}

View File

@@ -0,0 +1,106 @@
// ACP Core tests cover session interaction mode behavior.
import { describe, expect, it } from "vitest";
import {
isParentOwnedBackgroundAcpSession,
isRequesterParentOfBackgroundAcpSession,
} from "./session-interaction-mode.js";
const parentKey = "agent:main:main";
const otherKey = "agent:peer:some-other";
describe("isParentOwnedBackgroundAcpSession", () => {
it("returns interactive when entry is undefined", () => {
expect(isParentOwnedBackgroundAcpSession(undefined)).toBe(false);
});
it("returns parent-owned-background for persistent sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns interactive for persistent ACP sessions without parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
}),
).toBe(false);
});
it("returns parent-owned-background for oneshot sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns parent-owned-background for oneshot sessions with parentSessionKey set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
parentSessionKey: parentKey,
}),
).toBe(true);
});
it("returns interactive for a oneshot session without any parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
}),
).toBe(false);
});
});
describe("isRequesterParentOfBackgroundAcpSession", () => {
const backgroundEntry = {
acp: { mode: "oneshot" } as never,
spawnedBy: parentKey,
parentSessionKey: parentKey,
};
it("returns true when requester matches spawnedBy", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, spawnedBy: parentKey },
parentKey,
),
).toBe(true);
});
it("returns true when requester matches parentSessionKey", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, parentSessionKey: parentKey },
parentKey,
),
).toBe(true);
});
it("returns false when requester is a different session (not the parent)", () => {
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, otherKey)).toBe(false);
});
it("returns false when requester key is missing", () => {
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, undefined)).toBe(false);
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, "")).toBe(false);
});
it("returns true when target is parent-owned persistent ACP session", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "persistent" } as never, spawnedBy: parentKey },
parentKey,
),
).toBe(true);
});
it("delegates to isParentOwnedBackgroundAcpSession for target-only checks", () => {
expect(isParentOwnedBackgroundAcpSession(backgroundEntry)).toBe(true);
});
});

View File

@@ -0,0 +1,57 @@
// ACP Core module implements session interaction mode behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
type AcpSessionInteractionMode = "interactive" | "parent-owned-background";
type SessionInteractionEntry = {
spawnedBy?: string;
parentSessionKey?: string;
acp?: unknown;
};
function resolveAcpSessionInteractionMode(
entry?: SessionInteractionEntry | null,
): AcpSessionInteractionMode {
// Parent-owned ACP sessions are background work delegated from another session.
// They should report back through the parent task notifier instead of speaking directly
// on the user-facing channel themselves.
if (!entry?.acp) {
return "interactive";
}
if (normalizeOptionalString(entry.spawnedBy) || normalizeOptionalString(entry.parentSessionKey)) {
return "parent-owned-background";
}
return "interactive";
}
/** Returns true for ACP sessions delegated from a parent session instead of user-facing chat. */
export function isParentOwnedBackgroundAcpSession(entry?: SessionInteractionEntry | null): boolean {
return resolveAcpSessionInteractionMode(entry) === "parent-owned-background";
}
/**
* Returns true when `entry` is a parent-owned background ACP session AND the
* given `requesterSessionKey` is the session that spawned/owns it. This is a
* strictly narrower check than {@link isParentOwnedBackgroundAcpSession}: the
* target must match *and* the caller must be the parent.
*
* Used to gate behaviors that only make sense for the parent↔own-child pair
* (e.g. skipping the A2A ping-pong flow in `sessions_send`), so that an
* unrelated session with broad visibility (e.g. `tools.sessions.visibility=all`)
* sending to the same target is still routed through the normal A2A path.
*/
export function isRequesterParentOfBackgroundAcpSession(
entry: SessionInteractionEntry | null | undefined,
requesterSessionKey: string | null | undefined,
): boolean {
if (!isParentOwnedBackgroundAcpSession(entry)) {
return false;
}
const requester = normalizeOptionalString(requesterSessionKey);
if (!requester) {
return false;
}
const spawnedBy = normalizeOptionalString(entry?.spawnedBy);
const parentSessionKey = normalizeOptionalString(entry?.parentSessionKey);
return requester === spawnedBy || requester === parentSessionKey;
}

View File

@@ -0,0 +1,100 @@
// ACP Core tests cover session lineage meta behavior.
import { describe, expect, it } from "vitest";
import { toAcpSessionLineageMeta, type AcpSessionLineageRow } from "./session-lineage-meta.js";
describe("toAcpSessionLineageMeta", () => {
it("keeps root session metadata minimal", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:main",
kind: "direct",
channel: "telegram",
});
expect(meta).toEqual({
sessionKey: "agent:main:main",
kind: "direct",
channel: "telegram",
});
expect(Object.keys(meta)).toEqual(["sessionKey", "kind", "channel"]);
});
it("maps a one-level child parent key into parentSessionId", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:subagent:child",
kind: "direct",
parentSessionKey: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
expect(meta).toEqual({
sessionKey: "agent:main:subagent:child",
kind: "direct",
parentSessionId: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
});
it("keeps multi-level child lineage and workspace metadata", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:subagent:parent:subagent:leaf",
kind: "direct",
parentSessionKey: "agent:main:subagent:parent",
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
spawnedWorkspaceDir: "/workspace/leaf",
});
expect(meta).toEqual({
sessionKey: "agent:main:subagent:parent:subagent:leaf",
kind: "direct",
parentSessionId: "agent:main:subagent:parent",
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
spawnedWorkspaceDir: "/workspace/leaf",
});
});
it("falls back to spawnedBy for parentSessionId when no explicit parent key is present", () => {
expect(
toAcpSessionLineageMeta({
key: "agent:main:subagent:child",
kind: "direct",
spawnedBy: "agent:main:main",
}),
).toEqual({
sessionKey: "agent:main:subagent:child",
kind: "direct",
parentSessionId: "agent:main:main",
spawnedBy: "agent:main:main",
});
});
it("omits malformed optional lineage values", () => {
const row = {
key: "agent:main:subagent:broken",
kind: "direct",
channel: "",
parentSessionKey: " ",
spawnedBy: 42,
spawnDepth: 1.5,
subagentRole: "worker",
subagentControlScope: "all",
spawnedWorkspaceDir: "",
} as unknown as AcpSessionLineageRow;
expect(toAcpSessionLineageMeta(row)).toEqual({
sessionKey: "agent:main:subagent:broken",
kind: "direct",
});
});
});

View File

@@ -0,0 +1,79 @@
// ACP Core module implements session lineage meta behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
const SUBAGENT_ROLES = ["orchestrator", "leaf"] as const;
const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
type SubagentRole = (typeof SUBAGENT_ROLES)[number];
type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
export type AcpSessionLineageMeta = {
/** Stable session key emitted to ACP clients. */
sessionKey: string;
kind?: string;
channel?: string;
/** Best available parent session id, preferring explicit parentSessionKey over legacy spawnedBy. */
parentSessionId?: string;
spawnedBy?: string;
spawnDepth?: number;
subagentRole?: SubagentRole;
subagentControlScope?: SubagentControlScope;
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
};
export type AcpSessionLineageRow = {
/** Raw persisted session key; kept even when other optional fields are malformed. */
key: string;
kind?: string;
channel?: string;
parentSessionKey?: string;
spawnedBy?: string;
spawnDepth?: number;
subagentRole?: string;
subagentControlScope?: string;
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
};
function readInteger(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
return undefined;
}
return value;
}
function readEnum<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
const normalized = normalizeOptionalString(value);
return allowed.find((candidate) => candidate === normalized);
}
/** Converts persisted session rows into compact ACP lineage metadata for protocol responses. */
export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLineageMeta {
const sessionKey = normalizeOptionalString(row.key) ?? row.key;
const kind = normalizeOptionalString(row.kind);
const channel = normalizeOptionalString(row.channel);
// Older rows may only carry spawnedBy; expose it as parentSessionId so ACP clients
// can follow lineage without knowing which storage-era field populated it.
const parentSessionId =
normalizeOptionalString(row.parentSessionKey) ?? normalizeOptionalString(row.spawnedBy);
const spawnedBy = normalizeOptionalString(row.spawnedBy);
const spawnDepth = readInteger(row.spawnDepth);
const subagentRole = readEnum(row.subagentRole, SUBAGENT_ROLES);
const subagentControlScope = readEnum(row.subagentControlScope, SUBAGENT_CONTROL_SCOPES);
const spawnedWorkspaceDir = normalizeOptionalString(row.spawnedWorkspaceDir);
const spawnedCwd = normalizeOptionalString(row.spawnedCwd);
return {
sessionKey,
...(kind ? { kind } : {}),
...(channel ? { channel } : {}),
...(parentSessionId ? { parentSessionId } : {}),
...(spawnedBy ? { spawnedBy } : {}),
...(spawnDepth !== undefined ? { spawnDepth } : {}),
...(subagentRole ? { subagentRole } : {}),
...(subagentControlScope ? { subagentControlScope } : {}),
...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
};
}

View File

@@ -0,0 +1,235 @@
// ACP Core tests cover session behavior.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
let nowMs = 0;
const now = () => nowMs;
const advance = (ms: number) => {
nowMs += ms;
};
let store = createInMemorySessionStore({ now });
beforeEach(() => {
nowMs = 1_000;
store = createInMemorySessionStore({ now });
});
afterEach(() => {
store.clearAllSessionsForTest();
});
it("tracks active runs and clears on cancel", () => {
const session = store.createSession({
sessionKey: "acp:test",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-1", controller);
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
const cancelled = store.cancelActiveRun(session.sessionId);
expect(cancelled).toBe(true);
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
it("removes stale run lookup entries when rebinding an active run", () => {
const session = store.createSession({
sessionKey: "acp:rebind",
cwd: "/tmp",
});
store.setActiveRun(session.sessionId, "run-old", new AbortController());
store.setActiveRun(session.sessionId, "run-new", new AbortController());
expect(store.getSessionByRunId("run-old")).toBeUndefined();
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
});
it("deletes sessions and aborts active runs on close", () => {
const session = store.createSession({
sessionId: "close-me",
sessionKey: "acp:close",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-close", controller);
expect(store.deleteSession(session.sessionId)).toBe(true);
expect(controller.signal.aborted).toBe(true);
expect(store.hasSession(session.sessionId)).toBe(false);
expect(store.getSessionByRunId("run-close")).toBeUndefined();
});
it("reports false when deleting a missing session", () => {
expect(store.deleteSession("missing")).toBe(false);
});
it("refreshes existing session IDs instead of creating duplicates", () => {
const first = store.createSession({
sessionId: "existing",
sessionKey: "acp:one",
cwd: "/tmp/one",
});
advance(500);
const refreshed = store.createSession({
sessionId: "existing",
sessionKey: "acp:two",
cwd: "/tmp/two",
});
expect(refreshed).toBe(first);
expect(refreshed.sessionKey).toBe("acp:two");
expect(refreshed.cwd).toBe("/tmp/two");
expect(refreshed.createdAt).toBe(1_000);
expect(refreshed.lastTouchedAt).toBe(1_500);
expect(store.hasSession("existing")).toBe(true);
});
it("falls back for non-finite idle TTL options", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 2,
idleTtlMs: Number.NaN,
now,
});
try {
boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(1);
boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
expect(boundedStore.hasSession("first")).toBe(true);
expect(boundedStore.hasSession("second")).toBe(true);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("falls back for non-finite max session options", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: Number.NaN,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
for (let index = 0; index < 5_000; index += 1) {
const session = boundedStore.createSession({
sessionId: `session-${index}`,
sessionKey: `acp:${index}`,
cwd: "/tmp",
});
boundedStore.setActiveRun(session.sessionId, `run-${index}`, new AbortController());
}
expect(() =>
boundedStore.createSession({
sessionId: "overflow",
sessionKey: "acp:overflow",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("reaps idle sessions before enforcing the max session cap", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 1,
idleTtlMs: 1_000,
now,
});
try {
boundedStore.createSession({
sessionId: "old",
sessionKey: "acp:old",
cwd: "/tmp",
});
advance(2_000);
const fresh = boundedStore.createSession({
sessionId: "fresh",
sessionKey: "acp:fresh",
cwd: "/tmp",
});
expect(fresh.sessionId).toBe("fresh");
expect(boundedStore.getSession("old")).toBeUndefined();
expect(boundedStore.hasSession("old")).toBe(false);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("uses soft-cap eviction for the oldest idle session when full", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 2,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
const first = boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(100);
const second = boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
const controller = new AbortController();
boundedStore.setActiveRun(second.sessionId, "run-2", controller);
advance(100);
const third = boundedStore.createSession({
sessionId: "third",
sessionKey: "acp:third",
cwd: "/tmp",
});
expect(third.sessionId).toBe("third");
expect(boundedStore.getSession(first.sessionId)).toBeUndefined();
const retainedSession = boundedStore.getSession(second.sessionId);
expect(retainedSession?.sessionId).toBe("second");
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("rejects when full and no session is evictable", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 1,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
const only = boundedStore.createSession({
sessionId: "only",
sessionKey: "acp:only",
cwd: "/tmp",
});
boundedStore.setActiveRun(only.sessionId, "run-only", new AbortController());
expect(() =>
boundedStore.createSession({
sessionId: "next",
sessionKey: "acp:next",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
});

View File

@@ -0,0 +1,213 @@
// ACP Core module implements session behavior.
import { randomUUID } from "node:crypto";
import { resolveIntegerOption } from "./numeric-options.js";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
/** Creates or refreshes an in-memory ACP session under the supplied session id. */
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
ledgerSessionId?: string;
}) => AcpSession;
hasSession: (sessionId: string) => boolean;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
/** Binds an active runtime run to a session so cancel/close can abort it later. */
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
clearActiveRun: (sessionId: string) => void;
cancelActiveRun: (sessionId: string) => boolean;
deleteSession: (sessionId: string) => boolean;
clearAllSessionsForTest: () => void;
};
type AcpSessionStoreOptions = {
maxSessions?: number;
idleTtlMs?: number;
now?: () => number;
};
const DEFAULT_MAX_SESSIONS = 5_000;
const DEFAULT_IDLE_TTL_MS = 24 * 60 * 60 * 1_000;
/** Creates the bounded in-memory ACP session registry used by local ACP runtime clients. */
export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}): AcpSessionStore {
const maxSessions = resolveIntegerOption(options.maxSessions, DEFAULT_MAX_SESSIONS, { min: 1 });
const idleTtlMs = resolveIntegerOption(options.idleTtlMs, DEFAULT_IDLE_TTL_MS, { min: 1_000 });
const now = options.now ?? Date.now;
const sessions = new Map<string, AcpSession>();
const runIdToSessionId = new Map<string, string>();
const touchSession = (session: AcpSession, nowMs: number) => {
session.lastTouchedAt = nowMs;
};
const removeSession = (sessionId: string) => {
const session = sessions.get(sessionId);
if (!session) {
return false;
}
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.abortController?.abort();
sessions.delete(sessionId);
return true;
};
const reapIdleSessions = (nowMs: number) => {
const idleBefore = nowMs - idleTtlMs;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) {
continue;
}
if (session.lastTouchedAt > idleBefore) {
continue;
}
removeSession(sessionId);
}
};
const evictOldestIdleSession = () => {
let oldestSessionId: string | null = null;
let oldestLastTouchedAt = Number.POSITIVE_INFINITY;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) {
continue;
}
if (session.lastTouchedAt >= oldestLastTouchedAt) {
continue;
}
oldestLastTouchedAt = session.lastTouchedAt;
oldestSessionId = sessionId;
}
if (!oldestSessionId) {
return false;
}
return removeSession(oldestSessionId);
};
const createSession: AcpSessionStore["createSession"] = (params) => {
const nowMs = now();
const sessionId = params.sessionId ?? randomUUID();
const existingSession = sessions.get(sessionId);
if (existingSession) {
existingSession.sessionKey = params.sessionKey;
if ("ledgerSessionId" in params) {
existingSession.ledgerSessionId = params.ledgerSessionId;
}
existingSession.cwd = params.cwd;
touchSession(existingSession, nowMs);
return existingSession;
}
reapIdleSessions(nowMs);
// Active runs are never evicted to make cancellation ownership explicit; callers must
// clear/cancel them before the soft cap can make room.
if (sessions.size >= maxSessions && !evictOldestIdleSession()) {
throw new Error(
`ACP session limit reached (max ${maxSessions}). Close idle ACP clients and retry.`,
);
}
const session: AcpSession = {
sessionId,
sessionKey: params.sessionKey,
...(params.ledgerSessionId ? { ledgerSessionId: params.ledgerSessionId } : {}),
cwd: params.cwd,
createdAt: nowMs,
lastTouchedAt: nowMs,
abortController: null,
activeRunId: null,
};
sessions.set(sessionId, session);
return session;
};
const hasSession: AcpSessionStore["hasSession"] = (sessionId) => sessions.has(sessionId);
const getSession: AcpSessionStore["getSession"] = (sessionId) => {
const session = sessions.get(sessionId);
if (session) {
touchSession(session, now());
}
return session;
};
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
const sessionId = runIdToSessionId.get(runId);
if (!sessionId) {
return undefined;
}
const session = sessions.get(sessionId);
if (session) {
touchSession(session, now());
}
return session;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (sessionId, runId, abortController) => {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (session.activeRunId && session.activeRunId !== runId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
touchSession(session, now());
};
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = null;
session.abortController = null;
touchSession(session, now());
};
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) {
return false;
}
session.abortController.abort();
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.abortController = null;
session.activeRunId = null;
touchSession(session, now());
return true;
};
const deleteSession: AcpSessionStore["deleteSession"] = (sessionId) => removeSession(sessionId);
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
for (const session of sessions.values()) {
session.abortController?.abort();
}
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
hasSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
deleteSession,
clearAllSessionsForTest,
};
}
export const defaultAcpSessionStore = createInMemorySessionStore();

View File

@@ -0,0 +1,96 @@
// ACP Core type module defines shared TypeScript contracts.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
export type SessionId = string;
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
export function normalizeAcpProvenanceMode(
value: string | undefined,
): AcpProvenanceMode | undefined {
const normalized = normalizeOptionalLowercaseString(value);
if (!normalized) {
return undefined;
}
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
? (normalized as AcpProvenanceMode)
: undefined;
}
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
ledgerSessionId?: string;
cwd: string;
createdAt: number;
lastTouchedAt: number;
abortController: AbortController | null;
activeRunId: string | null;
};
export type AcpServerOptions = {
gatewayUrl?: string;
gatewayToken?: string;
gatewayPassword?: string;
defaultSessionKey?: string;
defaultSessionLabel?: string;
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
provenanceMode?: AcpProvenanceMode;
sessionCreateRateLimit?: {
maxRequests?: number;
windowMs?: number;
};
verbose?: boolean;
};
export type SessionAcpIdentitySource = "ensure" | "status" | "event";
export type SessionAcpIdentityState = "pending" | "resolved";
export type SessionAcpIdentity = {
/** Pending identities may expose provisional ids; resolved identities are safe for resume output. */
state: SessionAcpIdentityState;
acpxRecordId?: string;
acpxSessionId?: string;
agentSessionId?: string;
/** Runtime lifecycle point that last supplied the identity fields. */
source: SessionAcpIdentitySource;
lastUpdatedAt: number;
};
export type AcpSessionRuntimeOptions = {
/**
* ACP runtime mode set via session/set_mode (for example: "plan", "normal", "auto").
*/
runtimeMode?: string;
/** ACP runtime config option: model id. */
model?: string;
/** ACP runtime config option: thinking/reasoning effort. */
thinking?: string;
/** Working directory override for ACP session turns. */
cwd?: string;
/** ACP runtime config option: permission profile id. */
permissionProfile?: string;
/** ACP runtime config option: per-turn timeout in seconds. */
timeoutSeconds?: number;
/** Backend-specific option bag mapped through session/set_config_option. */
backendExtras?: Record<string, string>;
};
export type SessionAcpMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
/** Canonical backend/agent ids used for resume hints and thread/status details. */
identity?: SessionAcpIdentity;
mode: "persistent" | "oneshot";
runtimeOptions?: AcpSessionRuntimeOptions;
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};