fix(acp): canonicalize main alias session rehydrate

This commit is contained in:
Frank Yang
2026-03-11 23:15:39 +08:00
parent 5ca780fa78
commit 20de91c93d
4 changed files with 103 additions and 12 deletions

View File

@@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. Fixes #25692.
## 2026.3.8

View File

@@ -44,11 +44,11 @@ import {
type TurnLatencyStats,
} from "./manager.types.js";
import {
canonicalizeAcpSessionKey,
createUnsupportedControlError,
hasLegacyAcpIdentityProjection,
normalizeAcpErrorCode,
normalizeActorKey,
normalizeSessionKey,
requireReadySessionMeta,
resolveAcpAgentFromSessionKey,
resolveAcpSessionResolutionError,
@@ -87,7 +87,7 @@ export class AcpSessionManager {
constructor(private readonly deps: AcpSessionManagerDeps = DEFAULT_DEPS) {}
resolveSession(params: { cfg: OpenClawConfig; sessionKey: string }): AcpSessionResolution {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
return {
kind: "none",
@@ -213,7 +213,10 @@ export class AcpSessionManager {
handle: AcpRuntimeHandle;
meta: SessionAcpMeta;
}> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -321,7 +324,7 @@ export class AcpSessionManager {
sessionKey: string;
signal?: AbortSignal;
}): Promise<AcpSessionStatus> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -397,7 +400,7 @@ export class AcpSessionManager {
sessionKey: string;
runtimeMode: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -452,7 +455,7 @@ export class AcpSessionManager {
key: string;
value: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -525,7 +528,7 @@ export class AcpSessionManager {
sessionKey: string;
patch: Partial<AcpSessionRuntimeOptions>;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
const validatedPatch = validateRuntimeOptionPatch(params.patch);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
@@ -555,7 +558,7 @@ export class AcpSessionManager {
cfg: OpenClawConfig;
sessionKey: string;
}): Promise<AcpSessionRuntimeOptions> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -591,7 +594,10 @@ export class AcpSessionManager {
}
async runTurn(input: AcpRunTurnInput): Promise<void> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -738,7 +744,7 @@ export class AcpSessionManager {
sessionKey: string;
reason?: string;
}): Promise<void> {
const sessionKey = normalizeSessionKey(params.sessionKey);
const sessionKey = canonicalizeAcpSessionKey(params);
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}
@@ -806,7 +812,10 @@ export class AcpSessionManager {
}
async closeSession(input: AcpCloseSessionInput): Promise<AcpCloseSessionResult> {
const sessionKey = normalizeSessionKey(input.sessionKey);
const sessionKey = canonicalizeAcpSessionKey({
cfg: input.cfg,
sessionKey: input.sessionKey,
});
if (!sessionKey) {
throw new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "ACP session key is required.");
}

View File

@@ -170,6 +170,52 @@ describe("AcpSessionManager", () => {
expect(resolved.error.message).toContain("ACP metadata is missing");
});
it("canonicalizes the main alias before ACP rehydrate after restart", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
id: "acpx",
runtime: runtimeState.runtime,
});
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey;
if (sessionKey !== "agent:main:main") {
return null;
}
return {
sessionKey,
storeSessionKey: sessionKey,
acp: readySessionMeta(),
};
});
const manager = new AcpSessionManager();
const cfg = {
...baseCfg,
session: { mainKey: "main" },
agents: { list: [{ id: "main", default: true }] },
} as OpenClawConfig;
await manager.runTurn({
cfg,
sessionKey: "main",
text: "after restart",
mode: "prompt",
requestId: "r-main",
});
expect(hoisted.readAcpSessionEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
cfg,
sessionKey: "agent:main:main",
}),
);
expect(runtimeState.ensureSession).toHaveBeenCalledWith(
expect.objectContaining({
sessionKey: "agent:main:main",
}),
);
});
it("serializes concurrent turns for the same ACP session", async () => {
const runtimeState = createRuntime();
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({

View File

@@ -1,6 +1,14 @@
import type { OpenClawConfig } from "../../config/config.js";
import {
canonicalizeMainSessionAlias,
resolveMainSessionKey,
} from "../../config/sessions/main-session.js";
import type { SessionAcpMeta } from "../../config/sessions/types.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import {
normalizeAgentId,
normalizeMainKey,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
import type { AcpSessionResolution } from "./manager.types.js";
@@ -42,6 +50,33 @@ export function normalizeSessionKey(sessionKey: string): string {
return sessionKey.trim();
}
export function canonicalizeAcpSessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;
}): string {
const normalized = normalizeSessionKey(params.sessionKey);
if (!normalized) {
return "";
}
const lowered = normalized.toLowerCase();
if (lowered === "global" || lowered === "unknown") {
return lowered;
}
const parsed = parseAgentSessionKey(lowered);
if (parsed) {
return canonicalizeMainSessionAlias({
cfg: params.cfg,
agentId: parsed.agentId,
sessionKey: lowered,
});
}
const mainKey = normalizeMainKey(params.cfg.session?.mainKey);
if (lowered === "main" || lowered === mainKey) {
return resolveMainSessionKey(params.cfg);
}
return lowered;
}
export function normalizeActorKey(sessionKey: string): string {
return sessionKey.trim().toLowerCase();
}