Skip to content

Commit 983064f

Browse files
authored
fix(sessions): report ACP-runtime metadata for ACP-keyed sessions
Report ACP control-plane session runtime metadata from persisted ACP session metadata/backend, and keep ACP-shaped bridge sessions on normal configured model/runtime metadata. Proof: focused sessions runtime/model-display tests, core prod/test typechecks, touched-file format check, seeded openclaw sessions --json behavior proof, and passing relevant CI. Known unrelated red check: checks-fast-contracts-plugins-d plugin SDK documentation contract for codex helper subpaths.
1 parent bce56ba commit 983064f

10 files changed

Lines changed: 295 additions & 7 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
1313
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.
1414
- Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.
1515
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
16+
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
1617
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.
1718
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
1819
- ACP: preserve redacted numeric JSON-RPC `RequestError` details in runtime failure text, so backend diagnostics are visible instead of only `Internal error`. Fixes #81126. (#81188) Thanks @vyctorbrzezowski.

src/agents/acp-runtime-overlay.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { isAcpSessionKey } from "../routing/session-key.js";
2+
3+
/**
4+
* Leaf type for agent runtime classification. Defined here so that
5+
* agent-runtime-metadata.ts can import applyAcpRuntimeOverlay without
6+
* creating a circular dependency (agent-runtime-metadata → acp-runtime-overlay
7+
* → agent-runtime-metadata). agent-runtime-metadata.ts re-exports this type
8+
* so all existing consumers remain unaffected.
9+
*/
10+
export type AgentRuntimeMetadata = {
11+
id: string;
12+
source: "implicit" | "model" | "provider" | "session-key";
13+
};
14+
15+
/**
16+
* When a session key and persisted session metadata identify an ACP
17+
* control-plane session, override the resolved runtime metadata to report the
18+
* ACP runtime id with a "session-key" source — regardless of what the
19+
* agent-config policy resolved to.
20+
*
21+
* Callers that already have model/provider context (resolveModelAgentRuntimeMetadata)
22+
* still benefit here because the model-runtime policy chain does not inspect session
23+
* keys for the ACP indicator.
24+
*
25+
* Key shape alone is not sufficient: ACP bridge sessions may use ACP-shaped
26+
* keys without persisted SessionAcpMeta and still run the configured model.
27+
*
28+
* When `acpBackend` is provided and non-empty, it is used as the runtime id so that
29+
* sessions backed by a configured non-default ACP backend (e.g. a custom registered
30+
* backend) are reported faithfully instead of always being labelled "acpx".
31+
* Falls back to "acpx" when no backend is known.
32+
*/
33+
export function applyAcpRuntimeOverlay(
34+
meta: AgentRuntimeMetadata,
35+
sessionKey: string | undefined | null,
36+
acpRuntime: boolean | undefined,
37+
acpBackend?: string,
38+
): AgentRuntimeMetadata {
39+
if (acpRuntime === true && isAcpSessionKey(sessionKey)) {
40+
const id = acpBackend && acpBackend.length > 0 ? acpBackend : "acpx";
41+
return { id, source: "session-key" };
42+
}
43+
return meta;
44+
}

src/agents/agent-runtime-metadata.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { applyAcpRuntimeOverlay, type AgentRuntimeMetadata } from "./acp-runtime-overlay.js";
23
import { resolveAgentHarnessPolicy } from "./harness/policy.js";
34
import { resolveDefaultModelForAgent } from "./model-selection.js";
45

5-
type AgentRuntimeMetadata = {
6-
id: string;
7-
source: "implicit" | "model" | "provider";
8-
};
6+
export type { AgentRuntimeMetadata };
97

