pi-extended-teams 2.1.16
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/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createAgentCommunicationTools } from "./agent-communication-tools.js";
|
|
6
|
+
import * as paths from "../../src/utils/paths.js";
|
|
7
|
+
import { readInbox, sendPlainMessage } from "../../src/utils/messaging.js";
|
|
8
|
+
import { readRuntimeStatus } from "../../src/utils/runtime.js";
|
|
9
|
+
|
|
10
|
+
let root: string;
|
|
11
|
+
|
|
12
|
+
type Tool = {
|
|
13
|
+
name: string;
|
|
14
|
+
execute: (toolCallId: string, params: any) => Promise<any>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function installPathSpies() {
|
|
18
|
+
vi.spyOn(paths, "teamDir").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName))));
|
|
19
|
+
vi.spyOn(paths, "configPath").mockImplementation((teamName: unknown) => path.join(root, "teams", paths.sanitizeName(String(teamName)), "config.json"));
|
|
20
|
+
vi.spyOn(paths, "inboxPath").mockImplementation((teamName: unknown, agentName: unknown) => {
|
|
21
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "inboxes", `${paths.sanitizeName(String(agentName))}.json`);
|
|
22
|
+
});
|
|
23
|
+
vi.spyOn(paths, "runtimeStatusPath").mockImplementation((teamName: unknown, agentName: unknown) => {
|
|
24
|
+
return path.join(root, "teams", paths.sanitizeName(String(teamName)), "runtime", `${paths.sanitizeName(String(agentName))}.json`);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeTools(teamName: string | null = "session", agentName = "reader", role: "read" | "write" = "read", lifecycleRunId: string | null = "reader-run") {
|
|
29
|
+
return new Map<string, Tool>(createAgentCommunicationTools({
|
|
30
|
+
isTeammate: true,
|
|
31
|
+
agentName,
|
|
32
|
+
role,
|
|
33
|
+
getTeamName: () => teamName ?? undefined,
|
|
34
|
+
getLifecycleRunId: () => lifecycleRunId ?? undefined,
|
|
35
|
+
authorizeWriteMember: vi.fn(async () => {}),
|
|
36
|
+
onReportAndExit: vi.fn(async () => ({ accepted: true })),
|
|
37
|
+
} as any).map((tool: Tool) => [tool.name, tool]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("read-agent communication tools", () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-extended-teams-agent-tools-"));
|
|
43
|
+
installPathSpies();
|
|
44
|
+
const configFile = paths.configPath("session");
|
|
45
|
+
fs.mkdirSync(path.dirname(configFile), { recursive: true });
|
|
46
|
+
fs.writeFileSync(configFile, JSON.stringify({
|
|
47
|
+
name: "session",
|
|
48
|
+
members: [
|
|
49
|
+
{ name: "team-lead" },
|
|
50
|
+
{ name: "reader", lifecycleRunId: "reader-run", isActive: true },
|
|
51
|
+
{ name: "writer", lifecycleRunId: "reader-run", isActive: true },
|
|
52
|
+
],
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.restoreAllMocks();
|
|
58
|
+
if (root && fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("still delivers inbox messages when no lifecycle run id is available", async () => {
|
|
62
|
+
await sendPlainMessage("session", "team-lead", "reader", "Initial instructions", "assignment");
|
|
63
|
+
const tools = makeTools("session", "reader", "read", null);
|
|
64
|
+
|
|
65
|
+
const result = await tools.get("read_inbox")!.execute("read", { unread_only: true });
|
|
66
|
+
|
|
67
|
+
expect(result.content[0].text).toContain("Initial instructions");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("exposes direct communication and complete final reporting to read agents", () => {
|
|
71
|
+
const tools = Array.from(makeTools("session", "reader", "read").keys()).sort();
|
|
72
|
+
|
|
73
|
+
expect(tools).toEqual(["read_inbox", "report_and_exit", "report_progress", "send_message"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("lets a read agent submit its complete result instead of replacing it with a summary", async () => {
|
|
77
|
+
const onReportAndExit = vi.fn(async () => ({
|
|
78
|
+
accepted: true,
|
|
79
|
+
cancelledDeliveries: 2,
|
|
80
|
+
deliveryOutcome: "cancelled" as const,
|
|
81
|
+
}));
|
|
82
|
+
const tools = new Map<string, Tool>(createAgentCommunicationTools({
|
|
83
|
+
isTeammate: true, agentName: "reader", role: "read", getTeamName: () => "session",
|
|
84
|
+
getLifecycleRunId: () => "reader-run",
|
|
85
|
+
authorizeWriteMember: vi.fn(async () => {}), onReportAndExit,
|
|
86
|
+
}).map((tool: Tool) => [tool.name, tool]));
|
|
87
|
+
const content = JSON.stringify({ kind: "plan", document: { title: { kind: "title", body: "Plan", children: [] }, elements: [] } });
|
|
88
|
+
|
|
89
|
+
await expect(tools.get("report_and_exit")!.execute("report", { content, summary: "Complete plan result ready" })).resolves.toMatchObject({
|
|
90
|
+
details: { accepted: true, cancelledDeliveries: 2, deliveryOutcome: "cancelled" },
|
|
91
|
+
});
|
|
92
|
+
expect(onReportAndExit).toHaveBeenCalledWith({ content, summary: "Complete plan result ready" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("rejects blank final reports before closing report admission", async () => {
|
|
96
|
+
const onReportAndExit = vi.fn(async () => ({ accepted: true }));
|
|
97
|
+
const tools = new Map<string, Tool>(createAgentCommunicationTools({
|
|
98
|
+
isTeammate: true, agentName: "reader", role: "read", getTeamName: () => "session",
|
|
99
|
+
getLifecycleRunId: () => "reader-run",
|
|
100
|
+
authorizeWriteMember: vi.fn(async () => {}), onReportAndExit,
|
|
101
|
+
}).map((tool: Tool) => [tool.name, tool]));
|
|
102
|
+
|
|
103
|
+
await expect(tools.get("report_and_exit")!.execute("blank-report", {
|
|
104
|
+
content: " \n\t ",
|
|
105
|
+
summary: "Not usable",
|
|
106
|
+
})).rejects.toThrow("Final report content must not be empty");
|
|
107
|
+
expect(onReportAndExit).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("exposes the complete coordination surface to write agents", () => {
|
|
111
|
+
const tools = Array.from(makeTools("session", "writer", "write").keys()).sort();
|
|
112
|
+
|
|
113
|
+
expect(tools).toEqual([
|
|
114
|
+
"claim_file",
|
|
115
|
+
"list_file_claims",
|
|
116
|
+
"read_inbox",
|
|
117
|
+
"release_file",
|
|
118
|
+
"report_and_exit",
|
|
119
|
+
"report_progress",
|
|
120
|
+
"send_message",
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("send_message uses the current session and defaults spawned agents to the lead", async () => {
|
|
125
|
+
const tools = makeTools("session", "reader");
|
|
126
|
+
|
|
127
|
+
const result = await tools.get("send_message")!.execute("send", {
|
|
128
|
+
content: "I found something useful.",
|
|
129
|
+
summary: "finding",
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(result.details).toEqual({ session: "session", recipient: "team-lead" });
|
|
133
|
+
const inbox = await readInbox("session", "team-lead", false, false);
|
|
134
|
+
expect(inbox).toHaveLength(1);
|
|
135
|
+
expect(inbox[0]).toMatchObject({
|
|
136
|
+
from: "reader",
|
|
137
|
+
text: "I found something useful.",
|
|
138
|
+
summary: "finding",
|
|
139
|
+
read: false,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("send_message fails when the recipient subagent is not running", async () => {
|
|
144
|
+
const configPath = paths.configPath("session");
|
|
145
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
146
|
+
fs.writeFileSync(configPath, JSON.stringify({ name: "session", members: [{ name: "team-lead" }, { name: "reader" }] }));
|
|
147
|
+
const tools = makeTools("session", "reader");
|
|
148
|
+
|
|
149
|
+
await expect(tools.get("send_message")!.execute("send", {
|
|
150
|
+
recipient: "finished-agent",
|
|
151
|
+
content: "Please continue.",
|
|
152
|
+
})).rejects.toThrow("Cannot send message to finished-agent: agent is not running.");
|
|
153
|
+
|
|
154
|
+
expect(await readInbox("session", "finished-agent", false, false)).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("report_progress normalizes and persists progress without inbox side effects", async () => {
|
|
158
|
+
const onProgress = vi.fn();
|
|
159
|
+
const tools = new Map<string, Tool>(createAgentCommunicationTools({
|
|
160
|
+
isTeammate: true,
|
|
161
|
+
agentName: "reader",
|
|
162
|
+
role: "read",
|
|
163
|
+
getTeamName: () => "session",
|
|
164
|
+
getLifecycleRunId: () => "reader-run",
|
|
165
|
+
authorizeWriteMember: vi.fn(async () => {}),
|
|
166
|
+
onProgress,
|
|
167
|
+
onReportAndExit: vi.fn(async () => ({ accepted: true })),
|
|
168
|
+
}).map((tool: Tool) => [tool.name, tool]));
|
|
169
|
+
|
|
170
|
+
const result = await tools.get("report_progress")!.execute("progress", {
|
|
171
|
+
status: " Reviewing\n runtime state ",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.content[0].text).toBe("Progress updated: Reviewing runtime state");
|
|
175
|
+
expect(result.details).toMatchObject({ session: "session", status: "Reviewing runtime state", updated: true });
|
|
176
|
+
expect(onProgress).toHaveBeenCalledWith("Reviewing runtime state", expect.any(Number));
|
|
177
|
+
expect(await readRuntimeStatus("session", "reader")).toMatchObject({
|
|
178
|
+
latestProgress: "Reviewing runtime state",
|
|
179
|
+
progressUpdatedAt: expect.any(Number),
|
|
180
|
+
});
|
|
181
|
+
expect(await readInbox("session", "team-lead", false, false)).toEqual([]);
|
|
182
|
+
expect(await readInbox("session", "reader", false, false)).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("strips terminal control sequences from persisted progress", async () => {
|
|
186
|
+
const onProgress = vi.fn();
|
|
187
|
+
const tools = new Map<string, Tool>(createAgentCommunicationTools({
|
|
188
|
+
isTeammate: true,
|
|
189
|
+
agentName: "reader",
|
|
190
|
+
role: "read",
|
|
191
|
+
getTeamName: () => "session",
|
|
192
|
+
getLifecycleRunId: () => "reader-run",
|
|
193
|
+
authorizeWriteMember: vi.fn(async () => {}),
|
|
194
|
+
onProgress,
|
|
195
|
+
onReportAndExit: vi.fn(async () => ({ accepted: true })),
|
|
196
|
+
}).map((tool: Tool) => [tool.name, tool]));
|
|
197
|
+
|
|
198
|
+
await tools.get("report_progress")!.execute("progress", {
|
|
199
|
+
status: "Inspecting\u001b[2J files\u001b]52;c;payload\u0007 now\u001b[31m",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(onProgress).toHaveBeenCalledWith("Inspecting files now", expect.any(Number));
|
|
203
|
+
expect(await readRuntimeStatus("session", "reader")).toMatchObject({ latestProgress: "Inspecting files now" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("report_progress rejects empty and overlong normalized statuses", async () => {
|
|
207
|
+
const tool = makeTools("session", "reader").get("report_progress")!;
|
|
208
|
+
|
|
209
|
+
await expect(tool.execute("empty", { status: " \n\t " })).rejects.toThrow("status must not be empty");
|
|
210
|
+
await expect(tool.execute("long", { status: "x".repeat(121) })).rejects.toThrow("status must be at most 120 characters");
|
|
211
|
+
expect(await readRuntimeStatus("session", "reader")).toBeNull();
|
|
212
|
+
|
|
213
|
+
await expect(tool.execute("boundary", { status: "x".repeat(120) })).resolves.toMatchObject({
|
|
214
|
+
details: { status: "x".repeat(120) },
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("report_progress skips lifecycle-closed persistence without failing the agent turn", async () => {
|
|
219
|
+
const configFile = paths.configPath("session");
|
|
220
|
+
const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
221
|
+
config.members = config.members.map((member: any) => member.name === "reader" ? { ...member, isActive: false } : member);
|
|
222
|
+
fs.writeFileSync(configFile, JSON.stringify(config));
|
|
223
|
+
const onProgress = vi.fn();
|
|
224
|
+
const tools = new Map<string, Tool>(createAgentCommunicationTools({
|
|
225
|
+
isTeammate: true,
|
|
226
|
+
agentName: "reader",
|
|
227
|
+
role: "read",
|
|
228
|
+
getTeamName: () => "session",
|
|
229
|
+
getLifecycleRunId: () => "reader-run",
|
|
230
|
+
authorizeWriteMember: vi.fn(async () => {}),
|
|
231
|
+
onProgress,
|
|
232
|
+
onReportAndExit: vi.fn(async () => ({ accepted: true })),
|
|
233
|
+
}).map((tool: Tool) => [tool.name, tool]));
|
|
234
|
+
|
|
235
|
+
const result = await tools.get("report_progress")!.execute("closing-progress", {
|
|
236
|
+
status: "Preparing final report",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.content[0].text).toBe("Progress update skipped: Preparing final report");
|
|
240
|
+
expect(result.details).toMatchObject({
|
|
241
|
+
session: "session",
|
|
242
|
+
status: "Preparing final report",
|
|
243
|
+
updated: false,
|
|
244
|
+
reason: "lifecycle-closed",
|
|
245
|
+
});
|
|
246
|
+
expect(onProgress).toHaveBeenCalledWith("Preparing final report", expect.any(Number));
|
|
247
|
+
expect(await readRuntimeStatus("session", "reader")).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("read_inbox reads the current agent inbox and records readiness", async () => {
|
|
251
|
+
await sendPlainMessage("session", "team-lead", "reader", "Initial instructions", "assignment");
|
|
252
|
+
const tools = makeTools("session", "reader");
|
|
253
|
+
|
|
254
|
+
const result = await tools.get("read_inbox")!.execute("read", { unread_only: true });
|
|
255
|
+
|
|
256
|
+
expect(result.content[0].text).toContain("Initial instructions");
|
|
257
|
+
expect(result.details).toMatchObject({
|
|
258
|
+
session: "session",
|
|
259
|
+
targetAgent: "reader",
|
|
260
|
+
messages: [expect.objectContaining({ from: "team-lead", text: "Initial instructions" })],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const inbox = await readInbox("session", "reader", false, false);
|
|
264
|
+
expect(inbox[0].read).toBe(true);
|
|
265
|
+
|
|
266
|
+
const status = await readRuntimeStatus("session", "reader");
|
|
267
|
+
expect(status).toMatchObject({ teamName: "session", agentName: "reader", ready: true });
|
|
268
|
+
expect(typeof status?.lastHeartbeatAt).toBe("number");
|
|
269
|
+
expect(typeof status?.lastInboxReadAt).toBe("number");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("read_inbox defaults to returning only unread messages", async () => {
|
|
273
|
+
await sendPlainMessage("session", "team-lead", "reader", "Already handled", "old");
|
|
274
|
+
await readInbox("session", "reader", true, true);
|
|
275
|
+
await sendPlainMessage("session", "team-lead", "reader", "New instruction", "new");
|
|
276
|
+
const tools = makeTools("session", "reader");
|
|
277
|
+
|
|
278
|
+
const result = await tools.get("read_inbox")!.execute("read", {});
|
|
279
|
+
|
|
280
|
+
expect(result.details.messages).toHaveLength(1);
|
|
281
|
+
expect(result.details.messages[0]).toMatchObject({ text: "New instruction", read: true });
|
|
282
|
+
expect(result.content[0].text).not.toContain("Already handled");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("read_inbox can peek without marking read or updating readiness", async () => {
|
|
286
|
+
await sendPlainMessage("session", "team-lead", "reader", "Initial instructions", "assignment");
|
|
287
|
+
const tools = makeTools("session", "reader");
|
|
288
|
+
|
|
289
|
+
const result = await tools.get("read_inbox")!.execute("read", { unread_only: true, mark_as_read: false });
|
|
290
|
+
|
|
291
|
+
expect(result.content[0].text).toContain("Initial instructions");
|
|
292
|
+
expect(result.details.markAsRead).toBe(false);
|
|
293
|
+
const inbox = await readInbox("session", "reader", false, false);
|
|
294
|
+
expect(inbox[0].read).toBe(false);
|
|
295
|
+
expect(await readRuntimeStatus("session", "reader")).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("requires current session context for communication tools", async () => {
|
|
299
|
+
const tools = makeTools(null, "reader");
|
|
300
|
+
|
|
301
|
+
await expect(tools.get("send_message")!.execute("send", {
|
|
302
|
+
content: "hello",
|
|
303
|
+
summary: "hello",
|
|
304
|
+
})).rejects.toThrow("No active agent session context is available.");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import * as messaging from "../../src/utils/messaging";
|
|
3
|
+
import * as runtime from "../../src/utils/runtime";
|
|
4
|
+
import * as claims from "../../src/utils/claims";
|
|
5
|
+
import { formatInboxMessagesForModel, sanitizePlainTuiLine } from "../ui/renderers";
|
|
6
|
+
import { createFileClaimTools } from "./file-claim-tools";
|
|
7
|
+
|
|
8
|
+
export interface SubmittedAgentReport {
|
|
9
|
+
content: string;
|
|
10
|
+
summary?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentReportSubmissionResult {
|
|
14
|
+
accepted: boolean;
|
|
15
|
+
cancelledDeliveries?: number;
|
|
16
|
+
deliveryOutcome?: "cancelled" | "none";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentCommunicationToolsOptions {
|
|
20
|
+
isTeammate: boolean;
|
|
21
|
+
agentName: string;
|
|
22
|
+
role: "read" | "write";
|
|
23
|
+
getTeamName(): string | null | undefined;
|
|
24
|
+
getLifecycleRunId(): string | undefined;
|
|
25
|
+
authorizeWriteMember(teamName: string, agentName: string): Promise<void>;
|
|
26
|
+
onProgress?(status: string, updatedAt: number): void;
|
|
27
|
+
onReportAndExit(report: SubmittedAgentReport): Promise<AgentReportSubmissionResult>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireCurrentSession(options: Pick<AgentCommunicationToolsOptions, "getTeamName">): string {
|
|
31
|
+
const teamName = options.getTeamName();
|
|
32
|
+
if (!teamName) throw new Error("No active agent session context is available.");
|
|
33
|
+
return teamName;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireLifecycleRunId(options: Pick<AgentCommunicationToolsOptions, "getLifecycleRunId">): string {
|
|
37
|
+
const runId = options.getLifecycleRunId();
|
|
38
|
+
if (!runId) throw new Error("No lifecycle run identity is available for runtime telemetry.");
|
|
39
|
+
return runId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeProgressStatus(value: unknown): string {
|
|
43
|
+
if (typeof value !== "string") throw new Error("status must be a string.");
|
|
44
|
+
const status = sanitizePlainTuiLine(value).replace(/\s+/g, " ").trim();
|
|
45
|
+
if (!status) throw new Error("status must not be empty.");
|
|
46
|
+
if (status.length > 120) throw new Error("status must be at most 120 characters.");
|
|
47
|
+
return status;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeFinalReportContent(value: unknown): string {
|
|
51
|
+
if (typeof value !== "string") throw new Error("Final report content must be a string.");
|
|
52
|
+
const content = value.trim();
|
|
53
|
+
if (!content) throw new Error("Final report content must not be empty.");
|
|
54
|
+
return content;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createReportProgressTool(options: Pick<AgentCommunicationToolsOptions, "isTeammate" | "agentName" | "getTeamName" | "getLifecycleRunId" | "onProgress">): any {
|
|
58
|
+
return {
|
|
59
|
+
name: "report_progress",
|
|
60
|
+
label: "Report Progress",
|
|
61
|
+
description: "Update this agent's latest concise progress phrase without messaging or waking the lead.",
|
|
62
|
+
parameters: Type.Object({
|
|
63
|
+
status: Type.String({ minLength: 1, maxLength: 120, description: "Free-form progress phrase; normalized to one non-empty line (maximum 120 characters)." }),
|
|
64
|
+
}),
|
|
65
|
+
async execute(_toolCallId: string, params: any) {
|
|
66
|
+
if (!options.isTeammate) throw new Error("report_progress is only available to spawned agents.");
|
|
67
|
+
const teamName = requireCurrentSession(options);
|
|
68
|
+
const status = normalizeProgressStatus(params.status);
|
|
69
|
+
const updatedAt = Date.now();
|
|
70
|
+
options.onProgress?.(status, updatedAt);
|
|
71
|
+
try {
|
|
72
|
+
await runtime.writeRuntimeStatus(teamName, options.agentName, requireLifecycleRunId(options), {
|
|
73
|
+
latestProgress: status,
|
|
74
|
+
progressUpdatedAt: updatedAt,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!runtime.isRuntimeStatusWriteRejectedError(error)) throw error;
|
|
78
|
+
return {
|
|
79
|
+
content: [{ type: "text", text: `Progress update skipped: ${status}` }],
|
|
80
|
+
details: { session: teamName, status, updatedAt, updated: false, reason: "lifecycle-closed" },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: `Progress updated: ${status}` }],
|
|
85
|
+
details: { session: teamName, status, updatedAt, updated: true },
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createAgentCommunicationTools(options: AgentCommunicationToolsOptions): any[] {
|
|
92
|
+
const communicationTools = [
|
|
93
|
+
{
|
|
94
|
+
name: "send_message",
|
|
95
|
+
label: "Send Message",
|
|
96
|
+
description: "Send a direct message in the current Pi session. Spawned agents default to messaging the lead.",
|
|
97
|
+
parameters: Type.Object({
|
|
98
|
+
recipient: Type.Optional(Type.String({ description: "Recipient agent name. Defaults to team-lead for spawned agents." })),
|
|
99
|
+
content: Type.String(),
|
|
100
|
+
summary: Type.Optional(Type.String()),
|
|
101
|
+
}),
|
|
102
|
+
async execute(_toolCallId: string, params: any) {
|
|
103
|
+
const teamName = requireCurrentSession(options);
|
|
104
|
+
const recipient = params.recipient || (options.isTeammate ? "team-lead" : undefined);
|
|
105
|
+
if (!recipient) throw new Error("recipient is required when the lead sends a message.");
|
|
106
|
+
await messaging.sendPlainMessageIfRunning(teamName, options.agentName, recipient, params.content, params.summary || "Message");
|
|
107
|
+
return { content: [{ type: "text", text: `Message sent to ${recipient}.` }], details: { session: teamName, recipient } };
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
createReportProgressTool(options),
|
|
111
|
+
{
|
|
112
|
+
name: "read_inbox",
|
|
113
|
+
label: "Read Inbox",
|
|
114
|
+
description: "Read this agent's inbox in the current Pi session.",
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
unread_only: Type.Optional(Type.Boolean({ default: true })),
|
|
117
|
+
mark_as_read: Type.Optional(Type.Boolean({ default: true, description: "Set false to peek without marking messages read." })),
|
|
118
|
+
}),
|
|
119
|
+
async execute(_toolCallId: string, params: any) {
|
|
120
|
+
const teamName = requireCurrentSession(options);
|
|
121
|
+
const markAsRead = params.mark_as_read !== false;
|
|
122
|
+
const unreadOnly = params.unread_only !== false;
|
|
123
|
+
const msgs = await messaging.readInbox(teamName, options.agentName, unreadOnly, markAsRead);
|
|
124
|
+
const lifecycleRunId = options.getLifecycleRunId();
|
|
125
|
+
// Do not fail after read flags are persisted; telemetry is best-effort.
|
|
126
|
+
if (markAsRead && lifecycleRunId) {
|
|
127
|
+
await runtime.writeRuntimeStatus(teamName, options.agentName, lifecycleRunId, {
|
|
128
|
+
lastHeartbeatAt: Date.now(),
|
|
129
|
+
lastInboxReadAt: Date.now(),
|
|
130
|
+
ready: true,
|
|
131
|
+
lastError: undefined,
|
|
132
|
+
}).catch(() => {});
|
|
133
|
+
}
|
|
134
|
+
return { content: [{ type: "text", text: formatInboxMessagesForModel(msgs) }], details: { session: teamName, targetAgent: options.agentName, messages: msgs, markAsRead } };
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
const reportAndExitTool = {
|
|
140
|
+
name: "report_and_exit",
|
|
141
|
+
label: "Report and Exit",
|
|
142
|
+
description: "Submit the complete final report to the lead and finish this nested agent run.",
|
|
143
|
+
parameters: Type.Object({
|
|
144
|
+
content: Type.String({ minLength: 1, description: "Complete non-empty final report to send to the lead; do not replace required output with a summary." }),
|
|
145
|
+
summary: Type.Optional(Type.String({ description: "Short report summary." })),
|
|
146
|
+
}),
|
|
147
|
+
async execute(_toolCallId: string, params: SubmittedAgentReport) {
|
|
148
|
+
const teamName = requireCurrentSession(options);
|
|
149
|
+
const content = normalizeFinalReportContent(params.content);
|
|
150
|
+
const summary = typeof params.summary === "string" && params.summary.trim() ? params.summary.trim() : undefined;
|
|
151
|
+
const result = await options.onReportAndExit({ content, summary });
|
|
152
|
+
const text = result.accepted
|
|
153
|
+
? "Final report accepted. Finish immediately; the outer runner will release claims and stop this nested session."
|
|
154
|
+
: "A final report was already accepted for this run. This duplicate was ignored; finish immediately.";
|
|
155
|
+
return {
|
|
156
|
+
content: [{ type: "text", text }],
|
|
157
|
+
details: {
|
|
158
|
+
session: teamName,
|
|
159
|
+
accepted: result.accepted,
|
|
160
|
+
...(result.cancelledDeliveries === undefined ? {} : {
|
|
161
|
+
cancelledDeliveries: result.cancelledDeliveries,
|
|
162
|
+
deliveryOutcome: result.deliveryOutcome,
|
|
163
|
+
}),
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (options.role !== "write") return [...communicationTools, reportAndExitTool];
|
|
170
|
+
|
|
171
|
+
const fileClaimTools = createFileClaimTools({
|
|
172
|
+
agentName: options.agentName,
|
|
173
|
+
getAuthorizedWriteTeam: async () => {
|
|
174
|
+
const teamName = requireCurrentSession(options);
|
|
175
|
+
await options.authorizeWriteMember(teamName, options.agentName);
|
|
176
|
+
return teamName;
|
|
177
|
+
},
|
|
178
|
+
getCurrentTeam: () => requireCurrentSession(options),
|
|
179
|
+
claims,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
return [...communicationTools, ...fileClaimTools, reportAndExitTool];
|
|
183
|
+
}
|