@tmustier/pi-agent-teams 0.4.0-beta.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +72 -9
- package/WORKFLOW.md +110 -0
- package/docs/claude-parity.md +18 -13
- package/docs/hook-contract.md +183 -0
- package/docs/smoke-test-plan.md +26 -7
- package/extensions/teams/activity-tracker.ts +296 -8
- package/extensions/teams/cleanup.ts +216 -3
- package/extensions/teams/hooks.ts +57 -5
- package/extensions/teams/leader-attach-commands.ts +8 -4
- package/extensions/teams/leader-inbox.ts +162 -4
- package/extensions/teams/leader-info-commands.ts +105 -3
- package/extensions/teams/leader-lifecycle-commands.ts +205 -3
- package/extensions/teams/leader-messaging-commands.ts +19 -7
- package/extensions/teams/leader-spawn-command.ts +5 -1
- package/extensions/teams/leader-team-command.ts +51 -2
- package/extensions/teams/leader-teams-tool.ts +387 -11
- package/extensions/teams/leader.ts +126 -52
- package/extensions/teams/mailbox.ts +6 -1
- package/extensions/teams/model-policy.ts +117 -0
- package/extensions/teams/spawn-types.ts +4 -0
- package/extensions/teams/teammate-rpc.ts +14 -0
- package/extensions/teams/teams-panel.ts +117 -19
- package/extensions/teams/teams-ui-shared.ts +205 -2
- package/extensions/teams/teams-widget.ts +67 -14
- package/extensions/teams/worker.ts +18 -6
- package/extensions/teams/worktree.ts +143 -0
- package/package.json +4 -2
- package/scripts/integration-cleanup-test.mts +419 -0
- package/scripts/integration-hooks-remediation-test.mts +382 -0
- package/scripts/integration-spawn-overrides-test.mts +10 -0
- package/scripts/smoke-test.mts +701 -3
- package/skills/agent-teams/SKILL.md +28 -7
package/scripts/smoke-test.mts
CHANGED
|
@@ -31,9 +31,12 @@ import {
|
|
|
31
31
|
} from "../extensions/teams/task-store.js";
|
|
32
32
|
import { ensureTeamConfig, loadTeamConfig, upsertMember, setMemberStatus, updateTeamHooksPolicy } from "../extensions/teams/team-config.js";
|
|
33
33
|
import { sanitizeName } from "../extensions/teams/names.js";
|
|
34
|
-
import { isDeprecatedTeammateModelId } from "../extensions/teams/model-policy.js";
|
|
34
|
+
import { formatProviderModel, isDeprecatedTeammateModelId, resolveTeammateModelSelection } from "../extensions/teams/model-policy.js";
|
|
35
|
+
import { getMemberModel, getMemberThinking, shortModelLabel } from "../extensions/teams/teams-ui-shared.js";
|
|
35
36
|
import { getTeamsNamingRules, getTeamsStrings } from "../extensions/teams/teams-style.js";
|
|
36
37
|
import {
|
|
38
|
+
HOOK_CONTRACT_VERSION,
|
|
39
|
+
buildHookContextPayload,
|
|
37
40
|
getTeamsHookFailureAction,
|
|
38
41
|
getTeamsHookFollowupOwnerPolicy,
|
|
39
42
|
getTeamsHookMaxReopensPerTask,
|
|
@@ -42,6 +45,7 @@ import {
|
|
|
42
45
|
shouldCreateHookFollowupTask,
|
|
43
46
|
shouldReopenTaskOnHookFailure,
|
|
44
47
|
} from "../extensions/teams/hooks.js";
|
|
48
|
+
import { TranscriptTracker, type TranscriptEntry } from "../extensions/teams/activity-tracker.js";
|
|
45
49
|
import { listDiscoveredTeams } from "../extensions/teams/team-discovery.js";
|
|
46
50
|
import {
|
|
47
51
|
acquireTeamAttachClaim,
|
|
@@ -50,6 +54,7 @@ import {
|
|
|
50
54
|
releaseTeamAttachClaim,
|
|
51
55
|
} from "../extensions/teams/team-attach-claim.js";
|
|
52
56
|
import { getTeamHelpText } from "../extensions/teams/leader-team-command.js";
|
|
57
|
+
import { isTeamDone, formatElapsed, lastMessageSummary } from "../extensions/teams/teams-ui-shared.js";
|
|
53
58
|
import {
|
|
54
59
|
TEAM_MAILBOX_NS,
|
|
55
60
|
isIdleNotification,
|
|
@@ -64,6 +69,8 @@ import {
|
|
|
64
69
|
isPlanApprovedMessage,
|
|
65
70
|
isPlanRejectedMessage,
|
|
66
71
|
} from "../extensions/teams/protocol.js";
|
|
72
|
+
import { pollLeaderInbox } from "../extensions/teams/leader-inbox.js";
|
|
73
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
67
74
|
|
|
68
75
|
// ── helpers ──────────────────────────────────────────────────────────
|
|
69
76
|
let passed = 0;
|
|
@@ -117,6 +124,81 @@ assert(!isDeprecatedTeammateModelId("claude-sonnet-4-5"), "does not block sonnet
|
|
|
117
124
|
assert(!isDeprecatedTeammateModelId("claude-sonnet-4.5"), "does not block sonnet-4.5");
|
|
118
125
|
assert(!isDeprecatedTeammateModelId("gpt-5.1-codex-mini"), "keeps current models allowed");
|
|
119
126
|
|
|
127
|
+
const modelResolvedExplicit = resolveTeammateModelSelection({
|
|
128
|
+
modelOverride: "openai-codex/gpt-5.1-codex-mini",
|
|
129
|
+
leaderProvider: "anthropic",
|
|
130
|
+
leaderModelId: "claude-sonnet-4-5",
|
|
131
|
+
});
|
|
132
|
+
assert(modelResolvedExplicit.ok, "resolveTeammateModelSelection accepts provider/model override");
|
|
133
|
+
if (modelResolvedExplicit.ok) {
|
|
134
|
+
assertEq(modelResolvedExplicit.value.source, "override", "explicit override source");
|
|
135
|
+
assertEq(formatProviderModel(modelResolvedExplicit.value.provider, modelResolvedExplicit.value.modelId), "openai-codex/gpt-5.1-codex-mini", "explicit override keeps provider/model");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const modelResolvedModelOnly = resolveTeammateModelSelection({
|
|
139
|
+
modelOverride: "gpt-5.1-codex-mini",
|
|
140
|
+
leaderProvider: "openai-codex",
|
|
141
|
+
leaderModelId: "gpt-5.1-codex-mini",
|
|
142
|
+
});
|
|
143
|
+
assert(modelResolvedModelOnly.ok, "resolveTeammateModelSelection accepts model-only override");
|
|
144
|
+
if (modelResolvedModelOnly.ok) {
|
|
145
|
+
assertEq(formatProviderModel(modelResolvedModelOnly.value.provider, modelResolvedModelOnly.value.modelId), "openai-codex/gpt-5.1-codex-mini", "model-only override inherits leader provider");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const modelResolvedInvalid = resolveTeammateModelSelection({ modelOverride: "openai-codex/" });
|
|
149
|
+
assert(!modelResolvedInvalid.ok, "resolveTeammateModelSelection rejects invalid provider/model override");
|
|
150
|
+
if (!modelResolvedInvalid.ok) {
|
|
151
|
+
assertEq(modelResolvedInvalid.reason, "invalid_override", "invalid override reason");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const modelResolvedDeprecated = resolveTeammateModelSelection({ modelOverride: "claude-sonnet-4" });
|
|
155
|
+
assert(!modelResolvedDeprecated.ok, "resolveTeammateModelSelection rejects deprecated override");
|
|
156
|
+
if (!modelResolvedDeprecated.ok) {
|
|
157
|
+
assertEq(modelResolvedDeprecated.reason, "deprecated_override", "deprecated override reason");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const modelResolvedDeprecatedLeader = resolveTeammateModelSelection({
|
|
161
|
+
leaderProvider: "anthropic",
|
|
162
|
+
leaderModelId: "claude-sonnet-4-20250514",
|
|
163
|
+
});
|
|
164
|
+
assert(modelResolvedDeprecatedLeader.ok, "resolveTeammateModelSelection handles deprecated leader model fallback");
|
|
165
|
+
if (modelResolvedDeprecatedLeader.ok) {
|
|
166
|
+
assertEq(modelResolvedDeprecatedLeader.value.source, "default", "deprecated leader model is not inherited");
|
|
167
|
+
assertEq(formatProviderModel(modelResolvedDeprecatedLeader.value.provider, modelResolvedDeprecatedLeader.value.modelId), null, "deprecated leader fallback has no explicit model");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── 1c. UI shared helpers ────────────────────────────────────────────
|
|
171
|
+
console.log("\n1c. teams-ui-shared helpers");
|
|
172
|
+
{
|
|
173
|
+
// shortModelLabel
|
|
174
|
+
assertEq(shortModelLabel("anthropic/claude-sonnet-4-5-20250514"), "claude-sonnet-4-5", "shortModelLabel strips provider and date");
|
|
175
|
+
assertEq(shortModelLabel("openai-codex/gpt-5.1-codex-mini"), "gpt-5.1-codex-mini", "shortModelLabel strips provider, no date");
|
|
176
|
+
assertEq(shortModelLabel("claude-sonnet-4-5-20250514"), "claude-sonnet-4-5", "shortModelLabel strips date without provider");
|
|
177
|
+
assertEq(shortModelLabel("claude-opus-4-20250514-v2"), "claude-opus-4", "shortModelLabel strips date with variant suffix");
|
|
178
|
+
assertEq(shortModelLabel("gpt-5.1-codex-mini"), "gpt-5.1-codex-mini", "shortModelLabel keeps clean model id");
|
|
179
|
+
|
|
180
|
+
// getMemberModel
|
|
181
|
+
assertEq(getMemberModel(undefined), null, "getMemberModel returns null for undefined member");
|
|
182
|
+
assertEq(
|
|
183
|
+
getMemberModel({ name: "a", role: "worker", addedAt: "", status: "online" }),
|
|
184
|
+
null,
|
|
185
|
+
"getMemberModel returns null when no meta",
|
|
186
|
+
);
|
|
187
|
+
assertEq(
|
|
188
|
+
getMemberModel({ name: "a", role: "worker", addedAt: "", status: "online", meta: { model: "anthropic/claude-sonnet-4-5" } }),
|
|
189
|
+
"anthropic/claude-sonnet-4-5",
|
|
190
|
+
"getMemberModel extracts model from meta",
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
// getMemberThinking
|
|
194
|
+
assertEq(getMemberThinking(undefined), null, "getMemberThinking returns null for undefined member");
|
|
195
|
+
assertEq(
|
|
196
|
+
getMemberThinking({ name: "a", role: "worker", addedAt: "", status: "online", meta: { thinkingLevel: "high" } }),
|
|
197
|
+
"high",
|
|
198
|
+
"getMemberThinking extracts thinking from meta",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
120
202
|
// ── 2. fs-lock ───────────────────────────────────────────────────────
|
|
121
203
|
console.log("\n2. fs-lock.withLock");
|
|
122
204
|
{
|
|
@@ -205,6 +287,31 @@ console.log("\n3. mailbox");
|
|
|
205
287
|
});
|
|
206
288
|
const msgs3 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
207
289
|
assertEq(msgs3.length, 2, "pop returns 2 new unread messages");
|
|
290
|
+
|
|
291
|
+
// urgent messages
|
|
292
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
|
|
293
|
+
from: "peer",
|
|
294
|
+
text: "urgent interrupt",
|
|
295
|
+
timestamp: "2025-01-01T00:03:00Z",
|
|
296
|
+
urgent: true,
|
|
297
|
+
});
|
|
298
|
+
const msgs4 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
299
|
+
assertEq(msgs4.length, 1, "pop returns 1 urgent message");
|
|
300
|
+
{
|
|
301
|
+
const m = msgs4.at(0);
|
|
302
|
+
assertEq(m?.urgent, true, "urgent flag preserved");
|
|
303
|
+
assertEq(m?.text, "urgent interrupt", "urgent message text correct");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// non-urgent messages default to no urgent field
|
|
307
|
+
await writeToMailbox(teamDir, TEAM_MAILBOX_NS, "agent1", {
|
|
308
|
+
from: "peer",
|
|
309
|
+
text: "normal msg",
|
|
310
|
+
timestamp: "2025-01-01T00:04:00Z",
|
|
311
|
+
});
|
|
312
|
+
const msgs5 = await popUnreadMessages(teamDir, TEAM_MAILBOX_NS, "agent1");
|
|
313
|
+
assertEq(msgs5.length, 1, "pop returns 1 normal message");
|
|
314
|
+
assertEq(msgs5.at(0)?.urgent, undefined, "non-urgent message has no urgent flag");
|
|
208
315
|
}
|
|
209
316
|
|
|
210
317
|
// ── 4. task-store ────────────────────────────────────────────────────
|
|
@@ -557,6 +664,7 @@ console.log("\n9. teams-hooks (quality gates)");
|
|
|
557
664
|
|
|
558
665
|
assert(res.ran === true, "runs on_task_completed hook");
|
|
559
666
|
assert(res.exitCode === 0, "hook exit code is 0");
|
|
667
|
+
assertEq(res.contractVersion, HOOK_CONTRACT_VERSION, "result includes contract version");
|
|
560
668
|
assert(fs.existsSync(outFile), "hook wrote output file");
|
|
561
669
|
const hookOutRaw = fs.readFileSync(outFile, "utf8").trim();
|
|
562
670
|
const hookOut = JSON.parse(hookOutRaw) as {
|
|
@@ -601,6 +709,67 @@ console.log("\n9. teams-hooks (quality gates)");
|
|
|
601
709
|
assertEq(getTeamsHookMaxReopensPerTask({}), 3, "hook max reopens default is 3");
|
|
602
710
|
assertEq(getTeamsHookMaxReopensPerTask({ PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK: "0" }), 0, "hook max reopens supports zero");
|
|
603
711
|
|
|
712
|
+
// contract version constant
|
|
713
|
+
assert(typeof HOOK_CONTRACT_VERSION === "number", "HOOK_CONTRACT_VERSION is a number");
|
|
714
|
+
assert(HOOK_CONTRACT_VERSION >= 1, "HOOK_CONTRACT_VERSION >= 1");
|
|
715
|
+
assert(Number.isInteger(HOOK_CONTRACT_VERSION), "HOOK_CONTRACT_VERSION is an integer");
|
|
716
|
+
|
|
717
|
+
// buildHookContextPayload produces correct shape
|
|
718
|
+
const payload = buildHookContextPayload({
|
|
719
|
+
event: "task_completed",
|
|
720
|
+
teamId: "test-team",
|
|
721
|
+
teamDir: "/tmp/test",
|
|
722
|
+
taskListId: "test-tl",
|
|
723
|
+
style: "normal",
|
|
724
|
+
memberName: "agent1",
|
|
725
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
726
|
+
completedTask: {
|
|
727
|
+
id: "42",
|
|
728
|
+
subject: "Test subject",
|
|
729
|
+
description: "Test desc",
|
|
730
|
+
owner: "agent1",
|
|
731
|
+
status: "completed",
|
|
732
|
+
blocks: ["43"],
|
|
733
|
+
blockedBy: [],
|
|
734
|
+
metadata: { result: "ok" },
|
|
735
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
736
|
+
updatedAt: "2025-01-01T00:00:00Z",
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
assertEq(payload.version, HOOK_CONTRACT_VERSION, "payload version matches constant");
|
|
740
|
+
assertEq(payload.event, "task_completed", "payload event correct");
|
|
741
|
+
assertEq(payload.team.id, "test-team", "payload team.id correct");
|
|
742
|
+
assertEq(payload.member, "agent1", "payload member correct");
|
|
743
|
+
assertEq(payload.task?.id, "42", "payload task.id correct");
|
|
744
|
+
assertEq(payload.task?.owner, "agent1", "payload task.owner correct");
|
|
745
|
+
assertEq(payload.task?.blocks.at(0), "43", "payload task.blocks preserved");
|
|
746
|
+
|
|
747
|
+
// null task for idle events
|
|
748
|
+
const idlePayload = buildHookContextPayload({
|
|
749
|
+
event: "idle",
|
|
750
|
+
teamId: "test-team",
|
|
751
|
+
teamDir: "/tmp/test",
|
|
752
|
+
taskListId: "test-tl",
|
|
753
|
+
style: "normal",
|
|
754
|
+
});
|
|
755
|
+
assertEq(idlePayload.task, null, "idle payload has null task");
|
|
756
|
+
assertEq(idlePayload.member, null, "idle payload has null member when not provided");
|
|
757
|
+
|
|
758
|
+
// disabled hooks still return contract version
|
|
759
|
+
const disabledRes = await runTeamsHook({
|
|
760
|
+
invocation: {
|
|
761
|
+
event: "idle",
|
|
762
|
+
teamId: "noop",
|
|
763
|
+
teamDir: "/tmp/noop",
|
|
764
|
+
taskListId: "noop",
|
|
765
|
+
style: "normal",
|
|
766
|
+
},
|
|
767
|
+
cwd: tmpRoot,
|
|
768
|
+
env: { PI_TEAMS_HOOKS_ENABLED: "0" },
|
|
769
|
+
});
|
|
770
|
+
assertEq(disabledRes.ran, false, "disabled hooks do not run");
|
|
771
|
+
assertEq(disabledRes.contractVersion, HOOK_CONTRACT_VERSION, "disabled hooks still report contract version");
|
|
772
|
+
|
|
604
773
|
// restore env
|
|
605
774
|
if (prevRoot === undefined) delete process.env.PI_TEAMS_ROOT_DIR;
|
|
606
775
|
else process.env.PI_TEAMS_ROOT_DIR = prevRoot;
|
|
@@ -672,20 +841,352 @@ console.log("\n10. team discovery + attach claims");
|
|
|
672
841
|
}
|
|
673
842
|
}
|
|
674
843
|
|
|
675
|
-
// ── 11.
|
|
676
|
-
console.log("\n11.
|
|
844
|
+
// ── 11. /team done (end-of-run cleanup) ──────────────────────────────
|
|
845
|
+
console.log("\n11. /team done (end-of-run)");
|
|
846
|
+
{
|
|
847
|
+
const doneDir = path.join(tmpRoot, "done-test");
|
|
848
|
+
const doneTeamId = "done-team";
|
|
849
|
+
const doneTeamDir = path.join(doneDir, doneTeamId);
|
|
850
|
+
const doneTlId = doneTeamId;
|
|
851
|
+
|
|
852
|
+
await ensureTeamConfig(doneTeamDir, { teamId: doneTeamId, taskListId: doneTlId, leadName: "team-lead", style: "normal" });
|
|
853
|
+
await upsertMember(doneTeamDir, { name: "alice", role: "worker", status: "online" });
|
|
854
|
+
await upsertMember(doneTeamDir, { name: "bob", role: "worker", status: "online" });
|
|
855
|
+
|
|
856
|
+
// Create tasks and mark all completed
|
|
857
|
+
const t1 = await createTask(doneTeamDir, doneTlId, { subject: "Task 1", description: "", owner: "alice" });
|
|
858
|
+
const t2 = await createTask(doneTeamDir, doneTlId, { subject: "Task 2", description: "", owner: "bob" });
|
|
859
|
+
await completeTask(doneTeamDir, doneTlId, t1.id, "alice");
|
|
860
|
+
await completeTask(doneTeamDir, doneTlId, t2.id, "bob");
|
|
861
|
+
|
|
862
|
+
const doneTasks = await listTasks(doneTeamDir, doneTlId);
|
|
863
|
+
const allCompleted = doneTasks.every((t) => t.status === "completed");
|
|
864
|
+
assert(allCompleted, "/team done precondition: all tasks completed");
|
|
865
|
+
assertEq(doneTasks.length, 2, "/team done: 2 tasks exist");
|
|
866
|
+
|
|
867
|
+
// Mark workers offline (simulating what /team done does)
|
|
868
|
+
await setMemberStatus(doneTeamDir, "alice", "offline", { meta: { stoppedReason: "team-done" } });
|
|
869
|
+
await setMemberStatus(doneTeamDir, "bob", "offline", { meta: { stoppedReason: "team-done" } });
|
|
870
|
+
|
|
871
|
+
const cfgAfter = await loadTeamConfig(doneTeamDir);
|
|
872
|
+
const onlineAfter = (cfgAfter?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
873
|
+
assertEq(onlineAfter.length, 0, "/team done: all workers offline after done");
|
|
874
|
+
|
|
875
|
+
const offlineAlice = cfgAfter?.members.find((m) => m.name === "alice");
|
|
876
|
+
assert(offlineAlice?.meta?.["stoppedReason"] === "team-done", "/team done: alice has stoppedReason=team-done");
|
|
877
|
+
|
|
878
|
+
// Test force-done with in-progress tasks (simulates --force path)
|
|
879
|
+
const forceDir = path.join(tmpRoot, "force-done-test");
|
|
880
|
+
const forceTeamId = "force-done-team";
|
|
881
|
+
const forceTeamDir = path.join(forceDir, forceTeamId);
|
|
882
|
+
const forceTlId = forceTeamId;
|
|
883
|
+
|
|
884
|
+
await ensureTeamConfig(forceTeamDir, { teamId: forceTeamId, taskListId: forceTlId, leadName: "team-lead", style: "normal" });
|
|
885
|
+
await upsertMember(forceTeamDir, { name: "carol", role: "worker", status: "online" });
|
|
886
|
+
|
|
887
|
+
const ft1 = await createTask(forceTeamDir, forceTlId, { subject: "Ongoing work", description: "", owner: "carol" });
|
|
888
|
+
await startAssignedTask(forceTeamDir, forceTlId, ft1.id, "carol");
|
|
889
|
+
|
|
890
|
+
const forceTasks = await listTasks(forceTeamDir, forceTlId);
|
|
891
|
+
assertEq(forceTasks[0]?.status, "in_progress", "/team done --force precondition: task in_progress");
|
|
892
|
+
|
|
893
|
+
// Simulate force-done: unassign in-progress tasks
|
|
894
|
+
await unassignTasksForAgent(forceTeamDir, forceTlId, "carol", "team done");
|
|
895
|
+
await setMemberStatus(forceTeamDir, "carol", "offline", { meta: { stoppedReason: "team-done" } });
|
|
896
|
+
|
|
897
|
+
const forceTasksAfter = await listTasks(forceTeamDir, forceTlId);
|
|
898
|
+
assertEq(forceTasksAfter[0]?.status, "pending", "/team done --force: in-progress task reset to pending");
|
|
899
|
+
assertEq(forceTasksAfter[0]?.owner, undefined, "/team done --force: in-progress task unassigned");
|
|
900
|
+
|
|
901
|
+
const forceCfgAfter = await loadTeamConfig(forceTeamDir);
|
|
902
|
+
const forceCarol = forceCfgAfter?.members.find((m) => m.name === "carol");
|
|
903
|
+
assertEq(forceCarol?.status, "offline", "/team done --force: worker offline");
|
|
904
|
+
|
|
905
|
+
// Test idempotency: calling done again on already-done team is safe
|
|
906
|
+
await setMemberStatus(doneTeamDir, "alice", "offline", { meta: { stoppedReason: "team-done" } });
|
|
907
|
+
await setMemberStatus(doneTeamDir, "bob", "offline", { meta: { stoppedReason: "team-done" } });
|
|
908
|
+
const idempotentCfg = await loadTeamConfig(doneTeamDir);
|
|
909
|
+
const idempotentOnline = (idempotentCfg?.members ?? []).filter((m) => m.role === "worker" && m.status === "online");
|
|
910
|
+
assertEq(idempotentOnline.length, 0, "/team done idempotent: still 0 online after second done");
|
|
911
|
+
const idempotentTasks = await listTasks(doneTeamDir, doneTlId);
|
|
912
|
+
const idempotentAllDone = idempotentTasks.every((t) => t.status === "completed");
|
|
913
|
+
assert(idempotentAllDone, "/team done idempotent: tasks still completed after second done");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ── 12. isTeamDone (pure function unit tests) ───────────────────────
|
|
917
|
+
console.log("\n12. isTeamDone");
|
|
918
|
+
{
|
|
919
|
+
// Minimal mock: isTeamDone only reads .status from TeammateRpc values
|
|
920
|
+
type MockRpc = { status: string };
|
|
921
|
+
const mockMap = (entries: Array<[string, MockRpc]>) =>
|
|
922
|
+
new Map(entries) as unknown as ReadonlyMap<string, import("../extensions/teams/teammate-rpc.js").TeammateRpc>;
|
|
923
|
+
|
|
924
|
+
const completedTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
|
|
925
|
+
id, subject: "x", description: "", status: "completed",
|
|
926
|
+
blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
|
|
927
|
+
});
|
|
928
|
+
const pendingTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
|
|
929
|
+
id, subject: "x", description: "", status: "pending",
|
|
930
|
+
blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
|
|
931
|
+
});
|
|
932
|
+
const inProgressTask = (id: string): import("../extensions/teams/task-store.js").TeamTask => ({
|
|
933
|
+
id, subject: "x", description: "", status: "in_progress",
|
|
934
|
+
blocks: [], blockedBy: [], metadata: {}, createdAt: "", updatedAt: "",
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// No tasks → not done
|
|
938
|
+
assert(!isTeamDone([], mockMap([])), "isTeamDone: empty tasks = false");
|
|
939
|
+
|
|
940
|
+
// All completed, no teammates → done
|
|
941
|
+
assert(isTeamDone([completedTask("1"), completedTask("2")], mockMap([])), "isTeamDone: all completed, no teammates = true");
|
|
942
|
+
|
|
943
|
+
// All completed, all idle → done
|
|
944
|
+
assert(
|
|
945
|
+
isTeamDone([completedTask("1")], mockMap([["alice", { status: "idle" }]])),
|
|
946
|
+
"isTeamDone: all completed + idle teammate = true",
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
// All completed, one streaming → not done
|
|
950
|
+
assert(
|
|
951
|
+
!isTeamDone([completedTask("1")], mockMap([["alice", { status: "streaming" }]])),
|
|
952
|
+
"isTeamDone: all completed + streaming teammate = false",
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
// All completed, one starting → not done
|
|
956
|
+
assert(
|
|
957
|
+
!isTeamDone([completedTask("1")], mockMap([["alice", { status: "starting" }]])),
|
|
958
|
+
"isTeamDone: all completed + starting teammate = false",
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
// All completed, stopped teammate → done
|
|
962
|
+
assert(
|
|
963
|
+
isTeamDone([completedTask("1")], mockMap([["alice", { status: "stopped" }]])),
|
|
964
|
+
"isTeamDone: all completed + stopped teammate = true",
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
// Pending task → not done
|
|
968
|
+
assert(
|
|
969
|
+
!isTeamDone([completedTask("1"), pendingTask("2")], mockMap([])),
|
|
970
|
+
"isTeamDone: one pending = false",
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
// In-progress task → not done
|
|
974
|
+
assert(
|
|
975
|
+
!isTeamDone([completedTask("1"), inProgressTask("2")], mockMap([])),
|
|
976
|
+
"isTeamDone: one in-progress = false",
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// Mixed: all completed, mixed teammate states (idle + stopped) → done
|
|
980
|
+
assert(
|
|
981
|
+
isTeamDone(
|
|
982
|
+
[completedTask("1"), completedTask("2")],
|
|
983
|
+
mockMap([["alice", { status: "idle" }], ["bob", { status: "stopped" }]]),
|
|
984
|
+
),
|
|
985
|
+
"isTeamDone: all completed + idle+stopped = true",
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ── 13. formatElapsed + lastMessageSummary ───────────────────────────
|
|
990
|
+
console.log("\n13. formatElapsed + lastMessageSummary");
|
|
991
|
+
{
|
|
992
|
+
assert(formatElapsed(500) === "0s", "formatElapsed <1s rounds to 0s");
|
|
993
|
+
assert(formatElapsed(1000) === "1s", "formatElapsed 1s");
|
|
994
|
+
assert(formatElapsed(45000) === "45s", "formatElapsed 45s");
|
|
995
|
+
assert(formatElapsed(90000) === "1m30s", "formatElapsed 1m30s");
|
|
996
|
+
assert(formatElapsed(120000) === "2m", "formatElapsed 2m exact");
|
|
997
|
+
assert(formatElapsed(3600000) === "1h", "formatElapsed 1h exact");
|
|
998
|
+
assert(formatElapsed(3660000) === "1h1m", "formatElapsed 1h1m");
|
|
999
|
+
|
|
1000
|
+
// lastMessageSummary with undefined rpc
|
|
1001
|
+
assert(lastMessageSummary(undefined) === "", "lastMessageSummary(undefined) is empty");
|
|
1002
|
+
|
|
1003
|
+
// lastMessageSummary with mock rpc-like object (only lastAssistantText matters)
|
|
1004
|
+
const mockRpc = { lastAssistantText: "Hello world, this is a test" } as unknown as import("../extensions/teams/teammate-rpc.js").TeammateRpc;
|
|
1005
|
+
const summary = lastMessageSummary(mockRpc, 20);
|
|
1006
|
+
assert(summary.length <= 20, "lastMessageSummary respects maxLen");
|
|
1007
|
+
assert(summary.endsWith("…"), "lastMessageSummary truncates with ellipsis");
|
|
1008
|
+
|
|
1009
|
+
const shortRpc = { lastAssistantText: "Short" } as unknown as import("../extensions/teams/teammate-rpc.js").TeammateRpc;
|
|
1010
|
+
assert(lastMessageSummary(shortRpc, 20) === "Short", "lastMessageSummary keeps short text intact");
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ── 14. leader-inbox LLM message injection ───────────────────────────
|
|
1014
|
+
console.log("\n14. leader-inbox LLM message injection");
|
|
1015
|
+
{
|
|
1016
|
+
const inboxTeamDir = path.join(tmpRoot, "inbox-test");
|
|
1017
|
+
const inboxTaskListId = "inbox-tl";
|
|
1018
|
+
const leadName = "team-lead";
|
|
1019
|
+
const style = "default";
|
|
1020
|
+
|
|
1021
|
+
// Set up team config
|
|
1022
|
+
await ensureTeamConfig(inboxTeamDir, { teamId: "inbox-team", taskListId: inboxTaskListId, leadName, style });
|
|
1023
|
+
|
|
1024
|
+
// Create and complete a task with a result
|
|
1025
|
+
const t1 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Fix tests", description: "", owner: "alice" });
|
|
1026
|
+
await completeTask(inboxTeamDir, inboxTaskListId, t1.id, "alice", "All 12 tests passing");
|
|
1027
|
+
|
|
1028
|
+
// Write an idle notification with completedTaskId
|
|
1029
|
+
const ts = new Date().toISOString();
|
|
1030
|
+
await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
|
|
1031
|
+
from: "alice",
|
|
1032
|
+
text: JSON.stringify({
|
|
1033
|
+
type: "idle_notification",
|
|
1034
|
+
from: "alice",
|
|
1035
|
+
timestamp: ts,
|
|
1036
|
+
completedTaskId: t1.id,
|
|
1037
|
+
completedStatus: "completed",
|
|
1038
|
+
}),
|
|
1039
|
+
timestamp: ts,
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
// Track messages sent to leader LLM
|
|
1043
|
+
const llmMessages: Array<{ content: string; options?: { deliverAs?: string } }> = [];
|
|
1044
|
+
|
|
1045
|
+
// Minimal ExtensionContext stub
|
|
1046
|
+
const stubCtx = {
|
|
1047
|
+
cwd: inboxTeamDir,
|
|
1048
|
+
ui: { notify: () => {} },
|
|
1049
|
+
sessionManager: { getSessionId: () => "inbox-team" },
|
|
1050
|
+
} as unknown as ExtensionContext;
|
|
1051
|
+
|
|
1052
|
+
await pollLeaderInbox({
|
|
1053
|
+
ctx: stubCtx,
|
|
1054
|
+
teamId: "inbox-team",
|
|
1055
|
+
teamDir: inboxTeamDir,
|
|
1056
|
+
taskListId: inboxTaskListId,
|
|
1057
|
+
leadName,
|
|
1058
|
+
style,
|
|
1059
|
+
pendingPlanApprovals: new Map(),
|
|
1060
|
+
sendLeaderLlmMessage: (content, options) => {
|
|
1061
|
+
llmMessages.push({ content, options });
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
assert(llmMessages.length === 1, "one LLM message sent on task completion");
|
|
1066
|
+
const msg0 = llmMessages[0];
|
|
1067
|
+
if (msg0) {
|
|
1068
|
+
assert(msg0.content.includes("[Team]"), "LLM message has [Team] prefix");
|
|
1069
|
+
assert(msg0.content.includes("alice"), "LLM message includes worker name");
|
|
1070
|
+
assert(msg0.content.includes(t1.id), "LLM message includes task id");
|
|
1071
|
+
assert(msg0.content.includes("Fix tests"), "LLM message includes task subject");
|
|
1072
|
+
assert(msg0.content.includes("All 12 tests passing"), "LLM message includes result");
|
|
1073
|
+
assert(msg0.content.includes("All 1 task(s) are now completed"), "LLM message includes allDone summary");
|
|
1074
|
+
assertEq(msg0.options?.deliverAs, "followUp", "LLM message uses followUp delivery");
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Test failure path with abort metadata
|
|
1078
|
+
const t2 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Deploy staging", description: "", owner: "bob" });
|
|
1079
|
+
await updateTask(inboxTeamDir, inboxTaskListId, t2.id, (cur) => ({
|
|
1080
|
+
...cur,
|
|
1081
|
+
status: "pending",
|
|
1082
|
+
metadata: {
|
|
1083
|
+
...(cur.metadata ?? {}),
|
|
1084
|
+
abortReason: "timeout after 60s",
|
|
1085
|
+
partialResult: "Deployed but health check failed",
|
|
1086
|
+
},
|
|
1087
|
+
}));
|
|
1088
|
+
|
|
1089
|
+
const ts2 = new Date().toISOString();
|
|
1090
|
+
await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
|
|
1091
|
+
from: "bob",
|
|
1092
|
+
text: JSON.stringify({
|
|
1093
|
+
type: "idle_notification",
|
|
1094
|
+
from: "bob",
|
|
1095
|
+
timestamp: ts2,
|
|
1096
|
+
completedTaskId: t2.id,
|
|
1097
|
+
completedStatus: "failed",
|
|
1098
|
+
}),
|
|
1099
|
+
timestamp: ts2,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
llmMessages.length = 0;
|
|
1103
|
+
await pollLeaderInbox({
|
|
1104
|
+
ctx: stubCtx,
|
|
1105
|
+
teamId: "inbox-team",
|
|
1106
|
+
teamDir: inboxTeamDir,
|
|
1107
|
+
taskListId: inboxTaskListId,
|
|
1108
|
+
leadName,
|
|
1109
|
+
style,
|
|
1110
|
+
pendingPlanApprovals: new Map(),
|
|
1111
|
+
sendLeaderLlmMessage: (content, options) => {
|
|
1112
|
+
llmMessages.push({ content, options });
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
assert(llmMessages.length === 1, "one LLM message sent on task failure");
|
|
1117
|
+
const failMsg = llmMessages[0];
|
|
1118
|
+
if (failMsg) {
|
|
1119
|
+
assert(failMsg.content.includes("failed"), "failure LLM message includes failed");
|
|
1120
|
+
assert(failMsg.content.includes("timeout after 60s"), "failure LLM message includes abortReason");
|
|
1121
|
+
assert(failMsg.content.includes("health check failed"), "failure LLM message includes partialResult");
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Test hook-aware allDone qualifier
|
|
1125
|
+
const t3 = await createTask(inboxTeamDir, inboxTaskListId, { subject: "Final task", description: "", owner: "carol" });
|
|
1126
|
+
await completeTask(inboxTeamDir, inboxTaskListId, t3.id, "carol", "Done");
|
|
1127
|
+
// Also complete t2 so all tasks are done
|
|
1128
|
+
await updateTask(inboxTeamDir, inboxTaskListId, t2.id, (cur) => ({
|
|
1129
|
+
...cur,
|
|
1130
|
+
status: "completed",
|
|
1131
|
+
owner: "bob",
|
|
1132
|
+
}));
|
|
1133
|
+
|
|
1134
|
+
const ts3 = new Date().toISOString();
|
|
1135
|
+
await writeToMailbox(inboxTeamDir, TEAM_MAILBOX_NS, leadName, {
|
|
1136
|
+
from: "carol",
|
|
1137
|
+
text: JSON.stringify({
|
|
1138
|
+
type: "idle_notification",
|
|
1139
|
+
from: "carol",
|
|
1140
|
+
timestamp: ts3,
|
|
1141
|
+
completedTaskId: t3.id,
|
|
1142
|
+
completedStatus: "completed",
|
|
1143
|
+
}),
|
|
1144
|
+
timestamp: ts3,
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
llmMessages.length = 0;
|
|
1148
|
+
await pollLeaderInbox({
|
|
1149
|
+
ctx: stubCtx,
|
|
1150
|
+
teamId: "inbox-team",
|
|
1151
|
+
teamDir: inboxTeamDir,
|
|
1152
|
+
taskListId: inboxTaskListId,
|
|
1153
|
+
leadName,
|
|
1154
|
+
style,
|
|
1155
|
+
pendingPlanApprovals: new Map(),
|
|
1156
|
+
enqueueHook: () => {}, // hooks present → should qualify allDone
|
|
1157
|
+
sendLeaderLlmMessage: (content, options) => {
|
|
1158
|
+
llmMessages.push({ content, options });
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
assert(llmMessages.length === 1, "one LLM message sent with hooks active");
|
|
1163
|
+
const hookMsg = llmMessages[0];
|
|
1164
|
+
if (hookMsg) {
|
|
1165
|
+
assert(hookMsg.content.includes("quality gates are still running"), "allDone qualified when hooks active");
|
|
1166
|
+
assert(!hookMsg.content.includes("Review results and determine next steps"), "no premature wrap-up prompt when hooks active");
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// ── 15. docs/help drift guard ────────────────────────────────────────
|
|
1171
|
+
console.log("\n15. docs/help drift guard");
|
|
677
1172
|
{
|
|
678
1173
|
const help = getTeamHelpText();
|
|
1174
|
+
assert(help.includes("/team done"), "help mentions /team done");
|
|
679
1175
|
assert(help.includes("/team style list"), "help mentions /team style list");
|
|
680
1176
|
assert(help.includes("/team style init"), "help mentions /team style init");
|
|
681
1177
|
assert(help.includes("/team attach <teamId> [--claim]"), "help mentions /team attach claim mode");
|
|
682
1178
|
assert(help.includes("/team detach"), "help mentions /team detach");
|
|
1179
|
+
assert(help.includes("[--urgent]"), "help mentions --urgent flag");
|
|
1180
|
+
assert(help.includes("/team gc"), "help mentions /team gc");
|
|
1181
|
+
assert(help.includes("/team status"), "help mentions /team status");
|
|
683
1182
|
|
|
684
1183
|
const readmePath = path.join(process.cwd(), "README.md");
|
|
685
1184
|
if (!fs.existsSync(readmePath)) {
|
|
686
1185
|
console.log(" (skipped) README.md not found");
|
|
687
1186
|
} else {
|
|
688
1187
|
const readme = fs.readFileSync(readmePath, "utf8");
|
|
1188
|
+
assert(readme.includes("/team done"), "README mentions /team done");
|
|
1189
|
+
assert(readme.includes("\"action\": \"team_done\""), "README mentions teams tool team_done action");
|
|
689
1190
|
assert(readme.includes("/team style list"), "README mentions /team style list");
|
|
690
1191
|
assert(readme.includes("/team attach <teamId> [--claim]"), "README mentions /team attach claim mode");
|
|
691
1192
|
assert(readme.includes("/team detach"), "README mentions /team detach");
|
|
@@ -696,6 +1197,8 @@ console.log("\n11. docs/help drift guard");
|
|
|
696
1197
|
assert(readme.includes("\"action\": \"plan_approve\""), "README mentions teams tool plan_approve action");
|
|
697
1198
|
assert(readme.includes("\"action\": \"hooks_policy_get\""), "README mentions teams tool hooks_policy_get action");
|
|
698
1199
|
assert(readme.includes("\"action\": \"hooks_policy_set\""), "README mentions teams tool hooks_policy_set action");
|
|
1200
|
+
assert(readme.includes("\"action\": \"model_policy_get\""), "README mentions teams tool model_policy_get action");
|
|
1201
|
+
assert(readme.includes("\"action\": \"model_policy_check\""), "README mentions teams tool model_policy_check action");
|
|
699
1202
|
assert(readme.includes("PI_TEAMS_HOOKS_FAILURE_ACTION"), "README mentions hook failure action policy");
|
|
700
1203
|
assert(readme.includes("PI_TEAMS_HOOKS_MAX_REOPENS_PER_TASK"), "README mentions hook reopen cap policy");
|
|
701
1204
|
assert(readme.includes("PI_TEAMS_HOOK_CONTEXT_JSON"), "README mentions hook context json contract");
|
|
@@ -704,7 +1207,202 @@ console.log("\n11. docs/help drift guard");
|
|
|
704
1207
|
assert(readme.includes("`t` or `shift+t`"), "README mentions panel task toggle key");
|
|
705
1208
|
assert(readme.includes("task view: `c` complete"), "README mentions panel task mutations");
|
|
706
1209
|
assert(readme.includes("`r` reassign"), "README mentions panel task reassignment");
|
|
1210
|
+
assert(readme.includes("tool args inline"), "README mentions transcript tool content display");
|
|
707
1211
|
assert(readme.includes("_styles"), "README mentions _styles directory");
|
|
1212
|
+
assert(readme.includes("[--urgent]"), "README mentions --urgent flag");
|
|
1213
|
+
assert(readme.includes("\"urgent\": true"), "README mentions urgent tool param example");
|
|
1214
|
+
assert(readme.includes("/team gc"), "README mentions /team gc command");
|
|
1215
|
+
assert(readme.includes("/team cleanup"), "README mentions /team cleanup command");
|
|
1216
|
+
assert(readme.includes("docs/hook-contract.md"), "README references hook contract doc");
|
|
1217
|
+
assert(readme.includes("member_status"), "README mentions teams tool member_status action");
|
|
1218
|
+
assert(readme.includes("/team status"), "README mentions /team status command");
|
|
1219
|
+
assert(readme.includes("PI_TEAMS_STALL_THRESHOLD_MS"), "README mentions stall threshold env var");
|
|
1220
|
+
assert(readme.includes("Stall detection"), "README mentions stall detection feature");
|
|
1221
|
+
assert(readme.includes("Time in state"), "README mentions time-in-state feature");
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const skillPath = path.join(process.cwd(), "skills/agent-teams/SKILL.md");
|
|
1225
|
+
if (!fs.existsSync(skillPath)) {
|
|
1226
|
+
console.log(" (skipped) SKILL.md not found");
|
|
1227
|
+
} else {
|
|
1228
|
+
const skill = fs.readFileSync(skillPath, "utf8");
|
|
1229
|
+
assert(skill.includes("team_done"), "SKILL.md mentions team_done action");
|
|
1230
|
+
assert(skill.includes("/team done"), "SKILL.md mentions /team done command");
|
|
1231
|
+
assert(skill.includes("urgent"), "SKILL.md mentions urgent flag");
|
|
1232
|
+
assert(skill.includes("model_policy_get"), "SKILL.md mentions model_policy_get action");
|
|
1233
|
+
assert(skill.includes("hooks_policy_get"), "SKILL.md mentions hooks_policy_get action");
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// ── 12. transcript tracker (tool content summarization) ─────────────
|
|
1238
|
+
{
|
|
1239
|
+
console.log(`\n12. transcript tracker (tool content summarization)`);
|
|
1240
|
+
|
|
1241
|
+
const tracker = new TranscriptTracker();
|
|
1242
|
+
|
|
1243
|
+
// Helper to get last entry with proper narrowing
|
|
1244
|
+
function lastEntry(name: string): TranscriptEntry {
|
|
1245
|
+
const entries = tracker.get(name).getEntries();
|
|
1246
|
+
const last = entries[entries.length - 1];
|
|
1247
|
+
if (!last) throw new Error("no transcript entries");
|
|
1248
|
+
return last;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Simulate tool_execution_start with read tool
|
|
1252
|
+
tracker.handleEvent("alice", {
|
|
1253
|
+
type: "tool_execution_start",
|
|
1254
|
+
toolCallId: "tc1",
|
|
1255
|
+
toolName: "Read",
|
|
1256
|
+
args: { path: "/src/index.ts" },
|
|
1257
|
+
});
|
|
1258
|
+
{
|
|
1259
|
+
const e = lastEntry("alice");
|
|
1260
|
+
assert(e.kind === "tool_start", "read tool_start recorded");
|
|
1261
|
+
if (e.kind === "tool_start") {
|
|
1262
|
+
assert(e.summary === "/src/index.ts", "read summary is file path");
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Simulate tool_execution_end with content array (ToolResultMessage shape)
|
|
1267
|
+
tracker.handleEvent("alice", {
|
|
1268
|
+
type: "tool_execution_end",
|
|
1269
|
+
toolCallId: "tc1",
|
|
1270
|
+
toolName: "Read",
|
|
1271
|
+
result: { content: [{ type: "text", text: "file contents here\nline two" }] },
|
|
1272
|
+
isError: false,
|
|
1273
|
+
});
|
|
1274
|
+
{
|
|
1275
|
+
const e = lastEntry("alice");
|
|
1276
|
+
assert(e.kind === "tool_end", "read tool_end recorded");
|
|
1277
|
+
if (e.kind === "tool_end") {
|
|
1278
|
+
assert(e.summary === "file contents here line two", "read result summarized from content array");
|
|
1279
|
+
assert(!e.isError, "read result not error");
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// Simulate bash tool with command
|
|
1284
|
+
tracker.handleEvent("alice", {
|
|
1285
|
+
type: "tool_execution_start",
|
|
1286
|
+
toolCallId: "tc2",
|
|
1287
|
+
toolName: "Bash",
|
|
1288
|
+
args: { command: "npm run check" },
|
|
1289
|
+
});
|
|
1290
|
+
{
|
|
1291
|
+
const e = lastEntry("alice");
|
|
1292
|
+
assert(e.kind === "tool_start", "bash tool_start recorded");
|
|
1293
|
+
if (e.kind === "tool_start") {
|
|
1294
|
+
assert(e.summary === "npm run check", "bash summary normalizes whitespace");
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
// Simulate bash error result
|
|
1299
|
+
tracker.handleEvent("alice", {
|
|
1300
|
+
type: "tool_execution_end",
|
|
1301
|
+
toolCallId: "tc2",
|
|
1302
|
+
toolName: "Bash",
|
|
1303
|
+
result: { content: [{ type: "text", text: "Command failed with exit code 1" }] },
|
|
1304
|
+
isError: true,
|
|
1305
|
+
});
|
|
1306
|
+
{
|
|
1307
|
+
const e = lastEntry("alice");
|
|
1308
|
+
assert(e.kind === "tool_end", "bash error tool_end recorded");
|
|
1309
|
+
if (e.kind === "tool_end") {
|
|
1310
|
+
assert(e.isError, "bash error flagged");
|
|
1311
|
+
assert(e.summary === "Command failed with exit code 1", "bash error summary from content");
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Simulate edit tool
|
|
1316
|
+
tracker.handleEvent("alice", {
|
|
1317
|
+
type: "tool_execution_start",
|
|
1318
|
+
toolCallId: "tc3",
|
|
1319
|
+
toolName: "Edit",
|
|
1320
|
+
args: { path: "/src/utils.ts", oldText: "foo", newText: "bar" },
|
|
1321
|
+
});
|
|
1322
|
+
{
|
|
1323
|
+
const e = lastEntry("alice");
|
|
1324
|
+
assert(e.kind === "tool_start", "edit tool_start recorded");
|
|
1325
|
+
if (e.kind === "tool_start") {
|
|
1326
|
+
assert(e.summary === "/src/utils.ts", "edit summary is file path");
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Simulate grep tool
|
|
1331
|
+
tracker.handleEvent("alice", {
|
|
1332
|
+
type: "tool_execution_start",
|
|
1333
|
+
toolCallId: "tc4",
|
|
1334
|
+
toolName: "Grep",
|
|
1335
|
+
args: { pattern: "TODO", path: "/src" },
|
|
1336
|
+
});
|
|
1337
|
+
{
|
|
1338
|
+
const e = lastEntry("alice");
|
|
1339
|
+
assert(e.kind === "tool_start", "grep tool_start recorded");
|
|
1340
|
+
if (e.kind === "tool_start") {
|
|
1341
|
+
assert(e.summary === "TODO in /src", "grep summary includes pattern and path");
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Simulate team_message tool — should show recipient + message, not just recipient
|
|
1346
|
+
tracker.handleEvent("alice", {
|
|
1347
|
+
type: "tool_execution_start",
|
|
1348
|
+
toolCallId: "tc4b",
|
|
1349
|
+
toolName: "team_message",
|
|
1350
|
+
args: { recipient: "bob", message: "please rebase onto main", urgent: false },
|
|
1351
|
+
});
|
|
1352
|
+
{
|
|
1353
|
+
const e = lastEntry("alice");
|
|
1354
|
+
assert(e.kind === "tool_start", "team_message tool_start recorded");
|
|
1355
|
+
if (e.kind === "tool_start") {
|
|
1356
|
+
assert(e.summary === "→ bob: please rebase onto main", "team_message summary includes recipient and message");
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Simulate unknown tool — fallback to first string arg
|
|
1361
|
+
tracker.handleEvent("alice", {
|
|
1362
|
+
type: "tool_execution_start",
|
|
1363
|
+
toolCallId: "tc5",
|
|
1364
|
+
toolName: "CustomTool",
|
|
1365
|
+
args: { target: "my-resource" },
|
|
1366
|
+
});
|
|
1367
|
+
{
|
|
1368
|
+
const e = lastEntry("alice");
|
|
1369
|
+
assert(e.kind === "tool_start", "unknown tool_start recorded");
|
|
1370
|
+
if (e.kind === "tool_start") {
|
|
1371
|
+
assert(e.summary === "my-resource", "unknown tool fallback to first string arg");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Simulate empty result
|
|
1376
|
+
tracker.handleEvent("alice", {
|
|
1377
|
+
type: "tool_execution_end",
|
|
1378
|
+
toolCallId: "tc5",
|
|
1379
|
+
toolName: "CustomTool",
|
|
1380
|
+
result: { content: [{ type: "text", text: "" }] },
|
|
1381
|
+
isError: false,
|
|
1382
|
+
});
|
|
1383
|
+
{
|
|
1384
|
+
const e = lastEntry("alice");
|
|
1385
|
+
assert(e.kind === "tool_end", "empty result tool_end recorded");
|
|
1386
|
+
if (e.kind === "tool_end") {
|
|
1387
|
+
assert(e.summary === "(empty)", "empty result shows (empty)");
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Simulate long summary truncation
|
|
1392
|
+
const longPath = "/very/long/" + "a".repeat(200) + ".ts";
|
|
1393
|
+
tracker.handleEvent("alice", {
|
|
1394
|
+
type: "tool_execution_start",
|
|
1395
|
+
toolCallId: "tc6",
|
|
1396
|
+
toolName: "Read",
|
|
1397
|
+
args: { path: longPath },
|
|
1398
|
+
});
|
|
1399
|
+
{
|
|
1400
|
+
const e = lastEntry("alice");
|
|
1401
|
+
assert(e.kind === "tool_start", "long path tool_start recorded");
|
|
1402
|
+
if (e.kind === "tool_start") {
|
|
1403
|
+
assert(e.summary !== null && e.summary.length <= 120, "long summary truncated to 120 chars");
|
|
1404
|
+
assert(e.summary !== null && e.summary.endsWith("…"), "truncated summary ends with ellipsis");
|
|
1405
|
+
}
|
|
708
1406
|
}
|
|
709
1407
|
}
|
|
710
1408
|
|