108
export function resolveAgentRuntimeMetadata(
119
_cfg: OpenClawConfig,
@@ -24,6 +22,19 @@ export function resolveModelAgentRuntimeMetadata(params: {
2422
provider?: string;
2523
model?: string;
2624
sessionKey?: string;
25+
/**
26+
* True when the loaded session entry has persisted ACP metadata. ACP-shaped
27+
* keys without this marker can be bridge sessions that use the configured
28+
* model/runtime.
29+
*/
30+
acpRuntime?: boolean;
31+
/**
32+
* The ACP backend identifier stored on the session entry (`entry.acp.backend`).
33+
* When provided for an ACP-keyed session, the overlay reports this value as the
34+
* runtime id instead of the generic fallback "acpx", so sessions backed by a
35+
* non-default registered ACP backend are classified correctly.
36+
*/
37+
acpBackend?: string;
2738
}): AgentRuntimeMetadata {
2839
const resolved =
2940
params.provider && params.model
@@ -36,8 +47,9 @@ export function resolveModelAgentRuntimeMetadata(params: {
3647
agentId: params.agentId,
3748
sessionKey: params.sessionKey,
3849
});
39-
return {
50+
const meta: AgentRuntimeMetadata = {
4051
id: policy.runtime,
4152
source: policy.runtimeSource ?? "implicit",
4253
};
54+
return applyAcpRuntimeOverlay(meta, params.sessionKey, params.acpRuntime, params.acpBackend);
4355
}

src/agents/tools/agents-list-tool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type AgentListEntry = {
2222
model?: string;
2323
agentRuntime?: {
2424
id: string;
25-
source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit";
25+
source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key";
2626
};
2727
};
2828

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
3+
import type { OpenClawConfig } from "../config/types.openclaw.js";
4+
import { parseAgentSessionKey } from "../routing/session-key.js";
5+
6+
/**
7+
* Catalog #18 — `openclaw sessions --json` reports `agentRuntime.id: "pi"` for
8+
* ACP sessions because `resolveAgentRuntimeMetadata` only consults agent-config
9+
* policies (env / agent / defaults / implicit fallback to "pi"). The session
10+
* key clearly carries the ACP runtime indicator (the `:acp:` segment), but
11+
* `sessions.ts:294` ignores it and just calls `resolveAgentRuntimeMetadata(cfg, agentId)`.
12+
*
13+
* Empirical observation from a deployed openclaw container against a copilot
14+
* agent that has no explicit `agentRuntime.id` policy:
15+
*
16+
* {
17+
* "key": "agent:copilot:acp:86b7b5af-3773-4a56-b244-069d6c5d3db9",
18+
* "agentId": "copilot",
19+
* "agentRuntime": { "id": "pi", "source": "implicit" },
20+
* "kind": "direct"
21+
* }
22+
*
23+
* That is wrong: this session is plainly ACP, not PI. The runtime field is
24+
* supposed to be a faithful classifier of how this session is actually being
25+
* run; instead, every ACP session in the JSON output is mislabelled as `pi`.
26+
*
27+
* This test mirrors the exact computation `sessionsCommand` performs at
28+
* `src/commands/sessions.ts:294` and proves the bug in two parts:
29+
*
30+
* - RED: ACP-keyed session resolves to `id: "pi"`, `source: "implicit"`.
31+
* - GREEN control: a non-ACP `agent:main:main` session resolves to the
32+
* same implicit-pi metadata, which IS correct in that case. The control
33+
* proves the assertion infrastructure is not masking the RED case.
34+
*
35+
* Fix shape (see the third test): when the session key is ACP-style,
36+
* agentRuntime.id should report `acpx` (or whatever runtime id is actually
37+
* driving the session) so that the JSON faithfully classifies the session.
38+
* The fix likely belongs at the caller (sessions.ts:294 and the other
39+
* call sites in `src/gateway/server-methods/sessions.ts`,
40+
* `src/gateway/session-utils.ts`) so it can pass session-key context to
41+
* `resolveAgentRuntimeMetadata`, OR `resolveAgentRuntimeMetadata` itself
42+
* gains an optional `sessionKey` parameter and applies a session-key-aware
43+
* override.
44+
*/
45+
46+
const ACP_SESSION_KEY = "agent:copilot:acp:86b7b5af-3773-4a56-b244-069d6c5d3db9";
47+
const NON_ACP_SESSION_KEY = "agent:main:main";
48+
49+
/**
50+
* Build a minimal `OpenClawConfig` that mirrors the deployed scenario:
51+
* - a copilot agent exists in the agents.list
52+
* - it has NO explicit `agentRuntime.id` policy
53+
* - no top-level `agents.defaults.agentRuntime` either
54+
*
55+
* Result: `resolveAgentRuntimeMetadata(cfg, "copilot")` falls through to the
56+
* implicit "pi" branch — which is the bug under test.
57+
*/
58+
function buildConfigWithoutAgentRuntimePolicy(): OpenClawConfig {
59+
return {
60+
agents: {
61+
list: [
62+
{
63+
id: "copilot",
64+
// Intentionally no `agentRuntime` field, no `runtime` descriptor.
65+
},
66+
{
67+
id: "main",
68+
},
69+
],
70+
// No `defaults.agentRuntime` either.
71+
defaults: {},
72+
},
73+
} as OpenClawConfig;
74+
}
75+
76+
/**
77+
* Mirror the per-row computation from `src/commands/sessions.ts:290-298`:
78+
* const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId;
79+
* const agentRuntime = resolveModelAgentRuntimeMetadata({ cfg, agentId, sessionKey: row.key });
80+
*
81+
* Returns the same shape that ends up serialized to `--json` output.
82+
* After commit 02fe0d8978, the production path goes through resolveModelAgentRuntimeMetadata
83+
* (not resolveAgentRuntimeMetadata which is now a stub returning { id: "auto", source: "implicit" }).
84+
*/
85+
function computeSessionAgentRuntime(params: {
86+
cfg: OpenClawConfig;
87+
sessionKey: string;
88+
fallbackAgentId: string;
89+
/** Mirrors `entry?.acp != null` passed from loaded session rows. */
90+
acpRuntime?: boolean;
91+
/** Mirrors `entry?.acp?.backend` passed from the session store entry. */
92+
acpBackend?: string;
93+
}): ReturnType<typeof resolveModelAgentRuntimeMetadata> {
94+
const agentId = parseAgentSessionKey(params.sessionKey)?.agentId ?? params.fallbackAgentId;
95+
return resolveModelAgentRuntimeMetadata({
96+
cfg: params.cfg,
97+
agentId,
98+
sessionKey: params.sessionKey,
99+
acpRuntime: params.acpRuntime,
100+
acpBackend: params.acpBackend,
101+
});
102+
}
103+
104+
describe("sessions --json agentRuntime classifier (catalog #18)", () => {
105+
it("RED→GREEN: ACP session key is no longer misclassified (overlay applies)", () => {
106+
const cfg = buildConfigWithoutAgentRuntimePolicy();
107+
const agentRuntime = computeSessionAgentRuntime({
108+
cfg,
109+
sessionKey: ACP_SESSION_KEY,
110+
fallbackAgentId: "copilot",
111+
acpRuntime: true,
112+
});
113+
114+
// The bug was: the session key plainly contains `:acp:` and yet the
115+
// resolved metadata said id="pi", source="implicit".
116+
// After the fix (applyAcpRuntimeOverlay in resolveModelAgentRuntimeMetadata),
117+
// the ACP session key overrides the runtime to id="acpx", source="session-key".
118+
expect(
119+
agentRuntime.id,
120+
`ACP session ${ACP_SESSION_KEY} should no longer be misclassified as "auto" or "pi". ` +
121+
`Got "${agentRuntime.id}". resolveModelAgentRuntimeMetadata must pass sessionKey to ` +
122+
`applyAcpRuntimeOverlay so ACP sessions are classified as "acpx".`,
123+
).not.toBe("auto");
124+
expect(
125+
agentRuntime.source,
126+
`ACP session ${ACP_SESSION_KEY} resolved with source="${agentRuntime.source}". ` +
127+
`For an ACP-keyed session, the source should not be "implicit" — ` +
128+
`the session key itself is an explicit signal that the runtime is ACP.`,
129+
).not.toBe("implicit");
130+
});
131+
132+
it("GREEN control: non-ACP session is NOT overridden by ACP overlay", () => {
133+
const cfg = buildConfigWithoutAgentRuntimePolicy();
134+
const agentRuntime = computeSessionAgentRuntime({
135+
cfg,
136+
sessionKey: NON_ACP_SESSION_KEY,
137+
fallbackAgentId: "main",
138+
});
139+
140+
// For a non-ACP session, the overlay must NOT fire — the result must
141+
// not be "acpx" and source must not be "session-key". The control
142+
// proves the overlay is gated on the `:acp:` segment in the session key.
143+
// (The concrete id — "codex" for the default openai/gpt-5.5 provider —
144+
// is determined by resolveAgentHarnessPolicy's Codex-routing rule;
145+
// what matters here is the absence of the ACP override.)
146+
expect(agentRuntime.id).not.toBe("acpx");
147+
expect(agentRuntime.source).not.toBe("session-key");
148+
});
149+
150+
it("FIX-SHAPE expectation: ACP session should resolve to 'acpx'", () => {
151+
// What "fixed" should look like once the bug is addressed.
152+
// RED today; GREEN once the fix lands.
153+
//
154+
// Note: the exact id ("acpx" vs another label) is a design choice for
155+
// the fix author. What matters is that it is meaningfully different
156+
// from "pi" and reflects the actual runtime driving the session.
157+
// If the fix picks a different label, update this assertion to match —
158+
// the structural point (session-key-aware classification) is the
159+
// load-bearing part.
160+
const cfg = buildConfigWithoutAgentRuntimePolicy();
161+
const agentRuntime = computeSessionAgentRuntime({
162+
cfg,
163+
sessionKey: ACP_SESSION_KEY,
164+
fallbackAgentId: "copilot",
165+
acpRuntime: true,
166+
});
167+
168+
expect(
169+
agentRuntime.id,
170+
`ACP session ${ACP_SESSION_KEY} should resolve to runtime id "acpx" (or the canonical ACP runtime label). ` +
171+
`Got "${agentRuntime.id}". Fix candidates: ` +
172+
`(a) override at the call site in src/commands/sessions.ts:294 once isAcpSessionKey(row.key) is true, or ` +
173+
`(b) extend resolveAgentRuntimeMetadata to accept an optional sessionKey and apply the override centrally.`,
174+
).toBe("acpx");
175+
});
176+
177+
it("backend override: ACP session with entry.acp.backend set reports that backend id, NOT 'acpx'", () => {
178+
// When the session entry carries an explicit acp.backend (e.g. a registered
179+
// non-default backend), the overlay must reflect the actual backend instead
180+
// of the generic "acpx" fallback.
181+
const cfg = buildConfigWithoutAgentRuntimePolicy();
182+
const agentRuntime = computeSessionAgentRuntime({
183+
cfg,
184+
sessionKey: ACP_SESSION_KEY,
185+
fallbackAgentId: "copilot",
186+
acpRuntime: true,
187+
acpBackend: "custom-backend",
188+
});
189+
190+
expect(agentRuntime.id).toBe("custom-backend");
191+
expect(agentRuntime.source).toBe("session-key");
192+
});
193+
194+
it("backend fallback: ACP session with entry.acp but no backend falls back to 'acpx'", () => {
195+
// When the session entry has ACP metadata but no acp.backend, the overlay
196+
// must fall back to the canonical "acpx" id.
197+
const cfg = buildConfigWithoutAgentRuntimePolicy();
198+
const agentRuntime = computeSessionAgentRuntime({
199+
cfg,
200+
sessionKey: ACP_SESSION_KEY,
201+
fallbackAgentId: "copilot",
202+
acpRuntime: true,
203+
// acpBackend intentionally omitted — mirrors entry with no acp.backend
204+
});
205+
206+
expect(agentRuntime.id).toBe("acpx");
207+
expect(agentRuntime.source).toBe("session-key");
208+
});
209+
210+
it("GREEN control: ACP-shaped bridge session without entry.acp is NOT overridden", () => {
211+
const cfg = buildConfigWithoutAgentRuntimePolicy();
212+
const agentRuntime = computeSessionAgentRuntime({
213+
cfg,
214+
sessionKey: ACP_SESSION_KEY,
215+
fallbackAgentId: "copilot",
216+
acpRuntime: false,
217+
});
218+
219+
expect(agentRuntime.id).not.toBe("acpx");
220+
expect(agentRuntime.source).not.toBe("session-key");
221+
});
222+
});

src/commands/sessions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,8 @@ export async function sessionsCommand(
295295
provider: modelRef.provider,
296296
model: modelRef.model,
297297
sessionKey: row.key,
298+
acpRuntime,
299+
acpBackend: entry?.acp?.backend,
298300
});
299301
return Object.assign({}, row, {
300302
agentId,

src/commands/status.summary.runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ function resolveSessionRuntimeLabel(params: {
164164
provider: params.provider,
165165
model: params.model,
166166
sessionKey: params.sessionKey,
167+
acpRuntime: params.entry?.acp != null,
168+
acpBackend: params.entry?.acp?.backend,
167169
});
168170
const id = normalizeOptionalLowercaseString(runtime.id);
169171
const resolvedHarness = id && id !== "pi" && id !== "auto" ? id : undefined;

src/gateway/server-methods/sessions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,6 +1817,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
18171817
provider: resolvedDisplayModel.provider,
18181818
model: resolvedDisplayModel.model,
18191819
sessionKey: target.canonicalKey ?? key,
1820+
acpRuntime: applied.entry?.acp != null,
1821+
acpBackend: applied.entry?.acp?.backend,
18201822
});
18211823
const result: SessionsPatchResult = {
18221824
ok: true,

src/gateway/session-utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,7 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
10241024
provider: resolvedModel.provider,
10251025
model: resolvedModel.model,
10261026
sessionKey: resolveAgentMainSessionKey({ cfg, agentId: id }),
1027+
acpRuntime: false,
10271028
}),
10281029
},
10291030
model ? { model } : {},
@@ -1735,6 +1736,8 @@ export function buildGatewaySessionRow(params: {
17351736
provider: rowModelProvider,
17361737
model: rowModel,
17371738
sessionKey: key,
1739+
acpRuntime: entry?.acp != null,
1740+
acpBackend: entry?.acp?.backend,
17381741
});
17391742
const estimatedCostUsd = lightweight
17401743
? resolveNonNegativeNumber(entry?.estimatedCostUsd)

src/shared/session-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export type GatewayAgentModel = {
1414
export type GatewayAgentRuntime = {
1515
id: string;
1616
fallback?: "pi" | "none";
17-
source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit";
17+
source: "env" | "agent" | "defaults" | "model" | "provider" | "implicit" | "session-key";
1818
};
1919

2020
export type GatewayAgentRow = {

0 commit comments

Comments
 (0)