feat: ACP thread-bound agents (#23580)

* docs: add ACP thread-bound agents plan doc

* docs: expand ACP implementation specification

* feat(acp): route ACP sessions through core dispatch and lifecycle cleanup

* feat(acp): add /acp commands and Discord spawn gate

* ACP: add acpx runtime plugin backend

* fix(subagents): defer transient lifecycle errors before announce

* Agents: harden ACP sessions_spawn and tighten spawn guidance

* Agents: require explicit ACP target for runtime spawns

* docs: expand ACP control-plane implementation plan

* ACP: harden metadata seeding and spawn guidance

* ACP: centralize runtime control-plane manager and fail-closed dispatch

* ACP: harden runtime manager and unify spawn helpers

* Commands: route ACP sessions through ACP runtime in agent command

* ACP: require persisted metadata for runtime spawns

* Sessions: preserve ACP metadata when updating entries

* Plugins: harden ACP backend registry across loaders

* ACPX: make availability probe compatible with adapters

* E2E: add manual Discord ACP plain-language smoke script

* ACPX: preserve streamed spacing across Discord delivery

* Docs: add ACP Discord streaming strategy

* ACP: harden Discord stream buffering for thread replies

* ACP: reuse shared block reply pipeline for projector

* ACP: unify streaming config and adopt coalesceIdleMs

* Docs: add temporary ACP production hardening plan

* Docs: trim temporary ACP hardening plan goals

* Docs: gate ACP thread controls by backend capabilities

* ACP: add capability-gated runtime controls and /acp operator commands

* Docs: remove temporary ACP hardening plan

* ACP: fix spawn target validation and close cache cleanup

* ACP: harden runtime dispatch and recovery paths

* ACP: split ACP command/runtime internals and centralize policy

* ACP: harden runtime lifecycle, validation, and observability

* ACP: surface runtime and backend session IDs in thread bindings

* docs: add temp plan for binding-service migration

* ACP: migrate thread binding flows to SessionBindingService

* ACP: address review feedback and preserve prompt wording

* ACPX plugin: pin runtime dependency and prefer bundled CLI

* Discord: complete binding-service migration cleanup and restore ACP plan

* Docs: add standalone ACP agents guide

* ACP: route harness intents to thread-bound ACP sessions

* ACP: fix spawn thread routing and queue-owner stall

* ACP: harden startup reconciliation and command bypass handling

* ACP: fix dispatch bypass type narrowing

* ACP: align runtime metadata to agentSessionId

* ACP: normalize session identifier handling and labels

* ACP: mark thread banner session ids provisional until first reply

* ACP: stabilize session identity mapping and startup reconciliation

* ACP: add resolved session-id notices and cwd in thread intros

* Discord: prefix thread meta notices consistently

* Discord: unify ACP/thread meta notices with gear prefix

* Discord: split thread persona naming from meta formatting

* Extensions: bump acpx plugin dependency to 0.1.9

* Agents: gate ACP prompt guidance behind acp.enabled

* Docs: remove temp experiment plan docs

* Docs: scope streaming plan to holy grail refactor

* Docs: refactor ACP agents guide for human-first flow

* Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow

* Docs/Skill: add OpenCode and Pi to ACP harness lists

* Docs/Skill: align ACP harness list with current acpx registry

* Dev/Test: move ACP plain-language smoke script and mark as keep

* Docs/Skill: reorder ACP harness lists with Pi first

* ACP: split control-plane manager into core/types/utils modules

* Docs: refresh ACP thread-bound agents plan

* ACP: extract dispatch lane and split manager domains

* ACP: centralize binding context and remove reverse deps

* Infra: unify system message formatting

* ACP: centralize error boundaries and session id rendering

* ACP: enforce init concurrency cap and strict meta clear

* Tests: fix ACP dispatch binding mock typing

* Tests: fix Discord thread-binding mock drift and ACP request id

* ACP: gate slash bypass and persist cleared overrides

* ACPX: await pre-abort cancel before runTurn return

* Extension: pin acpx runtime dependency to 0.1.11

* Docs: add pinned acpx install strategy for ACP extension

* Extensions/acpx: enforce strict local pinned startup

* Extensions/acpx: tighten acp-router install guidance

* ACPX: retry runtime test temp-dir cleanup

* Extensions/acpx: require proactive ACPX repair for thread spawns

* Extensions/acpx: require restart offer after acpx reinstall

* extensions/acpx: remove workspace protocol devDependency

* extensions/acpx: bump pinned acpx to 0.1.13

* extensions/acpx: sync lockfile after dependency bump

* ACPX: make runtime spawn Windows-safe

* fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
Onur Solmaz
2026-02-26 11:00:09 +01:00
committed by GitHub
parent a9d9a968ed
commit a7d56e3554
151 changed files with 19005 additions and 324 deletions

View File

@@ -0,0 +1,114 @@
import { randomUUID } from "node:crypto";
import { expect } from "vitest";
import { toAcpRuntimeError } from "./errors.js";
import type { AcpRuntime, AcpRuntimeEvent } from "./types.js";
export type AcpRuntimeAdapterContractParams = {
createRuntime: () => Promise<AcpRuntime> | AcpRuntime;
agentId?: string;
successPrompt?: string;
errorPrompt?: string;
assertSuccessEvents?: (events: AcpRuntimeEvent[]) => void | Promise<void>;
assertErrorOutcome?: (params: {
events: AcpRuntimeEvent[];
thrown: unknown;
}) => void | Promise<void>;
};
export async function runAcpRuntimeAdapterContract(
params: AcpRuntimeAdapterContractParams,
): Promise<void> {
const runtime = await params.createRuntime();
const sessionKey = `agent:${params.agentId ?? "codex"}:acp:contract-${randomUUID()}`;
const agent = params.agentId ?? "codex";
const handle = await runtime.ensureSession({
sessionKey,
agent,
mode: "persistent",
});
expect(handle.sessionKey).toBe(sessionKey);
expect(handle.backend.trim()).not.toHaveLength(0);
expect(handle.runtimeSessionName.trim()).not.toHaveLength(0);
const successEvents: AcpRuntimeEvent[] = [];
for await (const event of runtime.runTurn({
handle,
text: params.successPrompt ?? "contract-success",
mode: "prompt",
requestId: `contract-success-${randomUUID()}`,
})) {
successEvents.push(event);
}
expect(
successEvents.some(
(event) =>
event.type === "done" ||
event.type === "text_delta" ||
event.type === "status" ||
event.type === "tool_call",
),
).toBe(true);
await params.assertSuccessEvents?.(successEvents);
if (runtime.getStatus) {
const status = await runtime.getStatus({ handle });
expect(status).toBeDefined();
expect(typeof status).toBe("object");
}
if (runtime.setMode) {
await runtime.setMode({
handle,
mode: "contract",
});
}
if (runtime.setConfigOption) {
await runtime.setConfigOption({
handle,
key: "contract_key",
value: "contract_value",
});
}
let errorThrown: unknown = null;
const errorEvents: AcpRuntimeEvent[] = [];
const errorPrompt = params.errorPrompt?.trim();
if (errorPrompt) {
try {
for await (const event of runtime.runTurn({
handle,
text: errorPrompt,
mode: "prompt",
requestId: `contract-error-${randomUUID()}`,
})) {
errorEvents.push(event);
}
} catch (error) {
errorThrown = error;
}
const sawErrorEvent = errorEvents.some((event) => event.type === "error");
expect(Boolean(errorThrown) || sawErrorEvent).toBe(true);
if (errorThrown) {
const acpError = toAcpRuntimeError({
error: errorThrown,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "ACP runtime contract expected an error turn failure.",
});
expect(acpError.code.length).toBeGreaterThan(0);
expect(acpError.message.length).toBeGreaterThan(0);
}
}
await params.assertErrorOutcome?.({
events: errorEvents,
thrown: errorThrown,
});
await runtime.cancel({
handle,
reason: "contract-cancel",
});
await runtime.close({
handle,
reason: "contract-close",
});
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { formatAcpRuntimeErrorText } from "./error-text.js";
import { AcpRuntimeError } 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).toContain("ACP error (ACP_BACKEND_MISSING): backend missing");
expect(text).toContain("next:");
});
it("returns consistent ACP error envelope for runtime failures", () => {
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
expect(text).toContain("ACP error (ACP_TURN_FAILED): turn failed");
expect(text).toContain("next:");
});
});

