@@ -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 */
8997function 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" ,
0 commit comments