






















@@ -0,0 +1,214 @@
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+writeStore,
8+} from "./sessions.test-helpers.js";
9+10+/**
11+ * Catalog #19 — `kind` misclassified as `"direct"` for ACP spawn-child sessions.
12+ *
13+ * Bug summary: `classifySessionKey` (defined twice — `src/commands/sessions.ts:136-152`
14+ * and `src/commands/status.summary.runtime.ts:129-145`) classifies a session
15+ * based ONLY on the key shape (`:group:` / `:channel:` substrings) plus
16+ * `entry.chatType`. It ignores `entry.spawnedBy` and `entry.deliveryContext`,
17+ * so ACP spawn-child sessions (e.g., `agent:copilot:acp:<uuid>` with
18+ * `spawnedBy: "agent:main:telegram:group:..."` and
19+ * `deliveryContext: { channel: "telegram", to: <groupId>, threadId: <topic> }`)
20+ * are misclassified as `kind: "direct"` even though they were spawned from a
21+ * group/topic-bound parent.
22+ *
23+ * Available kinds today:
24+ * "global" | "unknown" | "cron" | "group" | "direct"
25+ *
26+ * The fix shape proposed in the catalog is to add a new `"spawn-child"` kind
27+ * (or, alternatively, fall through to the parent's classification — but the
28+ * catalog calls out `"spawn-child"` as the cleanest minimal fix).
29+ *
30+ * NOTE ON DUPLICATION: the same logic lives in two places —
31+ * - `src/commands/sessions.ts:136-152` (called by `sessionsCommand`,
32+ * the path under test here)
33+ * - `src/commands/status.summary.runtime.ts:129-145`
34+ * The eventual fix MUST update both, or extract a shared helper.
35+ *
36+ * NOTE ON SURFACE: `classifySessionKey` is private to each file (not exported),
37+ * so this test drives the classification through the exposed seam:
38+ * `sessionsCommand --json` and inspects the `kind` field of each session row
39+ * (mirroring `src/commands/sessions.test.ts` and `sessions.acp-runtime-metadata.test.ts`).
40+ */
41+42+mockSessionsConfig();
43+44+const { sessionsCommand } = await import("./sessions.js");
45+46+type SessionRowKind = "global" | "unknown" | "cron" | "group" | "direct" | "spawn-child";
47+48+type SessionsJsonPayload = {
49+sessions?: Array<{
50+key: string;
51+kind: SessionRowKind;
52+}>;
53+};
54+55+const ACP_SPAWN_CHILD_KEY = "agent:copilot:acp:7de23a0a-799d-4d63-b1b1-a7de9d4cd840";
56+const ACP_DM_KEY = "agent:copilot:acp:86b7b5af-3773-4a56-b244-069d6c5d3db9";
57+const TELEGRAM_GROUP_KEY = "agent:main:telegram:group:-1003967207344:topic:1";
58+59+/**
60+ * SessionEntry shape mirroring the deployed-container record described in
61+ * the catalog (a copilot ACP session spawned by a telegram supergroup parent).
62+ * Only the fields the classifier and the JSON emit path care about are set;
63+ * everything else stays unset / default.
64+ */
65+function buildAcpSpawnChildEntry(): SessionEntry {
66+return {
67+sessionId: "spawn-child-session-id",
68+updatedAt: Date.now() - 2 * 60_000,
69+spawnedBy: TELEGRAM_GROUP_KEY,
70+deliveryContext: {
71+channel: "telegram",
72+to: "-1003967207344",
73+threadId: 323,
74+},
75+// No chatType — ACP spawn-child entries don't carry one. The classifier
76+// must infer "this came from a group" from spawnedBy / deliveryContext.
77+};
78+}
79+80+/**
81+ * Plain DM-driven ACP session: same key shape (`agent:copilot:acp:<uuid>`)
82+ * but no `spawnedBy` and a direct delivery context. Today's classifier
83+ * correctly reports `"direct"` for this; that behavior should be preserved
84+ * after the fix.
85+ */
86+function buildAcpDirectEntry(): SessionEntry {
87+return {
88+sessionId: "dm-session-id",
89+updatedAt: Date.now() - 5 * 60_000,
90+deliveryContext: {
91+channel: "telegram",
92+to: "+15555550123",
93+},
94+};
95+}
96+97+/**
98+ * Group session with a key that explicitly embeds `:group:` — the
99+ * classifier's existing key-shape branch picks this up correctly today
100+ * and reports `"group"`.
101+ */
102+function buildTelegramGroupEntry(): SessionEntry {
103+return {
104+sessionId: "group-session-id",
105+updatedAt: Date.now() - 10 * 60_000,
106+chatType: "group",
107+};
108+}
109+110+describe("sessionsCommand kind classification (catalog #19)", () => {
111+beforeEach(() => {
112+vi.useFakeTimers();
113+vi.setSystemTime(new Date("2025-12-06T00:00:00Z"));
114+});
115+116+afterEach(() => {
117+resetMockSessionsConfig();
118+vi.useRealTimers();
119+});
120+121+it("RED: ACP spawn-child session must NOT be classified as 'direct'", async () => {
122+// RED today. The classifier ignores `spawnedBy` and `deliveryContext`,
123+// so an ACP key with no `:group:` substring and no `chatType` falls
124+// through to `"direct"`. Operators see this session in
125+// `openclaw sessions --json` as `kind: "direct"` even though it was
126+// plainly spawned from a group/topic. See `src/commands/sessions.ts:136-152`.
127+const store = writeStore(
128+{ [ACP_SPAWN_CHILD_KEY]: buildAcpSpawnChildEntry() },
129+"sessions-kind-spawn-child-red",
130+);
131+132+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
133+const row = payload.sessions?.find((entry) => entry.key === ACP_SPAWN_CHILD_KEY);
134+135+expect(
136+row,
137+`Expected sessionsCommand --json to include a row for ${ACP_SPAWN_CHILD_KEY}; got none.`,
138+).toBeDefined();
139+expect(
140+row?.kind,
141+`ACP spawn-child session ${ACP_SPAWN_CHILD_KEY} is misclassified: kind="${row?.kind}". ` +
142+`It carries spawnedBy="${TELEGRAM_GROUP_KEY}" and deliveryContext.channel="telegram", ` +
143+`which clearly mark it as a non-direct origin. The classifier at ` +
144+`src/commands/sessions.ts:136-152 ignores these fields and returns "direct".`,
145+).not.toBe("direct");
146+});
147+148+it("RED (fix-shape): ACP spawn-child session should resolve to 'spawn-child'", async () => {
149+// RED today; flips GREEN once the proposed fix lands.
150+//
151+// The catalog's recommended fix introduces a new `"spawn-child"` kind
152+// checked BEFORE the key-shape branch so spawn-child ACP sessions take
153+// precedence over the fallback `"direct"` classification.
154+//
155+// If the fix author chooses a different label (e.g., `"acp-child"`) or
156+// a different shape (e.g., fall through to the parent's classification
157+// and report `"group"`), update this assertion to match. The structural
158+// point is that `entry.spawnedBy` / `entry.deliveryContext` MUST drive
159+// the classification for ACP children.
160+const store = writeStore(
161+{ [ACP_SPAWN_CHILD_KEY]: buildAcpSpawnChildEntry() },
162+"sessions-kind-spawn-child-fix-shape",
163+);
164+165+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
166+const row = payload.sessions?.find((entry) => entry.key === ACP_SPAWN_CHILD_KEY);
167+168+expect(row).toBeDefined();
169+expect(
170+row?.kind,
171+`ACP spawn-child session ${ACP_SPAWN_CHILD_KEY} should classify as "spawn-child" ` +
172+`(or whichever non-direct label the fix author chooses). Got "${row?.kind}". ` +
173+`Fix locations: src/commands/sessions.ts:136-152 AND ` +
174+`src/commands/status.summary.runtime.ts:129-145 (the same logic is duplicated; ` +
175+`extract to a shared helper or update both).`,
176+).toBe("spawn-child");
177+});
178+179+it("GREEN control: non-spawn-child ACP DM session resolves to 'direct'", async () => {
180+// GREEN today. An ACP-keyed session WITHOUT `spawnedBy` and with a
181+// direct delivery context (or none) correctly resolves to `"direct"`.
182+// This control proves the test infrastructure exercises the real
183+// classification path; if it accidentally regressed to a different
184+// value, that would indicate the test harness was broken.
185+const store = writeStore(
186+{ [ACP_DM_KEY]: buildAcpDirectEntry() },
187+"sessions-kind-acp-direct-control",
188+);
189+190+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
191+const row = payload.sessions?.find((entry) => entry.key === ACP_DM_KEY);
192+193+expect(row).toBeDefined();
194+expect(row?.kind).toBe("direct");
195+});
196+197+it("GREEN control: telegram group key with chatType='group' resolves to 'group'", async () => {
198+// GREEN today. The classifier's key-shape branch (`:group:` substring)
199+// and the `chatType === "group"` branch both fire for this entry,
200+// yielding `"group"`. This control proves the existing happy-path
201+// classification still works and is not silently broken by the test
202+// harness.
203+const store = writeStore(
204+{ [TELEGRAM_GROUP_KEY]: buildTelegramGroupEntry() },
205+"sessions-kind-group-control",
206+);
207+208+const payload = await runSessionsJson<SessionsJsonPayload>(sessionsCommand, store);
209+const row = payload.sessions?.find((entry) => entry.key === TELEGRAM_GROUP_KEY);
210+211+expect(row).toBeDefined();
212+expect(row?.kind).toBe("group");
213+});
214+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。