




























@@ -0,0 +1,274 @@
1+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+import type { SessionEntry } from "../config/sessions/types.js";
3+import {
4+mockSessionsConfig,
5+resetMockSessionsConfig,
6+runSessionsJson,
7+setMockSessionsConfig,
8+writeStore,
9+} from "./sessions.test-helpers.js";
10+11+/**
12+ * Catalog #20 — `model` / `modelProvider` reported as agent-config, not ACP runtime actuals.
13+ *
14+ * Bug summary: For ACP-keyed sessions (e.g. `agent:copilot:acp:<uuid>`), the
15+ * `--json` listing reports the AGENT's configured model
16+ * (e.g. `model: "gpt-5.3-codex"`, `modelProvider: "microsoft-foundry"`) — but
17+ * those are the values the openclaw-agent-driven flow would have used. When
18+ * the same agent runs as an ACP child via `copilot --acp --stdio`, the actual
19+ * underlying model selection lives inside copilot CLI and is independent of
20+ * the agent's configured model. The listing happily reports the agent default
21+ * regardless of whether the session actually ran via ACP.
22+ *
23+ * `resolveSessionDisplayModelRef` (`src/commands/sessions-display-model.ts:123-148`)
24+ * has zero ACP-awareness: it only consults the session entry's persisted
25+ * `model` / `modelProvider` / `modelOverride` and the agent's configured
26+ * default. It never inspects the session key or the persisted ACP metadata.
27+ *
28+ * Decided fix shape (catalog #20, mirrors #18): SENTINEL OVERLAY at the call
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.
37+ *
38+ * NOTE ON DRIVING SURFACE: `resolveSessionDisplayModelRef` is exported, but
39+ * the bug as observed by operators surfaces through `sessions --json`, so we
40+ * drive the test end-to-end through `sessionsCommand --json` (mirroring the
41+ * #19 test pattern). This proves the bug at the actual emit site that
42+ * operators see, not just in the resolver in isolation.
43+ */
44+45+mockSessionsConfig();
46+47+const { sessionsCommand } = await import("./sessions.js");
48+49+type SessionsJsonPayload = {
50+sessions?: Array<{
51+key: string;
52+model?: string | null;
53+modelProvider?: string | null;
54+}>;
55+};
56+57+const ACP_SESSION_KEY = "agent:copilot:acp:86b7b5af-3773-4a56-b244-069d6c5d3db9";
58+const NON_ACP_SESSION_KEY = "agent:copilot:main";
59+60+const AGENT_CONFIGURED_MODEL = "gpt-5.3-codex";
61+const AGENT_CONFIGURED_PROVIDER = "microsoft-foundry";
62+63+/**
64+ * Mock config with a `copilot` agent whose configured model is
65+ * `microsoft-foundry/gpt-5.3-codex` (the deployed scenario from the catalog).
66+ *
67+ * Both the ACP and the non-ACP session entries below leave `model` /
68+ * `modelProvider` unset, so `resolveSessionDisplayModelRef` falls through to
69+ * the agent's configured default. That is precisely the path under test:
70+ * for ACP sessions the agent default is the WRONG answer.
71+ */
72+function mockAgentConfigWithCopilotModel(): void {
73+setMockSessionsConfig(() => ({
74+agents: {
75+list: [
76+{
77+id: "copilot",
78+model: { primary: `${AGENT_CONFIGURED_PROVIDER}/${AGENT_CONFIGURED_MODEL}` },
79+},
80+],
81+defaults: {
82+contextTokens: 200_000,
83+},
84+},
85+}));
86+}
87+88+/**
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+ *
94+ * No `model` / `modelProvider` set on the entry — the listing falls through
95+ * to the agent's configured default, which is the buggy path for ACP keys.
96+ */
97+function buildAcpSessionEntry(): SessionEntry {
98+return {
99+sessionId: "acp-session-id",
100+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+},
109+};
110+}
111+112+/**
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
130+ * GREEN-control case below. The agent default is the correct answer for
131+ * non-ACP sessions — those run through the openclaw-agent-driven flow that
132+ * actually uses the configured model.
133+ */
134+function buildNonAcpSessionEntry(): SessionEntry {
135+return {
136+sessionId: "non-acp-session-id",
137+updatedAt: Date.now() - 3 * 60_000,
138+};
139+}
140+141+describe("sessionsCommand model/modelProvider display for ACP sessions (catalog #20)", () => {
142+beforeEach(() => {
143+vi.useFakeTimers();
144+vi.setSystemTime(new Date("2025-12-06T00:00:00Z"));
145+mockAgentConfigWithCopilotModel();
146+});
147+148+afterEach(() => {
149+resetMockSessionsConfig();
150+vi.useRealTimers();
151+});
152+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.
160+//
161+// The discriminator the fix uses: `isAcpSessionKey(row.key)` AND
162+// `entry.acp != null` (persisted by the ACP control plane manager).
163+const store = writeStore(
164+{ [ACP_SESSION_KEY]: buildAcpSessionEntry() },
165+"sessions-acp-model-display-red",
166+);
167+168+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
169+const row = payload.sessions?.find((entry) => entry.key === ACP_SESSION_KEY);
170+171+expect(
172+row,
173+`Expected sessionsCommand --json to include a row for ${ACP_SESSION_KEY}; got none.`,
174+).toBeDefined();
175+expect(
176+row?.model,
177+`ACP session ${ACP_SESSION_KEY} reports model="${row?.model}" — that is the agent-configured ` +
178+`model (${AGENT_CONFIGURED_MODEL}), not what copilot actually used inside ACP. ` +
179+`resolveSessionDisplayModelRef (src/commands/sessions-display-model.ts:123) has zero ` +
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.`,
182+).not.toBe(AGENT_CONFIGURED_MODEL);
183+expect(
184+row?.modelProvider,
185+`ACP session ${ACP_SESSION_KEY} reports modelProvider="${row?.modelProvider}" — the ` +
186+`agent-configured provider (${AGENT_CONFIGURED_PROVIDER}), not the ACP runtime. ` +
187+`Same fix site as above; the overlay must gate on entry.acp presence.`,
188+).not.toBe(AGENT_CONFIGURED_PROVIDER);
189+});
190+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.
193+//
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).
200+const store = writeStore(
201+{ [ACP_SESSION_KEY]: buildAcpSessionEntry() },
202+"sessions-acp-model-display-fix-shape",
203+);
204+205+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
206+const row = payload.sessions?.find((entry) => entry.key === ACP_SESSION_KEY);
207+208+expect(row).toBeDefined();
209+expect(
210+row?.model,
211+`ACP session ${ACP_SESSION_KEY} should resolve model to "copilot-acp" (the catalog-chosen ` +
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.`,
214+).toBe("copilot-acp");
215+expect(
216+row?.modelProvider,
217+`ACP session ${ACP_SESSION_KEY} should resolve modelProvider to "acpx". Got ` +
218+`"${row?.modelProvider}". Same fix as the model assertion above; the overlay sets both ` +
219+`fields together so they remain internally consistent.`,
220+).toBe("acpx");
221+});
222+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+251+it("GREEN control: non-ACP session correctly reports the agent-configured model", async () => {
252+// GREEN today. The same agent configuration drives a non-ACP session
253+// (`agent:copilot:main`) — and for that session the agent-configured
254+// model IS the right answer because the openclaw-agent-driven flow
255+// actually runs that model. This control proves:
256+// 1. The test infrastructure is exercising the real resolver path
257+// (not a mock that would silently pass either way).
258+// 2. The configured-model branch of resolveSessionDisplayModelRef
259+// remains correct for non-ACP keys; the proposed sentinel overlay
260+// must NOT break this case (it should only fire when both
261+// isAcpSessionKey(row.key) is true AND entry.acp is present).
262+const store = writeStore(
263+{ [NON_ACP_SESSION_KEY]: buildNonAcpSessionEntry() },
264+"sessions-acp-model-display-green-control",
265+);
266+267+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
268+const row = payload.sessions?.find((entry) => entry.key === NON_ACP_SESSION_KEY);
269+270+expect(row).toBeDefined();
271+expect(row?.model).toBe(AGENT_CONFIGURED_MODEL);
272+expect(row?.modelProvider).toBe(AGENT_CONFIGURED_PROVIDER);
273+});
274+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。