View File

@@ -0,0 +1,45 @@
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;
}
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}`;
}
export function toAcpRuntimeErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): string {
return formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { AcpRuntimeError, withAcpRuntimeErrorBoundary } from "./errors.js";
describe("withAcpRuntimeErrorBoundary", () => {
it("wraps generic errors with fallback code and source message", async () => {
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw new Error("boom");
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).rejects.toMatchObject({
name: "AcpRuntimeError",
code: "ACP_TURN_FAILED",
message: "boom",
});
});
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);
});
});

61
src/acp/runtime/errors.ts Normal file
View File

@@ -0,0 +1,61 @@
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];
export class AcpRuntimeError extends Error {
readonly code: AcpRuntimeErrorCode;
override readonly cause?: unknown;
constructor(code: AcpRuntimeErrorCode, message: string, options?: { cause?: unknown }) {
super(message);
this.name = "AcpRuntimeError";
this.code = code;
this.cause = options?.cause;
}
}
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
return value instanceof AcpRuntimeError;
}
export function toAcpRuntimeError(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): AcpRuntimeError {
if (params.error instanceof AcpRuntimeError) {
return params.error;
}
if (params.error instanceof Error) {
return new AcpRuntimeError(params.fallbackCode, params.error.message, {
cause: params.error,
});
}
return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, {
cause: params.error,
});
}
export async function withAcpRuntimeErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): Promise<T> {
try {
return await params.run();
} catch (error) {
throw toAcpRuntimeError({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
}

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AcpRuntimeError } from "./errors.js";
import {
__testing,
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
requireAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "./registry.js";
import type { AcpRuntime } from "./types.js";
function createRuntimeStub(): AcpRuntime {
return {
ensureSession: vi.fn(async (input) => ({
sessionKey: input.sessionKey,
backend: "stub",
runtimeSessionName: `${input.sessionKey}:runtime`,
})),
runTurn: vi.fn(async function* () {
// no-op stream
}),
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
};
}
describe("acp runtime registry", () => {
beforeEach(() => {
__testing.resetAcpRuntimeBackendsForTests();
});
it("registers and resolves backends by id", () => {
const runtime = createRuntimeStub();
registerAcpRuntimeBackend({ id: "acpx", runtime });
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.id).toBe("acpx");
expect(backend?.runtime).toBe(runtime);
});
it("prefers a healthy backend when resolving without explicit id", () => {
const unhealthyRuntime = createRuntimeStub();
const healthyRuntime = createRuntimeStub();
registerAcpRuntimeBackend({
id: "unhealthy",
runtime: unhealthyRuntime,
healthy: () => false,
});
registerAcpRuntimeBackend({
id: "healthy",
runtime: healthyRuntime,
healthy: () => true,
});
const backend = getAcpRuntimeBackend();
expect(backend?.id).toBe("healthy");
});
it("throws a typed missing-backend error when no backend is registered", () => {
expect(() => requireAcpRuntimeBackend()).toThrowError(AcpRuntimeError);
expect(() => requireAcpRuntimeBackend()).toThrowError(/ACP runtime backend is not configured/i);
});
it("throws a typed unavailable error when the requested backend is unhealthy", () => {
registerAcpRuntimeBackend({
id: "acpx",
runtime: createRuntimeStub(),
healthy: () => false,
});
try {
requireAcpRuntimeBackend("acpx");
throw new Error("expected requireAcpRuntimeBackend to throw");
} catch (err) {
expect(err).toBeInstanceOf(AcpRuntimeError);
expect((err as AcpRuntimeError).code).toBe("ACP_BACKEND_UNAVAILABLE");
}
});
it("unregisters a backend by id", () => {
registerAcpRuntimeBackend({ id: "acpx", runtime: createRuntimeStub() });
unregisterAcpRuntimeBackend("acpx");
expect(getAcpRuntimeBackend("acpx")).toBeNull();
});
it("keeps backend state on a global registry for cross-loader access", () => {
const runtime = createRuntimeStub();
const sharedState = __testing.getAcpRuntimeRegistryGlobalStateForTests();
sharedState.backendsById.set("acpx", {
id: "acpx",
runtime,
});
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.runtime).toBe(runtime);
});
});

118
src/acp/runtime/registry.ts Normal file
View File

@@ -0,0 +1,118 @@
import { AcpRuntimeError } from "./errors.js";
import type { AcpRuntime } from "./types.js";
export type AcpRuntimeBackend = {
id: string;
runtime: AcpRuntime;
healthy?: () => boolean;
};
type AcpRuntimeRegistryGlobalState = {
backendsById: Map<string, AcpRuntimeBackend>;
};
const ACP_RUNTIME_REGISTRY_STATE_KEY = Symbol.for("openclaw.acpRuntimeRegistryState");
function createAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
return {
backendsById: new Map<string, AcpRuntimeBackend>(),
};
}
function resolveAcpRuntimeRegistryGlobalState(): AcpRuntimeRegistryGlobalState {
const runtimeGlobal = globalThis as typeof globalThis & {
[ACP_RUNTIME_REGISTRY_STATE_KEY]?: AcpRuntimeRegistryGlobalState;
};
if (!runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY]) {
runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY] = createAcpRuntimeRegistryGlobalState();
}
return runtimeGlobal[ACP_RUNTIME_REGISTRY_STATE_KEY];
}
const ACP_BACKENDS_BY_ID = resolveAcpRuntimeRegistryGlobalState().backendsById;
function normalizeBackendId(id: string | undefined): string {
return id?.trim().toLowerCase() || "";
}
function isBackendHealthy(backend: AcpRuntimeBackend): boolean {
if (!backend.healthy) {
return true;
}
try {
return backend.healthy();
} catch {
return false;
}
}
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void {
const id = normalizeBackendId(backend.id);
if (!id) {
throw new Error("ACP runtime backend id is required");
}
if (!backend.runtime) {
throw new Error(`ACP runtime backend "${id}" is missing runtime implementation`);
}
ACP_BACKENDS_BY_ID.set(id, {
...backend,
id,
});
}
export function unregisterAcpRuntimeBackend(id: string): void {
const normalized = normalizeBackendId(id);
if (!normalized) {
return;
}
ACP_BACKENDS_BY_ID.delete(normalized);
}
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null {
const normalized = normalizeBackendId(id);
if (normalized) {
return ACP_BACKENDS_BY_ID.get(normalized) ?? null;
}
if (ACP_BACKENDS_BY_ID.size === 0) {
return null;
}
for (const backend of ACP_BACKENDS_BY_ID.values()) {
if (isBackendHealthy(backend)) {
return backend;
}
}
return ACP_BACKENDS_BY_ID.values().next().value ?? null;
}
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend {
const normalized = normalizeBackendId(id);
const backend = getAcpRuntimeBackend(normalized || undefined);
if (!backend) {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
);
}
if (!isBackendHealthy(backend)) {
throw new AcpRuntimeError(
"ACP_BACKEND_UNAVAILABLE",
"ACP runtime backend is currently unavailable. Try again in a moment.",
);
}
if (normalized && backend.id !== normalized) {
throw new AcpRuntimeError(
"ACP_BACKEND_MISSING",
`ACP runtime backend "${normalized}" is not registered.`,
);
}
return backend;
}
export const __testing = {
resetAcpRuntimeBackendsForTests() {
ACP_BACKENDS_BY_ID.clear();
},
getAcpRuntimeRegistryGlobalStateForTests() {
return resolveAcpRuntimeRegistryGlobalState();
},
};

View File

@@ -0,0 +1,89 @@
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).toEqual([]);
});
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).toContain("agent session id: inner-123");
expect(lines).toContain("acpx session id: acpx-123");
expect(lines).toContain(
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
);
});
it("shows pending identity text for status rendering", () => {
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: Date.now(),
agentSessionId: "inner-123",
},
});
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
});
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
const cwd = resolveAcpSessionCwd({
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
runtimeOptions: {
cwd: "/repo/new",
},
cwd: "/repo/old",
state: "idle",
lastActivityAt: Date.now(),
});
expect(cwd).toBe("/repo/new");
});
});

View File

@@ -0,0 +1,131 @@
import type { SessionAcpIdentity, SessionAcpMeta } from "../../config/sessions/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-codex",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"codex-cli",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
]);
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeAgentHintKey(value: unknown): string | undefined {
const normalized = normalizeText(value);
if (!normalized) {
return undefined;
}
return normalized.toLowerCase().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;
}
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",
});
}
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) {
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;
}
export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined {
const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd);
if (runtimeCwd) {
return runtimeCwd;
}
return normalizeText(meta?.cwd);
}
export function resolveAcpThreadSessionDetailLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const meta = params.meta;
const identity = resolveSessionIdentityFromMeta(meta);
const backend = normalizeText(meta?.backend) ?? "backend";
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "thread",
});
if (lines.length === 0) {
return lines;
}
const hint = resolveAcpAgentResumeHintLine({
agentId: meta?.agent,
agentSessionId: identity?.agentSessionId,
});
if (hint) {
lines.push(hint);
}
return lines;
}

View File

@@ -0,0 +1,210 @@
import type {
SessionAcpIdentity,
SessionAcpIdentitySource,
SessionAcpMeta,
} from "../../config/sessions/types.js";
import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js";
function normalizeText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined {
if (value !== "pending" && value !== "resolved") {
return undefined;
}
return value;
}
function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined {
if (value !== "ensure" && value !== "status" && value !== "event") {
return undefined;
}
return value;
}
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(),
};
}
export function resolveSessionIdentityFromMeta(
meta: SessionAcpMeta | undefined,
): SessionAcpIdentity | undefined {
if (!meta) {
return undefined;
}
return normalizeIdentity(meta.identity);
}
export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean {
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
}
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
if (!identity) {
return true;
}
return identity.state === "pending";
}
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
);
}
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;
}
export function createIdentityFromEnsure(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
const acpxRecordId = normalizeText((params.handle as { acpxRecordId?: unknown }).acpxRecordId);
const acpxSessionId = normalizeText(params.handle.backendSessionId);
const agentSessionId = normalizeText(params.handle.agentSessionId);
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
return {
state: "pending",
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: "ensure",
lastUpdatedAt: params.now,
};
}
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,
};
}
export function resolveRuntimeHandleIdentifiersFromIdentity(
identity: SessionAcpIdentity | undefined,
): { backendSessionId?: string; agentSessionId?: string } {
if (!identity) {
return {};
}
return {
...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}),
...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}),
};
}

View File

@@ -0,0 +1,165 @@
import path from "node:path";
import { resolveAgentSessionDirs } from "../../agents/session-dirs.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { resolveStateDir } from "../../config/paths.js";
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
import {
mergeSessionEntry,
type SessionAcpMeta,
type SessionEntry,
} from "../../config/sessions/types.js";
import { parseAgentSessionKey } from "../../routing/session-key.js";
export type AcpSessionStoreEntry = {
cfg: OpenClawConfig;
storePath: string;
sessionKey: string;
storeSessionKey: string;
entry?: SessionEntry;
acp?: SessionAcpMeta;
storeReadFailed?: boolean;
};
function resolveStoreSessionKey(store: Record<string, SessionEntry>, sessionKey: string): string {
const normalized = sessionKey.trim();
if (!normalized) {
return "";
}
if (store[normalized]) {
return normalized;
}
const lower = normalized.toLowerCase();
if (store[lower]) {
return lower;
}
for (const key of Object.keys(store)) {
if (key.toLowerCase() === lower) {
return key;
}
}
return lower;
}
export function resolveSessionStorePathForAcp(params: {
sessionKey: string;
cfg?: OpenClawConfig;
}): { cfg: OpenClawConfig; storePath: string } {
const cfg = params.cfg ?? loadConfig();
const parsed = parseAgentSessionKey(params.sessionKey);
const storePath = resolveStorePath(cfg.session?.store, {
agentId: parsed?.agentId,
});
return { cfg, storePath };
}
export function readAcpSessionEntry(params: {
sessionKey: string;
cfg?: OpenClawConfig;
}): AcpSessionStoreEntry | null {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const { cfg, storePath } = resolveSessionStorePathForAcp({
sessionKey,
cfg: params.cfg,
});
let store: Record<string, SessionEntry>;
let storeReadFailed = false;
try {
store = loadSessionStore(storePath);
} catch {
storeReadFailed = true;
store = {};
}
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
const entry = store[storeSessionKey];
return {
cfg,
storePath,
sessionKey,
storeSessionKey,
entry,
acp: entry?.acp,
storeReadFailed,
};
}
export async function listAcpSessionEntries(params: {
cfg?: OpenClawConfig;
}): Promise<AcpSessionStoreEntry[]> {
const cfg = params.cfg ?? loadConfig();
const stateDir = resolveStateDir(process.env);
const sessionDirs = await resolveAgentSessionDirs(stateDir);
const entries: AcpSessionStoreEntry[] = [];
for (const sessionsDir of sessionDirs) {
const storePath = path.join(sessionsDir, "sessions.json");
let store: Record<string, SessionEntry>;
try {
store = loadSessionStore(storePath);
} catch {
continue;
}
for (const [sessionKey, entry] of Object.entries(store)) {
if (!entry?.acp) {
continue;
}
entries.push({
cfg,
storePath,
sessionKey,
storeSessionKey: sessionKey,
entry,
acp: entry.acp,
});
}
}
return entries;
}
export async function upsertAcpSessionMeta(params: {
sessionKey: string;
cfg?: OpenClawConfig;
mutate: (
current: SessionAcpMeta | undefined,
entry: SessionEntry | undefined,
) => SessionAcpMeta | null | undefined;
}): Promise<SessionEntry | null> {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
return null;
}
const { storePath } = resolveSessionStorePathForAcp({
sessionKey,
cfg: params.cfg,
});
return await updateSessionStore(
storePath,
(store) => {
const storeSessionKey = resolveStoreSessionKey(store, sessionKey);
const currentEntry = store[storeSessionKey];
const nextMeta = params.mutate(currentEntry?.acp, currentEntry);
if (nextMeta === undefined) {
return currentEntry ?? null;
}
if (nextMeta === null && !currentEntry) {
return null;
}
const nextEntry = mergeSessionEntry(currentEntry, {
acp: nextMeta ?? undefined,
});
if (nextMeta === null) {
delete nextEntry.acp;
}
store[storeSessionKey] = nextEntry;
return nextEntry;
},
{
activeSessionKey: sessionKey.toLowerCase(),
},
);
}

110
src/acp/runtime/types.ts Normal file
View File

@@ -0,0 +1,110 @@
export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeSessionMode = "persistent" | "oneshot";
export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status";
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;
cwd?: string;
env?: Record<string, string>;
};
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
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[];
};
export type AcpRuntimeEvent =
| {
type: "text_delta";
text: string;
stream?: "output" | "thought";
}
| {
type: "status";
text: string;
}
| {
type: "tool_call";
text: string;
}
| {
type: "done";
stopReason?: string;
}
| {
type: "error";
message: string;
code?: string;
retryable?: boolean;
};
export interface AcpRuntime {
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities?(input: {
handle?: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> | AcpRuntimeCapabilities;
getStatus?(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus>;
setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
doctor?(): Promise<AcpRuntimeDoctorReport>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
}