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
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:
89
packages/acp-core/package.json
Normal file
89
packages/acp-core/package.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "@openclaw/acp-core",
|
||||
"version": "0.0.0-private",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./normalize-text": {
|
||||
"types": "./dist/normalize-text.d.mts",
|
||||
"import": "./dist/normalize-text.mjs",
|
||||
"default": "./dist/normalize-text.mjs"
|
||||
},
|
||||
"./meta": {
|
||||
"types": "./dist/meta.d.mts",
|
||||
"import": "./dist/meta.mjs",
|
||||
"default": "./dist/meta.mjs"
|
||||
},
|
||||
"./numeric-options": {
|
||||
"types": "./dist/numeric-options.d.mts",
|
||||
"import": "./dist/numeric-options.mjs",
|
||||
"default": "./dist/numeric-options.mjs"
|
||||
},
|
||||
"./record-shared": {
|
||||
"types": "./dist/record-shared.d.mts",
|
||||
"import": "./dist/record-shared.mjs",
|
||||
"default": "./dist/record-shared.mjs"
|
||||
},
|
||||
"./session": {
|
||||
"types": "./dist/session.d.mts",
|
||||
"import": "./dist/session.mjs",
|
||||
"default": "./dist/session.mjs"
|
||||
},
|
||||
"./session-interaction-mode": {
|
||||
"types": "./dist/session-interaction-mode.d.mts",
|
||||
"import": "./dist/session-interaction-mode.mjs",
|
||||
"default": "./dist/session-interaction-mode.mjs"
|
||||
},
|
||||
"./session-lineage-meta": {
|
||||
"types": "./dist/session-lineage-meta.d.mts",
|
||||
"import": "./dist/session-lineage-meta.mjs",
|
||||
"default": "./dist/session-lineage-meta.mjs"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.mts",
|
||||
"import": "./dist/types.mjs",
|
||||
"default": "./dist/types.mjs"
|
||||
},
|
||||
"./runtime/error-text": {
|
||||
"types": "./dist/runtime/error-text.d.mts",
|
||||
"import": "./dist/runtime/error-text.mjs",
|
||||
"default": "./dist/runtime/error-text.mjs"
|
||||
},
|
||||
"./runtime/errors": {
|
||||
"types": "./dist/runtime/errors.d.mts",
|
||||
"import": "./dist/runtime/errors.mjs",
|
||||
"default": "./dist/runtime/errors.mjs"
|
||||
},
|
||||
"./runtime/session-identifiers": {
|
||||
"types": "./dist/runtime/session-identifiers.d.mts",
|
||||
"import": "./dist/runtime/session-identifiers.mjs",
|
||||
"default": "./dist/runtime/session-identifiers.mjs"
|
||||
},
|
||||
"./runtime/session-identity": {
|
||||
"types": "./dist/runtime/session-identity.d.mts",
|
||||
"import": "./dist/runtime/session-identity.mjs",
|
||||
"default": "./dist/runtime/session-identity.mjs"
|
||||
},
|
||||
"./runtime/types": {
|
||||
"types": "./dist/runtime/types.d.mts",
|
||||
"import": "./dist/runtime/types.mjs",
|
||||
"default": "./dist/runtime/types.mjs"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@openclaw/normalization-core": "workspace:*"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/error-format.ts src/meta.ts src/normalize-text.ts src/numeric-options.ts src/record-shared.ts src/session.ts src/session-interaction-mode.ts src/session-lineage-meta.ts src/types.ts src/runtime/error-text.ts src/runtime/errors.ts src/runtime/session-identifiers.ts src/runtime/session-identity.ts src/runtime/types.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
|
||||
}
|
||||
}
|
||||
36
packages/acp-core/src/error-format.test.ts
Normal file
36
packages/acp-core/src/error-format.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
81
packages/acp-core/src/error-format.ts
Normal file
81
packages/acp-core/src/error-format.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
packages/acp-core/src/index.ts
Normal file
16
packages/acp-core/src/index.ts
Normal 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";
|
||||
25
packages/acp-core/src/meta.test.ts
Normal file
25
packages/acp-core/src/meta.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
packages/acp-core/src/meta.ts
Normal file
55
packages/acp-core/src/meta.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
3
packages/acp-core/src/normalize-text.ts
Normal file
3
packages/acp-core/src/normalize-text.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// ACP text normalization facade shared with older imports.
|
||||
|
||||
export { normalizeOptionalString as normalizeText } from "@openclaw/normalization-core/string-coerce";
|
||||
11
packages/acp-core/src/numeric-options.ts
Normal file
11
packages/acp-core/src/numeric-options.ts
Normal 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);
|
||||
}
|
||||
3
packages/acp-core/src/record-shared.ts
Normal file
3
packages/acp-core/src/record-shared.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// ACP record normalization facade shared with older imports.
|
||||
|
||||
export { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
67
packages/acp-core/src/runtime/error-text.test.ts
Normal file
67
packages/acp-core/src/runtime/error-text.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
48
packages/acp-core/src/runtime/error-text.ts
Normal file
48
packages/acp-core/src/runtime/error-text.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
192
packages/acp-core/src/runtime/errors.test.ts
Normal file
192
packages/acp-core/src/runtime/errors.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
168
packages/acp-core/src/runtime/errors.ts
Normal file
168
packages/acp-core/src/runtime/errors.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
117
packages/acp-core/src/runtime/session-identifiers.test.ts
Normal file
117
packages/acp-core/src/runtime/session-identifiers.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
142
packages/acp-core/src/runtime/session-identifiers.ts
Normal file
142
packages/acp-core/src/runtime/session-identifiers.ts
Normal 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;
|
||||
}
|
||||
261
packages/acp-core/src/runtime/session-identity.ts
Normal file
261
packages/acp-core/src/runtime/session-identity.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
203
packages/acp-core/src/runtime/types.ts
Normal file
203
packages/acp-core/src/runtime/types.ts
Normal 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>;
|
||||
}
|
||||
106
packages/acp-core/src/session-interaction-mode.test.ts
Normal file
106
packages/acp-core/src/session-interaction-mode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
57
packages/acp-core/src/session-interaction-mode.ts
Normal file
57
packages/acp-core/src/session-interaction-mode.ts
Normal 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;
|
||||
}
|
||||
100
packages/acp-core/src/session-lineage-meta.test.ts
Normal file
100
packages/acp-core/src/session-lineage-meta.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
79
packages/acp-core/src/session-lineage-meta.ts
Normal file
79
packages/acp-core/src/session-lineage-meta.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
235
packages/acp-core/src/session.test.ts
Normal file
235
packages/acp-core/src/session.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
213
packages/acp-core/src/session.ts
Normal file
213
packages/acp-core/src/session.ts
Normal 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();
|
||||
96
packages/acp-core/src/types.ts
Normal file
96
packages/acp-core/src/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user