Skip to content

Commit 8d33ecc

Browse files
efpivaGalin Iliev
authored andcommitted
fix(sessions): gate ACP model overlay on entry.acp metadata, not key shape alone; add bridge-session regression case and CHANGELOG
1 parent ba66350 commit 8d33ecc

3 files changed

Lines changed: 116 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai
105105
- Plugins/runtime: attribute deprecated runtime config load/write warnings to the plugin id and source that triggered them so logs and plugin doctor runs are actionable. Refs #81394. (#81425) Thanks @BKF-Gitty.
106106
- Memory/LanceDB: make auto-capture recognize short CJK memory phrases and configurable literal triggers, so Chinese, Japanese, and Korean users can capture memories without regex or LLM intent detection. Fixes #75680. Thanks @vyctorbrzezowski and @guokewuming.
107107
- Plugins doctor: report stale plugin config warnings and avoid claiming full plugin health when config warnings remain. (#81515) Thanks @BKF-Gitty.
108+
- Sessions: display `model: "<agentId>-acp"` / `modelProvider: "acpx"` (ACP-runtime sentinel) for ACP control-plane sessions in `openclaw sessions` output, instead of the agent's configured model which was misleading. Catalog finding 20. (#79543)
108109

109110
### Changes
110111

src/commands/sessions.acp-model-display.test.ts

Lines changed: 91 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ import {
2323
* `resolveSessionDisplayModelRef` (`src/commands/sessions-display-model.ts:123-148`)
2424
* has zero ACP-awareness: it only consults the session entry's persisted
2525
* `model` / `modelProvider` / `modelOverride` and the agent's configured
26-
* default. It never inspects the session key.
26+
* default. It never inspects the session key or the persisted ACP metadata.
2727
*
2828
* Decided fix shape (catalog #20, mirrors #18): SENTINEL OVERLAY at the call
29-
* site. When `isAcpSessionKey(row.key)` is true, the JSON-emit path overlays
30-
* `{ provider: "acpx", model: "copilot-acp" }` on top of the resolver result.
31-
* The resolver itself stays pure (a config-policy resolver); the call site
32-
* applies the runtime-aware overlay.
29+
* site, gated on BOTH key shape AND persisted `entry.acp` metadata. Key shape
30+
* alone is not sufficient because ACP bridge sessions (translator.ts) also use
31+
* ACP-shaped keys without ever writing `SessionAcpMeta` — those sessions run
32+
* the normal configured model and must not receive the sentinel.
33+
*
34+
* When `isAcpSessionKey(row.key)` is true AND `entry.acp != null`, the
35+
* JSON-emit path overlays `{ provider: "acpx", model: "<agentId>-acp" }` on
36+
* top of the resolver result. The resolver itself stays pure.
3337
*
3438
* NOTE ON DRIVING SURFACE: `resolveSessionDisplayModelRef` is exported, but
3539
* the bug as observed by operators surfaces through `sessions --json`, so we
@@ -82,19 +86,47 @@ function mockAgentConfigWithCopilotModel(): void {
8286
}
8387

8488
/**
85-
* Minimal ACP session entry: just a session id and an updatedAt timestamp.
89+
* ACP control-plane session entry: includes `entry.acp` as persisted by
90+
* `src/acp/control-plane/manager.core.ts:365` during acpx child init. The
91+
* presence of `entry.acp` is the discriminator the overlay uses to distinguish
92+
* real ACP child-runtime sessions from ACP bridge sessions.
93+
*
8694
* No `model` / `modelProvider` set on the entry — the listing falls through
8795
* to the agent's configured default, which is the buggy path for ACP keys.
8896
*/
8997
function buildAcpSessionEntry(): SessionEntry {
9098
return {
9199
sessionId: "acp-session-id",
92100
updatedAt: Date.now() - 2 * 60_000,
101+
acp: {
102+
backend: "copilot",
103+
agent: "copilot",
104+
runtimeSessionName: "acp-runtime-session-1",
105+
mode: "persistent",
106+
state: "idle",
107+
lastActivityAt: Date.now() - 2 * 60_000,
108+
},
93109
};
94110
}
95111

96112
/**
97-
* Minimal non-ACP session entry, same shape as the ACP entry. Used as the
113+
* ACP bridge session entry: ACP-shaped key but no `entry.acp`. The ACP bridge
114+
* (translator.ts) uses an in-memory-only session store and never writes
115+
* `SessionAcpMeta` to disk. If a bridge client passes an explicit ACP-shaped
116+
* key (e.g. `agent:copilot:acp:session-1`) and the Gateway persists the
117+
* session, it will have an ACP key without `entry.acp`. The overlay must NOT
118+
* fire for these sessions — they ran the configured model.
119+
*/
120+
function buildAcpBridgeSessionEntry(): SessionEntry {
121+
return {
122+
sessionId: "acp-bridge-session-id",
123+
updatedAt: Date.now() - 4 * 60_000,
124+
// No `acp` field: this is a bridge session, not a control-plane child session.
125+
};
126+
}
127+
128+
/**
129+
* Minimal non-ACP session entry, same shape as the ACP bridge entry. Used as the
98130
* GREEN-control case below. The agent default is the correct answer for
99131
* non-ACP sessions — those run through the openclaw-agent-driven flow that
100132
* actually uses the configured model.
@@ -118,18 +150,16 @@ describe("sessionsCommand model/modelProvider display for ACP sessions (catalog
118150
vi.useRealTimers();
119151
});
120152

121-
it("RED: ACP session must NOT report the agent-configured model", async () => {
122-
// RED today. The session is plainly ACP (key has the `:acp:` segment),
123-
// but `resolveSessionDisplayModelRef` (`src/commands/sessions-display-model.ts:123`)
124-
// ignores the key and returns the agent default. Operators relying on
125-
// `sessions --json` model fields see the model the openclaw-agent-driven
126-
// flow would have used, NOT what copilot actually selected internally
127-
// when it ran via ACP.
153+
it("RED: ACP control-plane session must NOT report the agent-configured model", async () => {
154+
// RED before fix. The session is a real ACP control-plane session
155+
// (key has the `:acp:` segment AND entry.acp is present), but
156+
// `resolveSessionDisplayModelRef` ignores both and returns the agent
157+
// default. Operators relying on `sessions --json` model fields see the
158+
// model the openclaw-agent-driven flow would have used, NOT what copilot
159+
// actually selected internally when it ran via ACP.
128160
//
129-
// The discriminator the fix should consult is `isAcpSessionKey(row.key)`
130-
// (re-exported from `src/routing/session-key.ts:9`, defined at
131-
// `src/sessions/session-key-utils.ts:86`). Mirrors catalog #18's overlay
132-
// pattern.
161+
// The discriminator the fix uses: `isAcpSessionKey(row.key)` AND
162+
// `entry.acp != null` (persisted by the ACP control plane manager).
133163
const store = writeStore(
134164
{ [ACP_SESSION_KEY]: buildAcpSessionEntry() },
135165
"sessions-acp-model-display-red",
@@ -147,33 +177,26 @@ describe("sessionsCommand model/modelProvider display for ACP sessions (catalog
147177
`ACP session ${ACP_SESSION_KEY} reports model="${row?.model}" — that is the agent-configured ` +
148178
`model (${AGENT_CONFIGURED_MODEL}), not what copilot actually used inside ACP. ` +
149179
`resolveSessionDisplayModelRef (src/commands/sessions-display-model.ts:123) has zero ` +
150-
`ACP-awareness; the call site at src/commands/sessions.ts:335 should consult ` +
151-
`isAcpSessionKey(row.key) and overlay an ACP-runtime sentinel before serialization.`,
180+
`ACP-awareness; the call site at src/commands/sessions.ts should consult ` +
181+
`isAcpSessionKey(row.key) AND entry.acp != null, then overlay an ACP-runtime sentinel.`,
152182
).not.toBe(AGENT_CONFIGURED_MODEL);
153183
expect(
154184
row?.modelProvider,
155185
`ACP session ${ACP_SESSION_KEY} reports modelProvider="${row?.modelProvider}" — the ` +
156186
`agent-configured provider (${AGENT_CONFIGURED_PROVIDER}), not the ACP runtime. ` +
157-
`Same fix site as above: src/commands/sessions-display-model.ts:123 has no key awareness; ` +
158-
`apply the overlay at the call site using isAcpSessionKey.`,
187+
`Same fix site as above; the overlay must gate on entry.acp presence.`,
159188
).not.toBe(AGENT_CONFIGURED_PROVIDER);
160189
});
161190

162-
it("RED (fix-shape): ACP session should report the ACP runtime sentinel", async () => {
163-
// RED today; flips GREEN once the catalog-#20 sentinel-overlay fix lands.
191+
it("RED (fix-shape): ACP control-plane session should report the ACP runtime sentinel", async () => {
192+
// RED before fix; GREEN once the catalog-#20 sentinel-overlay fix lands.
164193
//
165-
// The catalog's chosen fix shape is option (a): when `isAcpSessionKey(row.key)`
166-
// is true, overlay `{ provider: "acpx", model: "copilot-acp" }` (or a
167-
// similar sentinel). This trades model-name accuracy for "this is ACP,
168-
// not the agent default" clarity. Plumbing the actual copilot-side model
169-
// selection into the openclaw record would require capturing ACP
170-
// `session.model_change` events (catalog notes this as deferrable).
171-
//
172-
// If the fix author chooses different sentinel names ("acp-runtime" vs
173-
// "acpx", "copilot-acp" vs "<acp-runtime>"), update both expectations to
174-
// match. The structural point is that for ACP-keyed sessions the values
175-
// MUST be different from the agent default and clearly mark the ACP
176-
// origin.
194+
// The catalog's chosen fix shape: when `isAcpSessionKey(row.key)` is true
195+
// AND `entry.acp != null`, overlay `{ provider: "acpx", model: "<agentId>-acp" }`.
196+
// This trades model-name accuracy for "this is ACP control-plane, not the
197+
// agent default" clarity. Plumbing the actual copilot-side model selection
198+
// into the openclaw record would require capturing ACP `session.model_change`
199+
// events (catalog notes this as deferrable).
177200
const store = writeStore(
178201
{ [ACP_SESSION_KEY]: buildAcpSessionEntry() },
179202
"sessions-acp-model-display-fix-shape",
@@ -186,9 +209,8 @@ describe("sessionsCommand model/modelProvider display for ACP sessions (catalog
186209
expect(
187210
row?.model,
188211
`ACP session ${ACP_SESSION_KEY} should resolve model to "copilot-acp" (the catalog-chosen ` +
189-
`sentinel). Got "${row?.model}". Fix lands at the call site in src/commands/sessions.ts:335 ` +
190-
`which already has row.key in scope — gate on isAcpSessionKey(row.key) and overlay ` +
191-
`{ provider: "acpx", model: "copilot-acp" }. Keeps resolveSessionDisplayModelRef pure.`,
212+
`sentinel). Got "${row?.model}". Fix gates on isAcpSessionKey(row.key) AND entry.acp != null ` +
213+
`and overlays { provider: "acpx", model: "copilot-acp" }. Keeps resolveSessionDisplayModelRef pure.`,
192214
).toBe("copilot-acp");
193215
expect(
194216
row?.modelProvider,
@@ -198,6 +220,34 @@ describe("sessionsCommand model/modelProvider display for ACP sessions (catalog
198220
).toBe("acpx");
199221
});
200222

223+
it("GREEN control: ACP bridge session (ACP key, no entry.acp) reports the configured model", async () => {
224+
// ACP bridge sessions (translator.ts) use ACP-shaped keys but never
225+
// persist SessionAcpMeta to disk. They run the normal configured model
226+
// and must NOT receive the acpx sentinel. This guards against a regression
227+
// where key-shape-only detection would misreport bridge sessions.
228+
const ACP_BRIDGE_SESSION_KEY = "agent:copilot:acp:bridge-session-1";
229+
const store = writeStore(
230+
{ [ACP_BRIDGE_SESSION_KEY]: buildAcpBridgeSessionEntry() },
231+
"sessions-acp-model-display-bridge-control",
232+
);
233+
234+
const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
235+
const row = payload.sessions?.find((entry) => entry.key === ACP_BRIDGE_SESSION_KEY);
236+
237+
expect(row).toBeDefined();
238+
expect(
239+
row?.model,
240+
`ACP bridge session ${ACP_BRIDGE_SESSION_KEY} has an ACP-shaped key but no entry.acp — ` +
241+
`it ran the configured model. Got model="${row?.model}"; expected "${AGENT_CONFIGURED_MODEL}". ` +
242+
`The overlay must gate on entry.acp != null, not key shape alone.`,
243+
).toBe(AGENT_CONFIGURED_MODEL);
244+
expect(
245+
row?.modelProvider,
246+
`ACP bridge session ${ACP_BRIDGE_SESSION_KEY} should report the configured provider. ` +
247+
`Got "${row?.modelProvider}"; expected "${AGENT_CONFIGURED_PROVIDER}".`,
248+
).toBe(AGENT_CONFIGURED_PROVIDER);
249+
});
250+
201251
it("GREEN control: non-ACP session correctly reports the agent-configured model", async () => {
202252
// GREEN today. The same agent configuration drives a non-ACP session
203253
// (`agent:copilot:main`) — and for that session the agent-configured
@@ -207,8 +257,8 @@ describe("sessionsCommand model/modelProvider display for ACP sessions (catalog
207257
// (not a mock that would silently pass either way).
208258
// 2. The configured-model branch of resolveSessionDisplayModelRef
209259
// remains correct for non-ACP keys; the proposed sentinel overlay
210-
// must NOT break this case (it should only fire when
211-
// isAcpSessionKey(row.key) is true).
260+
// must NOT break this case (it should only fire when both
261+
// isAcpSessionKey(row.key) is true AND entry.acp is present).
212262
const store = writeStore(
213263
{ [NON_ACP_SESSION_KEY]: buildNonAcpSessionEntry() },
214264
"sessions-acp-model-display-green-control",

src/commands/sessions.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ type SessionRow = SessionDisplayRow & {
3434
kind: "cron" | "direct" | "group" | "global" | "unknown";
3535
agentRuntime: ReturnType<typeof resolveModelAgentRuntimeMetadata>;
3636
runtimeLabel: string;
37+
/**
38+
* True only when the session entry has persisted ACP runtime metadata
39+
* (`entry.acp` is present). Key-shape alone is not sufficient because ACP
40+
* bridge sessions (translator.ts) may use ACP-shaped keys without ever
41+
* writing `SessionAcpMeta` — those use the normal configured model and must
42+
* not be overlaid with the acpx sentinel.
43+
*/
44+
acpRuntime: boolean;
3745
};
3846

3947
const AGENT_PAD = 10;
@@ -49,12 +57,18 @@ const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_0
4957
/**
5058
* Inline ACP model overlay — catalog #20.
5159
*
52-
* When a session ran via ACP (e.g. key = `agent:copilot:acp:<uuid>`), the
53-
* agent's configured model is irrelevant: the actual model is selected inside
54-
* the ACP child process. We overlay a sentinel `{ provider: "acpx",
60+
* When a session ran via the ACP control plane (e.g. key =
61+
* `agent:copilot:acp:<uuid>` AND `entry.acp` is present), the agent's
62+
* configured model is irrelevant: the actual model is selected inside the ACP
63+
* child process. We overlay a sentinel `{ provider: "acpx",
5564
* model: "<agentId>-acp" }` so the listing clearly signals "ACP runtime" and
5665
* does not mislead operators into thinking the configured model ran.
5766
*
67+
* Key-shape alone is not sufficient: ACP bridge sessions (translator.ts) also
68+
* use ACP-shaped keys but never persist `SessionAcpMeta` — they run the
69+
* normal configured model and must not receive the sentinel. The `acpRuntime`
70+
* flag is set at row-construction time from `entry.acp != null`.
71+
*
5872
* The resolver (`resolveSessionDisplayModelRef`) stays pure; this overlay
5973
* applies only at the emit sites in this file.
6074
*
@@ -64,8 +78,9 @@ const formatKTokens = (value: number) => `${(value / 1000).toFixed(value >= 10_0
6478
function applyAcpModelOverlayIfNeeded(
6579
modelRef: { provider: string; model: string },
6680
sessionKey: string,
81+
acpRuntime: boolean,
6782
): { provider: string; model: string } {
68-
if (!isAcpSessionKey(sessionKey)) {
83+
if (!acpRuntime || !isAcpSessionKey(sessionKey)) {
6984
return modelRef;
7085
}
7186
const agentId = parseAgentSessionKey(sessionKey)?.agentId ?? "acp";
@@ -286,9 +301,11 @@ export async function sessionsCommand(
286301
.map(([key, entry]) => {
287302
const row = toSessionDisplayRow(key, entry);
288303
const agentId = parseAgentSessionKey(row.key)?.agentId ?? target.agentId;
304+
const acpRuntime = entry?.acp != null;
289305
const modelRef = applyAcpModelOverlayIfNeeded(
290306
resolveSessionDisplayModelRef(cfg, row),
291307
row.key,
308+
acpRuntime,
292309
);
293310
const agentRuntime = resolveModelAgentRuntimeMetadata({
294311
cfg,
@@ -299,6 +316,7 @@ export async function sessionsCommand(
299316
});
300317
return Object.assign({}, row, {
301318
agentId,
319+
acpRuntime,
302320
agentRuntime,
303321
kind: classifySessionKey(row.key, store[row.key]),
304322
runtimeLabel: resolveSessionRuntimeLabel({
@@ -340,6 +358,7 @@ export async function sessionsCommand(
340358
const modelRef = applyAcpModelOverlayIfNeeded(
341359
resolveSessionDisplayModelRef(cfg, r),
342360
r.key,
361+
row.acpRuntime,
343362
);
344363
return {
345364
...r,
@@ -402,6 +421,7 @@ export async function sessionsCommand(
402421
const model = applyAcpModelOverlayIfNeeded(
403422
resolveSessionDisplayModelRef(cfg, row),
404423
row.key,
424+
row.acpRuntime,
405425
).model;
406426
const contextTokens =
407427
row.contextTokens ??

0 commit comments

Comments
 (0)