@desplega.ai/agent-swarm 1.84.0 → 1.85.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/README.md +48 -8
- package/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +87 -7
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/users.ts +11 -3
- package/src/http/utils.ts +17 -0
- package/src/integrations/kapso/inbound.ts +36 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +45 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-inbound.test.ts +60 -1
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- package/src/tools/cancel-task.ts +13 -5
- package/src/tools/get-task-details.ts +18 -10
- package/src/tools/get-tasks.ts +9 -4
- package/src/tools/oauth-access-token.ts +118 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/task-action.ts +20 -10
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -0
- package/templates/skills/agentmail-sending/SKILL.md +148 -28
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createPiRuntimeAuth,
|
|
6
|
+
extractPiAssistantText,
|
|
7
|
+
PiMonoAdapter,
|
|
8
|
+
resolveModel,
|
|
9
|
+
} from "../providers/pi-mono-adapter";
|
|
5
10
|
|
|
6
11
|
describe("PiMonoAdapter", () => {
|
|
7
12
|
test("name is 'pi'", () => {
|
|
@@ -198,6 +203,37 @@ describe("createPiRuntimeAuth", () => {
|
|
|
198
203
|
});
|
|
199
204
|
|
|
200
205
|
describe("Pi-mono event normalization", () => {
|
|
206
|
+
test("extractPiAssistantText ignores user messages", () => {
|
|
207
|
+
const text = extractPiAssistantText({
|
|
208
|
+
role: "user",
|
|
209
|
+
content: "/skill:work-on-task task-123\n\nTask: hello",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(text).toBe("");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("extractPiAssistantText extracts assistant text blocks", () => {
|
|
216
|
+
const text = extractPiAssistantText({
|
|
217
|
+
role: "assistant",
|
|
218
|
+
content: [
|
|
219
|
+
{ type: "text", text: "Hello, " },
|
|
220
|
+
{ type: "thinking", thinking: "hidden" },
|
|
221
|
+
{ type: "text", text: "world!" },
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(text).toBe("Hello, world!");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("extractPiAssistantText supports string assistant content", () => {
|
|
229
|
+
const text = extractPiAssistantText({
|
|
230
|
+
role: "assistant",
|
|
231
|
+
content: "Plain assistant output",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(text).toBe("Plain assistant output");
|
|
235
|
+
});
|
|
236
|
+
|
|
201
237
|
test("message_update with text content produces raw_log-style data", () => {
|
|
202
238
|
// Simulates what PiMonoSession.handleAgentEvent does
|
|
203
239
|
const event = {
|
|
@@ -54,11 +54,12 @@ describe("Session templates — registration", () => {
|
|
|
54
54
|
await ensureTemplatesRegistered();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("all
|
|
57
|
+
test("all 14 system templates are registered", () => {
|
|
58
58
|
const systemTemplates = [
|
|
59
59
|
"system.agent.role",
|
|
60
60
|
"system.agent.register",
|
|
61
61
|
"system.agent.lead",
|
|
62
|
+
"system.agent.slack",
|
|
62
63
|
"system.agent.worker",
|
|
63
64
|
"system.agent.worker.slack",
|
|
64
65
|
"system.agent.filesystem",
|
|
@@ -89,10 +90,10 @@ describe("Session templates — registration", () => {
|
|
|
89
90
|
}
|
|
90
91
|
});
|
|
91
92
|
|
|
92
|
-
test("total of
|
|
93
|
+
test("total of 19 session/system templates registered", () => {
|
|
93
94
|
const all = getAllTemplateDefinitions();
|
|
94
95
|
const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
|
|
95
|
-
expect(sessionSystem.length).toBe(
|
|
96
|
+
expect(sessionSystem.length).toBe(19);
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
|
@@ -150,6 +151,15 @@ describe("Session templates — individual resolution", () => {
|
|
|
150
151
|
const result = resolveTemplate("system.agent.lead", {});
|
|
151
152
|
expect(result.text).toContain("CRITICAL: You are a coordinator");
|
|
152
153
|
expect(result.text).toContain("coordinator");
|
|
154
|
+
expect(result.text).not.toContain("slack-reply");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("system.agent.slack contains Slack tool guidance", () => {
|
|
158
|
+
const result = resolveTemplate("system.agent.slack", {});
|
|
159
|
+
expect(result.text).toContain("Slack Tools");
|
|
160
|
+
expect(result.text).toContain("slack-reply");
|
|
161
|
+
expect(result.text).toContain("slack-read");
|
|
162
|
+
expect(result.text).toContain("slack-list-channels");
|
|
153
163
|
});
|
|
154
164
|
|
|
155
165
|
test("system.agent.worker contains worker tools", () => {
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
buildContextPreamble,
|
|
5
|
+
CONTEXT_PREAMBLE_MAX_CHARS,
|
|
6
|
+
CONTEXT_PREAMBLE_MAX_TOKENS,
|
|
7
|
+
fetchTaskContextForPreamble,
|
|
8
|
+
type TaskContextForPreamble,
|
|
9
|
+
} from "../commands/context-preamble";
|
|
10
|
+
|
|
11
|
+
const TEST_PORT = 19091;
|
|
12
|
+
const API_URL = `http://localhost:${TEST_PORT}`;
|
|
13
|
+
const API_KEY = "test-key";
|
|
14
|
+
|
|
15
|
+
// In-memory task store for the mock server
|
|
16
|
+
const mockTasks: Record<string, TaskContextForPreamble> = {};
|
|
17
|
+
|
|
18
|
+
let server: Server;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
server = createServer((req, res) => {
|
|
22
|
+
const url = req.url ?? "";
|
|
23
|
+
const match = url.match(/^\/api\/tasks\/([^/?]+)/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const id = match[1];
|
|
26
|
+
const task = mockTasks[id];
|
|
27
|
+
if (task) {
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
29
|
+
res.end(JSON.stringify(task));
|
|
30
|
+
} else {
|
|
31
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
32
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.writeHead(404);
|
|
37
|
+
res.end();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
await new Promise<void>((resolve) => server.listen(TEST_PORT, resolve));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
server.close();
|
|
45
|
+
// Clear mocks
|
|
46
|
+
for (const k of Object.keys(mockTasks)) delete mockTasks[k];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
function seedTask(task: TaskContextForPreamble): void {
|
|
50
|
+
mockTasks[task.id] = task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("fetchTaskContextForPreamble", () => {
|
|
54
|
+
test("returns null on 404", async () => {
|
|
55
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "missing-id");
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("fetches task context fields", async () => {
|
|
60
|
+
seedTask({
|
|
61
|
+
id: "task-a",
|
|
62
|
+
task: "Build the widget",
|
|
63
|
+
output: "Widget built successfully",
|
|
64
|
+
status: "completed",
|
|
65
|
+
attachments: [{ kind: "url", name: "Report", url: "https://example.com/report" }],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await fetchTaskContextForPreamble(API_URL, API_KEY, "task-a");
|
|
69
|
+
expect(result).not.toBeNull();
|
|
70
|
+
expect(result?.id).toBe("task-a");
|
|
71
|
+
expect(result?.task).toBe("Build the widget");
|
|
72
|
+
expect(result?.output).toBe("Widget built successfully");
|
|
73
|
+
expect(result?.attachments).toHaveLength(1);
|
|
74
|
+
expect(result?.attachments?.[0].name).toBe("Report");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("buildContextPreamble", () => {
|
|
79
|
+
test("returns null when parent task not found", async () => {
|
|
80
|
+
const result = await buildContextPreamble(API_URL, API_KEY, "nonexistent-parent");
|
|
81
|
+
expect(result).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("includes parent task subject and output in preamble", async () => {
|
|
85
|
+
seedTask({
|
|
86
|
+
id: "parent-1",
|
|
87
|
+
task: "Fix the auth bug in login flow",
|
|
88
|
+
output: "Fixed by patching jwt validation in auth.ts:42",
|
|
89
|
+
status: "completed",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-1");
|
|
93
|
+
expect(preamble).not.toBeNull();
|
|
94
|
+
expect(preamble).toContain("parent-1");
|
|
95
|
+
expect(preamble).toContain("Fix the auth bug in login flow");
|
|
96
|
+
expect(preamble).toContain("Fixed by patching jwt validation");
|
|
97
|
+
expect(preamble).toContain("get-task-details");
|
|
98
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("includes attachment pointers in preamble", async () => {
|
|
102
|
+
seedTask({
|
|
103
|
+
id: "parent-2",
|
|
104
|
+
task: "Generate a report",
|
|
105
|
+
output: "Report generated",
|
|
106
|
+
status: "completed",
|
|
107
|
+
attachments: [
|
|
108
|
+
{ kind: "url", name: "Final Report", url: "https://example.com/report.pdf" },
|
|
109
|
+
{
|
|
110
|
+
kind: "agent-fs",
|
|
111
|
+
name: "Raw Data",
|
|
112
|
+
path: "thoughts/agent/research/data.md",
|
|
113
|
+
orgId: "org-123",
|
|
114
|
+
driveId: "drv-456",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-2");
|
|
120
|
+
expect(preamble).toContain("Final Report");
|
|
121
|
+
expect(preamble).toContain("https://example.com/report.pdf");
|
|
122
|
+
expect(preamble).toContain("Raw Data");
|
|
123
|
+
expect(preamble).toContain("live.agent-fs.dev");
|
|
124
|
+
expect(preamble).toContain("org-123");
|
|
125
|
+
expect(preamble).toContain("drv-456");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("shows 'no output recorded' when task has no output or progress", async () => {
|
|
129
|
+
seedTask({
|
|
130
|
+
id: "parent-no-output",
|
|
131
|
+
task: "A task with no output yet",
|
|
132
|
+
status: "in_progress",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-no-output");
|
|
136
|
+
expect(preamble).toContain("no output recorded");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("walks ancestor chain and includes older ancestors as pointers", async () => {
|
|
140
|
+
seedTask({
|
|
141
|
+
id: "grandparent-1",
|
|
142
|
+
task: "Initial research task",
|
|
143
|
+
output: "Research complete",
|
|
144
|
+
status: "completed",
|
|
145
|
+
});
|
|
146
|
+
seedTask({
|
|
147
|
+
id: "child-of-grandparent",
|
|
148
|
+
task: "Second task referencing research",
|
|
149
|
+
output: "Second task done",
|
|
150
|
+
status: "completed",
|
|
151
|
+
parentTaskId: "grandparent-1",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "child-of-grandparent");
|
|
155
|
+
expect(preamble).not.toBeNull();
|
|
156
|
+
// Immediate parent (child-of-grandparent) gets inline detail
|
|
157
|
+
expect(preamble).toContain("child-of-grandparent");
|
|
158
|
+
expect(preamble).toContain("Second task done");
|
|
159
|
+
// Grandparent gets pointer-only entry
|
|
160
|
+
expect(preamble).toContain("grandparent-1");
|
|
161
|
+
expect(preamble).toContain("Older Ancestor Tasks");
|
|
162
|
+
expect(preamble).toContain("Initial research task");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("enforces token budget — truncates oversized output", async () => {
|
|
166
|
+
// Generate output that exceeds the budget
|
|
167
|
+
const hugeOutput = "x".repeat(CONTEXT_PREAMBLE_MAX_CHARS + 5000);
|
|
168
|
+
seedTask({
|
|
169
|
+
id: "parent-big",
|
|
170
|
+
task: "Task with very large output",
|
|
171
|
+
output: hugeOutput,
|
|
172
|
+
status: "completed",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-big");
|
|
176
|
+
expect(preamble).not.toBeNull();
|
|
177
|
+
// Preamble must be within budget (some slack for the truncation suffix)
|
|
178
|
+
expect(preamble?.length ?? 0).toBeLessThanOrEqual(
|
|
179
|
+
CONTEXT_PREAMBLE_MAX_CHARS + 300, // 300 chars slack for the truncation message
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("preamble starts with context section and ends with separator", async () => {
|
|
184
|
+
seedTask({
|
|
185
|
+
id: "parent-structure",
|
|
186
|
+
task: "A well-structured task",
|
|
187
|
+
output: "Done",
|
|
188
|
+
status: "completed",
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const preamble = await buildContextPreamble(API_URL, API_KEY, "parent-structure");
|
|
192
|
+
expect(preamble).toContain("---");
|
|
193
|
+
expect(preamble).toContain("Prior Conversation Context");
|
|
194
|
+
// Should end with trailing separator
|
|
195
|
+
expect(preamble?.trimEnd()).toMatch(/---\s*$/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("CONTEXT_PREAMBLE_MAX_TOKENS is 2000 by default", () => {
|
|
199
|
+
expect(CONTEXT_PREAMBLE_MAX_TOKENS).toBe(2000);
|
|
200
|
+
expect(CONTEXT_PREAMBLE_MAX_CHARS).toBe(8000);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -1,76 +1,70 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { createServer as createHttpServer, type Server } from "node:http";
|
|
3
2
|
import {
|
|
4
3
|
type ApiConfig,
|
|
5
4
|
ensureTaskFinished,
|
|
6
5
|
handleStructuredOutputFallback,
|
|
7
6
|
} from "../commands/runner";
|
|
8
7
|
|
|
9
|
-
const TEST_PORT = 13099;
|
|
10
|
-
|
|
11
8
|
// Configurable mock responses per test
|
|
12
9
|
let mockGetTask: Record<string, unknown> | null = null;
|
|
13
10
|
let mockGetTaskStatus = 200;
|
|
14
11
|
let lastFinishBody: Record<string, unknown> | null = null;
|
|
15
12
|
let mockFinishResponse: Record<string, unknown> = { success: true };
|
|
13
|
+
let mockFetchError: Error | null = null;
|
|
14
|
+
let originalFetch: typeof fetch;
|
|
16
15
|
|
|
17
16
|
function resetMocks() {
|
|
18
17
|
mockGetTask = null;
|
|
19
18
|
mockGetTaskStatus = 200;
|
|
20
19
|
lastFinishBody = null;
|
|
21
20
|
mockFinishResponse = { success: true };
|
|
21
|
+
mockFetchError = null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
function makeConfig(port = TEST_PORT): ApiConfig {
|
|
24
|
+
function makeConfig(): ApiConfig {
|
|
27
25
|
return {
|
|
28
|
-
apiUrl:
|
|
26
|
+
apiUrl: "http://runner-fallback.test",
|
|
29
27
|
apiKey: "test-key",
|
|
30
28
|
agentId: "test-agent-id",
|
|
31
29
|
};
|
|
32
30
|
}
|
|
33
31
|
|
|
34
|
-
beforeAll(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
chunks.push(chunk);
|
|
39
|
-
}
|
|
40
|
-
const body = Buffer.concat(chunks).toString();
|
|
41
|
-
const url = req.url || "";
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
originalFetch = globalThis.fetch;
|
|
34
|
+
globalThis.fetch = (async (input, init) => {
|
|
35
|
+
if (mockFetchError) throw mockFetchError;
|
|
42
36
|
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
38
|
+
const parsedUrl = new URL(url);
|
|
39
|
+
const method = init?.method ?? "GET";
|
|
40
|
+
|
|
41
|
+
if (method === "GET" && /^\/api\/tasks\/[^/]+$/.test(parsedUrl.pathname)) {
|
|
45
42
|
if (!mockGetTask) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
44
|
+
status: mockGetTaskStatus,
|
|
45
|
+
});
|
|
49
46
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
return new Response(JSON.stringify(mockGetTask), {
|
|
48
|
+
status: mockGetTaskStatus,
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
});
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(parsedUrl.pathname)) {
|
|
54
|
+
const body = typeof init?.body === "string" ? init.body : "";
|
|
57
55
|
lastFinishBody = body ? JSON.parse(body) : null;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
return new Response(JSON.stringify(mockFinishResponse), {
|
|
57
|
+
status: 200,
|
|
58
|
+
headers: { "Content-Type": "application/json" },
|
|
59
|
+
});
|
|
61
60
|
}
|
|
62
61
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
await new Promise<void>((resolve) => {
|
|
68
|
-
server.listen(TEST_PORT, () => resolve());
|
|
69
|
-
});
|
|
62
|
+
return new Response("Not found", { status: 404 });
|
|
63
|
+
}) as typeof fetch;
|
|
70
64
|
});
|
|
71
65
|
|
|
72
66
|
afterAll(() => {
|
|
73
|
-
|
|
67
|
+
globalThis.fetch = originalFetch;
|
|
74
68
|
});
|
|
75
69
|
|
|
76
70
|
describe("handleStructuredOutputFallback", () => {
|
|
@@ -175,10 +169,9 @@ describe("handleStructuredOutputFallback", () => {
|
|
|
175
169
|
|
|
176
170
|
test("returns fetch-error on network error", async () => {
|
|
177
171
|
resetMocks();
|
|
178
|
-
|
|
179
|
-
const badConfig = makeConfig(19999);
|
|
172
|
+
mockFetchError = new Error("network down");
|
|
180
173
|
|
|
181
|
-
const result = await handleStructuredOutputFallback(
|
|
174
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-7", "claude");
|
|
182
175
|
expect(result.kind).toBe("fetch-error");
|
|
183
176
|
expect((result as { kind: "fetch-error"; error: string }).error).toBeTruthy();
|
|
184
177
|
});
|
|
@@ -227,6 +220,92 @@ describe("ensureTaskFinished", () => {
|
|
|
227
220
|
expect(lastFinishBody!.output).toBe("Process completed successfully (no output captured)");
|
|
228
221
|
});
|
|
229
222
|
|
|
223
|
+
test("uses provider output when no outputSchema exists", async () => {
|
|
224
|
+
resetMocks();
|
|
225
|
+
mockGetTask = {
|
|
226
|
+
id: "task-provider-output",
|
|
227
|
+
task: "Do work",
|
|
228
|
+
status: "in_progress",
|
|
229
|
+
output: null,
|
|
230
|
+
progress: null,
|
|
231
|
+
logs: [],
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
await ensureTaskFinished(
|
|
235
|
+
makeConfig(),
|
|
236
|
+
"worker",
|
|
237
|
+
"task-provider-output",
|
|
238
|
+
0,
|
|
239
|
+
undefined,
|
|
240
|
+
"Provider final answer",
|
|
241
|
+
"pi",
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
expect(lastFinishBody).toBeTruthy();
|
|
245
|
+
expect(lastFinishBody!.status).toBe("completed");
|
|
246
|
+
expect(lastFinishBody!.output).toBe("Provider final answer");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("accepts provider output that satisfies outputSchema", async () => {
|
|
250
|
+
resetMocks();
|
|
251
|
+
mockGetTask = {
|
|
252
|
+
id: "task-provider-schema-valid",
|
|
253
|
+
task: "Do work",
|
|
254
|
+
status: "in_progress",
|
|
255
|
+
output: null,
|
|
256
|
+
outputSchema: {
|
|
257
|
+
type: "object",
|
|
258
|
+
required: ["result"],
|
|
259
|
+
properties: { result: { type: "string" } },
|
|
260
|
+
},
|
|
261
|
+
logs: [],
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
await ensureTaskFinished(
|
|
265
|
+
makeConfig(),
|
|
266
|
+
"worker",
|
|
267
|
+
"task-provider-schema-valid",
|
|
268
|
+
0,
|
|
269
|
+
undefined,
|
|
270
|
+
'{"result":"ok"}',
|
|
271
|
+
"pi",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(lastFinishBody).toBeTruthy();
|
|
275
|
+
expect(lastFinishBody!.status).toBe("completed");
|
|
276
|
+
expect(lastFinishBody!.output).toBe('{"result":"ok"}');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("fails provider output that violates outputSchema", async () => {
|
|
280
|
+
resetMocks();
|
|
281
|
+
mockGetTask = {
|
|
282
|
+
id: "task-provider-schema-invalid",
|
|
283
|
+
task: "Do work",
|
|
284
|
+
status: "in_progress",
|
|
285
|
+
output: null,
|
|
286
|
+
outputSchema: {
|
|
287
|
+
type: "object",
|
|
288
|
+
required: ["result"],
|
|
289
|
+
properties: { result: { type: "string" } },
|
|
290
|
+
},
|
|
291
|
+
logs: [],
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
await ensureTaskFinished(
|
|
295
|
+
makeConfig(),
|
|
296
|
+
"worker",
|
|
297
|
+
"task-provider-schema-invalid",
|
|
298
|
+
0,
|
|
299
|
+
undefined,
|
|
300
|
+
"plain text",
|
|
301
|
+
"pi",
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(lastFinishBody).toBeTruthy();
|
|
305
|
+
expect(lastFinishBody!.status).toBe("failed");
|
|
306
|
+
expect(lastFinishBody!.failureReason).toContain("outputSchema");
|
|
307
|
+
});
|
|
308
|
+
|
|
230
309
|
test("sets failed status for schema-fail fallback", async () => {
|
|
231
310
|
resetMocks();
|
|
232
311
|
mockGetTask = {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
initDb,
|
|
14
14
|
startTask,
|
|
15
15
|
} from "../be/db";
|
|
16
|
+
import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
|
|
16
17
|
|
|
17
18
|
const TEST_DB_PATH = "./test-task-completion-idempotency.sqlite";
|
|
18
19
|
|
|
@@ -318,3 +319,91 @@ describe("store-progress idempotency on terminal status (integration via DB laye
|
|
|
318
319
|
expect(after!.output).toBe("manually written");
|
|
319
320
|
});
|
|
320
321
|
});
|
|
322
|
+
|
|
323
|
+
interface FollowUpRow {
|
|
324
|
+
id: string;
|
|
325
|
+
agentId: string | null;
|
|
326
|
+
parentTaskId: string | null;
|
|
327
|
+
taskType: string | null;
|
|
328
|
+
task: string;
|
|
329
|
+
slackChannelId: string | null;
|
|
330
|
+
slackThreadTs: string | null;
|
|
331
|
+
slackUserId: string | null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function listFollowUpTasks(parentTaskId: string): FollowUpRow[] {
|
|
335
|
+
return getDb()
|
|
336
|
+
.prepare<FollowUpRow, [string]>(
|
|
337
|
+
`SELECT id, agentId, parentTaskId, taskType, task, slackChannelId, slackThreadTs, slackUserId
|
|
338
|
+
FROM agent_tasks
|
|
339
|
+
WHERE parentTaskId = ? AND taskType = 'follow-up'
|
|
340
|
+
ORDER BY createdAt ASC`,
|
|
341
|
+
)
|
|
342
|
+
.all(parentTaskId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
describe("worker task follow-up creation", () => {
|
|
346
|
+
test("creates lead follow-up for completed worker task", () => {
|
|
347
|
+
const lead = createAgent({
|
|
348
|
+
name: "follow-up-lead-1",
|
|
349
|
+
isLead: true,
|
|
350
|
+
status: "idle",
|
|
351
|
+
capabilities: [],
|
|
352
|
+
});
|
|
353
|
+
const worker = createAgent({
|
|
354
|
+
name: "follow-up-worker-1",
|
|
355
|
+
isLead: false,
|
|
356
|
+
status: "idle",
|
|
357
|
+
capabilities: [],
|
|
358
|
+
});
|
|
359
|
+
const task = createTaskExtended("Worker task", {
|
|
360
|
+
agentId: worker.id,
|
|
361
|
+
slackChannelId: "C123",
|
|
362
|
+
slackThreadTs: "1700000000.000001",
|
|
363
|
+
slackUserId: "U123",
|
|
364
|
+
});
|
|
365
|
+
startTask(task.id, worker.id);
|
|
366
|
+
|
|
367
|
+
const completed = completeTask(task.id, "Worker output");
|
|
368
|
+
expect(completed).not.toBeNull();
|
|
369
|
+
|
|
370
|
+
const followUp = createWorkerTaskFollowUp({
|
|
371
|
+
task: completed!,
|
|
372
|
+
status: "completed",
|
|
373
|
+
output: "Worker output",
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
expect(followUp).not.toBeNull();
|
|
377
|
+
const rows = listFollowUpTasks(task.id);
|
|
378
|
+
expect(rows).toHaveLength(1);
|
|
379
|
+
expect(rows[0]!.agentId).toBe(lead.id);
|
|
380
|
+
expect(rows[0]!.parentTaskId).toBe(task.id);
|
|
381
|
+
expect(rows[0]!.slackChannelId).toBe("C123");
|
|
382
|
+
expect(rows[0]!.slackThreadTs).toBe("1700000000.000001");
|
|
383
|
+
expect(rows[0]!.slackUserId).toBe("U123");
|
|
384
|
+
expect(rows[0]!.task).toContain("Worker output");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("does not create follow-up for lead-owned task", () => {
|
|
388
|
+
const lead = createAgent({
|
|
389
|
+
name: "follow-up-lead-2",
|
|
390
|
+
isLead: true,
|
|
391
|
+
status: "idle",
|
|
392
|
+
capabilities: [],
|
|
393
|
+
});
|
|
394
|
+
const task = createTaskExtended("Lead task", { agentId: lead.id });
|
|
395
|
+
startTask(task.id, lead.id);
|
|
396
|
+
|
|
397
|
+
const completed = completeTask(task.id, "Lead output");
|
|
398
|
+
expect(completed).not.toBeNull();
|
|
399
|
+
|
|
400
|
+
const followUp = createWorkerTaskFollowUp({
|
|
401
|
+
task: completed!,
|
|
402
|
+
status: "completed",
|
|
403
|
+
output: "Lead output",
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(followUp).toBeNull();
|
|
407
|
+
expect(listFollowUpTasks(task.id)).toHaveLength(0);
|
|
408
|
+
});
|
|
409
|
+
});
|
package/src/tools/cancel-task.ts
CHANGED
|
@@ -142,12 +142,20 @@ export async function cancelTaskHandler(
|
|
|
142
142
|
|
|
143
143
|
if ("content" in result) return result;
|
|
144
144
|
|
|
145
|
+
const structuredContent = {
|
|
146
|
+
yourAgentId: agentId,
|
|
147
|
+
...result,
|
|
148
|
+
};
|
|
149
|
+
|
|
145
150
|
return {
|
|
146
|
-
content: [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
+
content: [
|
|
152
|
+
{ type: "text", text: result.message },
|
|
153
|
+
{
|
|
154
|
+
type: "text",
|
|
155
|
+
text: JSON.stringify(structuredContent),
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
structuredContent,
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|
|
@@ -65,17 +65,25 @@ export async function getTaskDetailsHandler(
|
|
|
65
65
|
? { name: requestedByUser.name, email: requestedByUser.email }
|
|
66
66
|
: undefined;
|
|
67
67
|
|
|
68
|
+
const structuredContent = {
|
|
69
|
+
yourAgentId: agentId,
|
|
70
|
+
success: true,
|
|
71
|
+
message: `Task "${taskId}" details retrieved.`,
|
|
72
|
+
task,
|
|
73
|
+
requestedBy,
|
|
74
|
+
logs,
|
|
75
|
+
attachments,
|
|
76
|
+
};
|
|
77
|
+
|
|
68
78
|
return {
|
|
69
|
-
content: [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
attachments,
|
|
78
|
-
},
|
|
79
|
+
content: [
|
|
80
|
+
{ type: "text", text: `Task "${taskId}" details retrieved.` },
|
|
81
|
+
{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify(structuredContent),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
structuredContent,
|
|
79
87
|
};
|
|
80
88
|
}
|
|
81
89
|
|