@desplega.ai/agent-swarm 2.4.2 → 2.4.4

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.
Files changed (183) hide show
  1. package/dist/{acp-adapter-dv7hmx4r.js → acp-adapter-thq04adh.js} +4 -5
  2. package/dist/{actions-51nc1gny.js → actions-j6ywh6x5.js} +18 -18
  3. package/dist/{amp-adapter-abp5tdx8.js → amp-adapter-k8b5xmj8.js} +3 -3
  4. package/dist/{app-9h14qvr6.js → app-rb7vc02s.js} +3 -3
  5. package/dist/{assistant-wv1gz3mt.js → assistant-ftta1zjf.js} +20 -20
  6. package/dist/{boot-reembed-6yn53ms9.js → boot-reembed-srp6v6af.js} +1 -1
  7. package/dist/{boot-reembed-7fcka4z5.js → boot-reembed-xns7h5h9.js} +1 -1
  8. package/dist/{boot-scrub-sweep-pm1580ax.js → boot-scrub-sweep-3qqrh2mz.js} +3 -3
  9. package/dist/{boot-seeds-g5hfc1x8.js → boot-seeds-9ks8hce7.js} +4 -4
  10. package/dist/{claude-adapter-gf3kxhf0.js → claude-adapter-k5ksyres.js} +4 -4
  11. package/dist/{claude-managed-setup-h00hsgtz.js → claude-managed-setup-n5a5eqks.js} +1 -1
  12. package/dist/{claude-sdk-session-s2k1qd8y.js → claude-sdk-session-2j3j4kwn.js} +9822 -9749
  13. package/dist/{cli-pkht5hap.js → cli-1d70crsy.js} +2 -2
  14. package/dist/{cli-r99kc9yq.js → cli-1ephgwnq.js} +3 -3
  15. package/dist/{cli-7225gwj7.js → cli-1m17dc9d.js} +1 -1
  16. package/dist/{cli-bcj753sq.js → cli-2v045xyf.js} +3 -3
  17. package/dist/{cli-h38rjv87.js → cli-2y91wnbh.js} +12 -7
  18. package/dist/{cli-sv5gb0yb.js → cli-2zqpkvtt.js} +1 -1
  19. package/dist/{cli-xbzvet96.js → cli-36ckw0wx.js} +5 -4
  20. package/dist/{cli-8sxqsbbq.js → cli-37yb7307.js} +1 -1
  21. package/dist/{cli-hyfw2j1e.js → cli-3cgngyd8.js} +11 -11
  22. package/dist/{cli-a4gvwttf.js → cli-44159k28.js} +2 -2
  23. package/dist/{cli-7awf1fp8.js → cli-4dg55wsk.js} +3 -3
  24. package/dist/{cli-mhywgrrz.js → cli-4sydckyj.js} +1 -1
  25. package/dist/{cli-y2x52gs2.js → cli-51y09c6p.js} +20 -17
  26. package/dist/{cli-ahtpy9kf.js → cli-55qmbp6z.js} +10 -5
  27. package/dist/{cli-pw7v69vp.js → cli-625tb7aq.js} +1 -1
  28. package/dist/{cli-x5mq1fyq.js → cli-74c1wpm0.js} +3 -3
  29. package/dist/{cli-b1dfr1m4.js → cli-7a6p4e65.js} +1 -1
  30. package/dist/{cli-hqhzg1qq.js → cli-82d3nkc3.js} +1 -1
  31. package/dist/{cli-nmqdc2t3.js → cli-9fhkkpj5.js} +78 -12
  32. package/dist/{cli-h93c9vb3.js → cli-amyhm3gj.js} +1 -1
  33. package/dist/{cli-0qvtffe9.js → cli-b8k7epj1.js} +6 -6
  34. package/dist/{cli-1pa3rz69.js → cli-ba8ne1es.js} +6 -1
  35. package/dist/{cli-g0hgtz4p.js → cli-bnz0r3kc.js} +3 -3
  36. package/dist/{cli-0hyhdkdk.js → cli-cebvbc9t.js} +4 -1
  37. package/dist/{cli-efaqv1c7.js → cli-cynvc9fj.js} +35 -7
  38. package/dist/{cli-8zwe9mpk.js → cli-dqb4n629.js} +10 -10
  39. package/dist/{cli-7a91w22y.js → cli-e8xyjvfc.js} +1 -1
  40. package/dist/{cli-s6vr7r2d.js → cli-esxerzg7.js} +1 -1
  41. package/dist/{cli-w9g4sz81.js → cli-gf2850q7.js} +1 -1
  42. package/dist/{cli-10hhg0km.js → cli-gqvh8d8a.js} +7 -7
  43. package/dist/{cli-t30yj95q.js → cli-hj3debgw.js} +1 -1
  44. package/dist/{cli-fpmsqq59.js → cli-nc35pxb7.js} +3 -3
  45. package/dist/{cli-d3qqdy4c.js → cli-p20r8w42.js} +16 -14
  46. package/dist/{cli-shvfedvm.js → cli-pccfmtb5.js} +5 -5
  47. package/dist/{cli-02qm7src.js → cli-q6r7t299.js} +1037 -299
  48. package/dist/{cli-2wag8973.js → cli-qeaq3j6t.js} +1 -1
  49. package/dist/{cli-nn47d7e8.js → cli-qkhc5aqy.js} +15 -15
  50. package/dist/{cli-faypxz7q.js → cli-t7cyef5g.js} +4 -4
  51. package/dist/{cli-z4wd2ftv.js → cli-te6b5wvg.js} +149 -184
  52. package/dist/{cli-5rk36sjf.js → cli-v3yr2gbg.js} +119 -44
  53. package/dist/{cli-239yjk27.js → cli-vdm20wej.js} +5 -5
  54. package/dist/{cli-zfg9enh9.js → cli-vfha21c9.js} +6 -1
  55. package/dist/{cli-rdyw3mct.js → cli-vq97g1jq.js} +6 -5
  56. package/dist/{cli-n85dqg6d.js → cli-w6nadkh4.js} +4 -4
  57. package/dist/{cli-99jez72r.js → cli-w9etnnxk.js} +6 -1
  58. package/dist/{cli-saw9a6f6.js → cli-xc27fwzg.js} +2 -2
  59. package/dist/{cli-ftyypbg5.js → cli-xj0re5tm.js} +1 -1
  60. package/dist/{cli-fyzjmseb.js → cli-zrk6jxv8.js} +1 -1
  61. package/dist/{cli-y8n5k515.js → cli-zrwpctp4.js} +28 -28
  62. package/dist/cli.js +44 -19
  63. package/dist/{codex-adapter-ttygw382.js → codex-adapter-968xwfy7.js} +2 -2
  64. package/dist/{codex-hook-x7nejyrr.js → codex-hook-e2he7aca.js} +2 -2
  65. package/dist/{codex-session-runner-60b58vv3.js → codex-session-runner-ht4z99fa.js} +2 -2
  66. package/dist/{commands-edmdwa4x.js → commands-pcymaeg9.js} +3 -3
  67. package/dist/{controller-m263fg43.js → controller-fpjyk46r.js} +3 -3
  68. package/dist/{controllers-connect-5fym8sw1.js → controllers-connect-mwzx1fh6.js} +19 -19
  69. package/dist/{cursor-adapter-cz6nm6mj.js → cursor-adapter-cp4s2e2f.js} +2 -2
  70. package/dist/{db-2y0mgstc.js → db-sgd12bt9.js} +3 -3
  71. package/dist/{db-nn0hcmx5.js → db-xsjg9jqq.js} +2 -2
  72. package/dist/{dispatcher-0a9wwjcx.js → dispatcher-x0jfw2c9.js} +17 -17
  73. package/dist/{dsh-adapter-3n3net33.js → dsh-adapter-jrq7ma7h.js} +2 -2
  74. package/dist/{e2b-thdcxtjd.js → e2b-16d6ch39.js} +1 -1
  75. package/dist/{e2b-7am6tmq6.js → e2b-zn0b4w5w.js} +1 -1
  76. package/dist/{external-channel-zhvt95n3.js → external-channel-8njj4h3b.js} +2 -2
  77. package/dist/{grok-adapter-qnr1hp2y.js → grok-adapter-kkr97v2f.js} +5 -6
  78. package/dist/handlers-0pvee9nw.js +39 -0
  79. package/dist/{heartbeat-gz97r7ac.js → heartbeat-wd03zhn0.js} +20 -20
  80. package/dist/{hook-1mmdgjqs.js → hook-f0vghj42.js} +10 -5
  81. package/dist/{hook-x8rthca4.js → hook-xzj2ts2p.js} +2 -2
  82. package/dist/{http-r8f85xr9.js → http-4hwxv62p.js} +261 -123
  83. package/dist/{index-va3y63es.js → index-6srwvd5b.js} +1 -2
  84. package/dist/{index-y7rbcrc1.js → index-97q4xn03.js} +20 -11
  85. package/dist/{index-s98037v0.js → index-cbxkmfc5.js} +1 -2
  86. package/dist/{index-vzvfqzfj.js → index-gdh8jrdm.js} +17 -17
  87. package/dist/{index-aex0eba9.js → index-hg0x2cdv.js} +17 -17
  88. package/dist/index-thm0y6a8.js +41 -0
  89. package/dist/{index-smxyk04s.js → index-ww2vznkp.js} +1 -1
  90. package/dist/{keepalive-64c0f34c.js → keepalive-hrh7xnr9.js} +3 -3
  91. package/dist/{lead-b1da0f97.js → lead-ee8vazz2.js} +30 -31
  92. package/dist/{maintenance-mnxpar12.js → maintenance-e8wnwnqx.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-36hs2v4h.js → oauth-refresh-sweep-zm0yj4sj.js} +2 -2
  94. package/dist/{onboard-xes8de61.js → onboard-dtwcq603.js} +3 -3
  95. package/dist/{opencode-adapter-3s7ax02e.js → opencode-adapter-x4tqzhkh.js} +3 -3
  96. package/dist/{otel-impl-fzc57kcq.js → otel-impl-w3fbhpn0.js} +1 -1
  97. package/dist/{pi-mono-adapter-ytp7gz8b.js → pi-mono-adapter-mcfkn9vf.js} +3 -3
  98. package/dist/{pricing-refresh-1nek2w7b.js → pricing-refresh-j38dehfs.js} +3 -3
  99. package/dist/{rbac-roles-ttyfaep2.js → rbac-roles-ae8yttq4.js} +3 -3
  100. package/dist/{rbac-roles-sqefb5hw.js → rbac-roles-ejxx9b35.js} +2 -2
  101. package/dist/{render-v2-vb527jwn.js → render-v2-n16n0phe.js} +3 -3
  102. package/dist/{resume-jcdzg1bn.js → resume-z0fgjc38.js} +5 -5
  103. package/dist/{session-access-zsp4wsp0.js → session-access-cv3mq6mc.js} +6 -4
  104. package/dist/{setup-1nb2zqd3.js → setup-mafhj3yt.js} +1 -1
  105. package/dist/{work-objects-61w08fwr.js → work-objects-8b86mgjp.js} +21 -21
  106. package/dist/{worker-c1e68hpa.js → worker-16s75hm5.js} +30 -31
  107. package/dist/worker-token-xf11yyds.js +136 -0
  108. package/openapi.json +295 -12
  109. package/package.json +5 -5
  110. package/src/apps/action-runner.ts +4 -0
  111. package/src/apps/catalog.generated.json +3 -0
  112. package/src/apps/page-validator.ts +8 -2
  113. package/src/be/db.ts +7 -4
  114. package/src/be/migrations/231_slack_tasks_last_updated_index.sql +5 -0
  115. package/src/be/migrations/232_agent_session_tokens.sql +41 -0
  116. package/src/be/seed-scripts/catalog/heartbeat-jev-gate.ts +10 -1
  117. package/src/be/swarm-config-guard.ts +16 -0
  118. package/src/be/users.ts +169 -12
  119. package/src/cli.tsx +24 -0
  120. package/src/commands/worker-token.ts +164 -0
  121. package/src/github/handlers.ts +6 -5
  122. package/src/hooks/hook.ts +2 -1
  123. package/src/http/apps.ts +43 -5
  124. package/src/http/codex-oauth-keep-warm.ts +27 -9
  125. package/src/http/config.ts +5 -3
  126. package/src/http/core.ts +2 -0
  127. package/src/http/session-access.ts +51 -2
  128. package/src/http/sessions.ts +249 -10
  129. package/src/http/tasks.ts +4 -32
  130. package/src/providers/acp-adapter.ts +33 -13
  131. package/src/providers/acp-swarm-events.ts +1 -1
  132. package/src/providers/amp-adapter.ts +1 -1
  133. package/src/providers/codex-adapter.ts +4 -4
  134. package/src/providers/codex-oauth/storage.ts +1 -1
  135. package/src/providers/opencode-adapter.ts +2 -2
  136. package/src/providers/pi-mono-extension.ts +2 -1
  137. package/src/rbac/legacy-policy.ts +3 -1
  138. package/src/rbac/permissions.ts +4 -0
  139. package/src/tests/acp-adapter.test.ts +10 -23
  140. package/src/tests/acp-fake-agent.ts +36 -0
  141. package/src/tests/acp-swarm-events.test.ts +1 -1
  142. package/src/tests/agent-session-tokens.test.ts +452 -0
  143. package/src/tests/apps-action-parent-task.test.ts +295 -0
  144. package/src/tests/claude-stop-hook.test.ts +47 -0
  145. package/src/tests/codex-adapter.test.ts +42 -0
  146. package/src/tests/codex-oauth-keep-warm.test.ts +85 -1
  147. package/src/tests/config-session-auth.test.ts +14 -0
  148. package/src/tests/docs-index-footprint.test.js +2 -2
  149. package/src/tests/env-flag.test.ts +15 -1
  150. package/src/tests/github-handlers-cancel-config.test.ts +29 -8
  151. package/src/tests/github-handlers-inline-comments.test.ts +9 -2
  152. package/src/tests/grok-adapter.test.ts +3 -3
  153. package/src/tests/heartbeat-jev-gate.test.ts +23 -0
  154. package/src/tests/migration-232-agent-session-tokens.test.ts +67 -0
  155. package/src/tests/rbac-engine.test.ts +1 -0
  156. package/src/tests/runtime-child-token-cleanup.test.ts +3 -3
  157. package/src/tests/script-connections.test.ts +4 -0
  158. package/src/tests/session-route-posture.test.ts +2 -0
  159. package/src/tests/skill-script-refs.test.ts +63 -0
  160. package/src/tests/slack-render-v2-query-plans.test.ts +147 -0
  161. package/src/tests/worker-token-cli.test.ts +76 -0
  162. package/src/tests/workflow-executors.test.ts +4 -2
  163. package/src/tests/workflow-system-one-extraction.test.ts +805 -0
  164. package/src/types.ts +2 -1
  165. package/src/utils/env-flag.ts +9 -0
  166. package/src/utils/request-auth-context.ts +2 -1
  167. package/src/workflows/definition.ts +7 -1
  168. package/src/workflows/engine.ts +18 -12
  169. package/src/workflows/executors/index.ts +1 -0
  170. package/src/workflows/executors/registry.ts +2 -0
  171. package/src/workflows/executors/system-one-decision.ts +56 -255
  172. package/src/workflows/executors/system-one-extract-wire.ts +57 -0
  173. package/src/workflows/executors/system-one-extraction.ts +830 -0
  174. package/src/workflows/executors/system-one-providers.ts +277 -110
  175. package/src/workflows/executors/system-one-transport.ts +264 -0
  176. package/templates/official/lead/HEARTBEAT.md +1 -1
  177. package/templates/skills/apps/SKILL.md +4 -1
  178. package/templates/skills/apps/content.md +4 -1
  179. package/templates/skills/code-quality/content.md +9 -14
  180. package/templates/skills/heartbeat-runbook/content.md +3 -3
  181. package/dist/cli-v1sjhn52.js +0 -7
  182. package/dist/handlers-dkcarkd0.js +0 -39
  183. package/dist/index-5jecgp3n.js +0 -41
@@ -0,0 +1,295 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import { parseAppDefinition } from "../apps/definition";
10
+ import { createApp } from "../apps/store";
11
+ import {
12
+ closeDb,
13
+ createAgent,
14
+ createTask,
15
+ createTaskExtended,
16
+ createUser,
17
+ getTaskById,
18
+ initDb,
19
+ } from "../be/db";
20
+ import { type IdentityActor, mintToken } from "../be/users";
21
+ import { handleApps } from "../http/apps";
22
+ import { resolveHttpRequestAuth } from "../http/auth";
23
+ import { SESSION_WRITE_DENIED } from "../http/session-access";
24
+ import { getPathSegments, parseQueryParams } from "../http/utils";
25
+ import { setRequestAuth } from "../utils/request-auth-context";
26
+ import { listenOnFreePort } from "./test-net";
27
+
28
+ const TEST_DB_PATH = `/private/tmp/test-apps-action-parent-task-${process.pid}.sqlite`;
29
+ const API_KEY = "example-apps-action-parent-task-key";
30
+ const OPERATOR_ACTOR: IdentityActor = { kind: "operator", id: "apps-action-parent-task-test" };
31
+ /** Non-lead worker: the default caller. */
32
+ const AGENT_ID = crypto.randomUUID();
33
+ const LEAD_ID = crypto.randomUUID();
34
+ /** Owns the parent task the worker has no context for. */
35
+ const OTHER_AGENT_ID = crypto.randomUUID();
36
+ const TASK_ACTION = "generateScreen";
37
+ const SYNC_ACTION = "syncNotes";
38
+ const CHAIN_ISSUE = "$row, $rowIndex, $form, and $chain bindings are only allowed in action params";
39
+
40
+ let server: Server;
41
+ let base = "";
42
+ /** A user with no share on anyone else's session. */
43
+ let userToken = "";
44
+ let previousApiKey: string | undefined;
45
+
46
+ function createTestServer(): Server {
47
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
48
+ setRequestAuth(req, await resolveHttpRequestAuth(req, API_KEY));
49
+ res.setHeader("Content-Type", "application/json");
50
+ const pathSegments = getPathSegments(req.url || "");
51
+ const queryParams = parseQueryParams(req.url || "");
52
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
53
+ const handled = await handleApps(req, res, pathSegments, queryParams, myAgentId);
54
+ if (!handled) {
55
+ res.writeHead(404);
56
+ res.end(JSON.stringify({ error: "not found" }));
57
+ }
58
+ });
59
+ }
60
+
61
+ /** Runs an action as an agent (default: the worker) or, with `bearer`, as a token holder. */
62
+ async function runAction<T>(
63
+ appId: string,
64
+ body: Record<string, unknown>,
65
+ options: { agentId?: string; bearer?: string; action?: string } = {},
66
+ ): Promise<{ status: number; body: T }> {
67
+ const action = options.action ?? TASK_ACTION;
68
+ const response = await fetch(`${base}/api/apps/${appId}/actions/${action}`, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ ...(options.bearer
73
+ ? { Authorization: `Bearer ${options.bearer}` }
74
+ : { "X-Agent-ID": options.agentId ?? AGENT_ID }),
75
+ },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: (await response.json()) as T };
79
+ }
80
+
81
+ /** A page whose Button chains a task-kind action under the chain scope's leaf task. */
82
+ function chainedButtonPage() {
83
+ return {
84
+ root: "root",
85
+ elements: {
86
+ root: { type: "Container", props: {}, children: ["send"] },
87
+ send: {
88
+ type: "Button",
89
+ props: { label: "Send" },
90
+ on: {
91
+ press: [
92
+ {
93
+ action: "app.action",
94
+ params: {
95
+ name: TASK_ACTION,
96
+ input: { prompt: "next step" },
97
+ parentTaskId: { $chain: "leafTaskId" },
98
+ },
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ function definitionWith(page: Record<string, unknown>) {
108
+ return {
109
+ models: {
110
+ note: {
111
+ columns: { title: { kind: "string" } },
112
+ // The sync action needs one (model x source) pair to be valid.
113
+ sources: { tracker: { connector: "swarm-tasks", joinKey: "title" } },
114
+ },
115
+ },
116
+ queries: { allNotes: { model: "note" } },
117
+ actions: {
118
+ [TASK_ACTION]: { kind: "task", prompt: "Generate the next screen" },
119
+ // A non-task action, to show parentTaskId is rejected rather than dropped.
120
+ [SYNC_ACTION]: { kind: "sync" },
121
+ },
122
+ pages: { main: page },
123
+ defaultPage: "main",
124
+ };
125
+ }
126
+
127
+ /** Stores the chained-Button app the way the API does: validated first. */
128
+ async function createChainedApp(): Promise<string> {
129
+ const parsed = await parseAppDefinition(definitionWith(chainedButtonPage()));
130
+ if (!parsed.success) throw new Error(JSON.stringify(parsed.issues));
131
+ const app = await createApp({
132
+ name: `Chained ${crypto.randomUUID()}`,
133
+ definition: parsed.definition,
134
+ });
135
+ return app.id;
136
+ }
137
+
138
+ beforeAll(async () => {
139
+ for (const suffix of ["", "-wal", "-shm"]) {
140
+ try {
141
+ await unlink(`${TEST_DB_PATH}${suffix}`);
142
+ } catch {}
143
+ }
144
+ // Safe to mutate: CI runs one process per test file (`bun test --parallel=4`)
145
+ // and afterAll restores the previous value.
146
+ previousApiKey = process.env.AGENT_SWARM_API_KEY;
147
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
148
+ initDb(TEST_DB_PATH);
149
+ const user = await createUser({ name: "Parent Task User" });
150
+ userToken = (await mintToken(user.id, "apps-action-parent-task", OPERATOR_ACTOR)).plaintext;
151
+ await createAgent({ id: AGENT_ID, name: "parent-task-worker", isLead: false, status: "idle" });
152
+ await createAgent({ id: LEAD_ID, name: "parent-task-lead", isLead: true, status: "idle" });
153
+ await createAgent({
154
+ id: OTHER_AGENT_ID,
155
+ name: "parent-task-other",
156
+ isLead: false,
157
+ status: "idle",
158
+ });
159
+ server = createTestServer();
160
+ const port = await listenOnFreePort(server, "127.0.0.1");
161
+ base = `http://127.0.0.1:${port}`;
162
+ });
163
+
164
+ afterAll(async () => {
165
+ await new Promise<void>((resolve) => server.close(() => resolve()));
166
+ closeDb();
167
+ if (previousApiKey === undefined) delete process.env.AGENT_SWARM_API_KEY;
168
+ else process.env.AGENT_SWARM_API_KEY = previousApiKey;
169
+ for (const suffix of ["", "-wal", "-shm"]) {
170
+ try {
171
+ await unlink(`${TEST_DB_PATH}${suffix}`);
172
+ } catch {}
173
+ }
174
+ });
175
+
176
+ describe("$chain sentinel in the page validator", () => {
177
+ test("accepts parentTaskId: { $chain } inside an app.action step's params", async () => {
178
+ const parsed = await parseAppDefinition(definitionWith(chainedButtonPage()));
179
+ expect(parsed.success).toBe(true);
180
+ });
181
+
182
+ test("accepts a $chain sentinel nested deeper inside app.action params", async () => {
183
+ const page = chainedButtonPage();
184
+ page.elements.send.on.press[0]!.params = {
185
+ name: TASK_ACTION,
186
+ input: { context: { parentTaskId: { $chain: "leafTaskId" } } },
187
+ };
188
+ const parsed = await parseAppDefinition(definitionWith(page));
189
+ expect(parsed.success).toBe(true);
190
+ });
191
+
192
+ test("rejects a $chain sentinel used as a plain prop value", async () => {
193
+ const page = chainedButtonPage();
194
+ page.elements = {
195
+ ...page.elements,
196
+ echo: { type: "Text", props: { content: { $chain: "leafTaskId" } } },
197
+ } as typeof page.elements;
198
+ page.elements.root.children = ["send", "echo"];
199
+ const parsed = await parseAppDefinition(definitionWith(page));
200
+ expect(parsed.success).toBe(false);
201
+ if (parsed.success) return;
202
+ expect(parsed.issues.some((entry) => entry.message === CHAIN_ISSUE)).toBe(true);
203
+ });
204
+ });
205
+
206
+ describe("POST /api/apps/:id/actions/:name with parentTaskId", () => {
207
+ test("chains the created task under the given parent", async () => {
208
+ const appId = await createChainedApp();
209
+ const parent = await createTask(AGENT_ID, "Previous turn of the chain");
210
+
211
+ const started = await runAction<{ ok: boolean; taskId: string; status: string }>(appId, {
212
+ input: { prompt: "next step" },
213
+ parentTaskId: parent.id,
214
+ });
215
+ expect(started.status).toBe(200);
216
+ expect(started.body).toMatchObject({ ok: true, status: "pending" });
217
+
218
+ const created = await getTaskById(started.body.taskId);
219
+ expect(created?.parentTaskId).toBe(parent.id);
220
+ });
221
+
222
+ test("answers 404 for an unknown parent instead of storing a dangling id", async () => {
223
+ const appId = await createChainedApp();
224
+ const missing = await runAction<{ error: string }>(appId, {
225
+ input: {},
226
+ parentTaskId: crypto.randomUUID(),
227
+ });
228
+ expect(missing.status).toBe(404);
229
+ expect(missing.body.error).toBe("parent task not found");
230
+ });
231
+
232
+ test("denies an agent caller without context for the parent, as POST /api/tasks does", async () => {
233
+ const appId = await createChainedApp();
234
+ const foreign = await createTask(OTHER_AGENT_ID, "Someone else's thread");
235
+
236
+ const denied = await runAction<{ error: string }>(appId, {
237
+ input: {},
238
+ parentTaskId: foreign.id,
239
+ });
240
+ expect(denied.status).toBe(403);
241
+ expect(denied.body.error).toBe(`You don't have context for parent task ${foreign.id}.`);
242
+
243
+ // A lead keeps its `task.parent.attach` grant: the gate is about identity, not the route.
244
+ const asLead = await runAction<{ taskId: string }>(
245
+ appId,
246
+ { input: {}, parentTaskId: foreign.id },
247
+ { agentId: LEAD_ID },
248
+ );
249
+ expect(asLead.status).toBe(200);
250
+ expect((await getTaskById(asLead.body.taskId))?.parentTaskId).toBe(foreign.id);
251
+ });
252
+
253
+ test("denies a user caller without a write share on the parent's session", async () => {
254
+ const appId = await createChainedApp();
255
+ const owner = await createUser({ name: "Session Owner" });
256
+ const theirs = await createTaskExtended("Someone else's session", {
257
+ agentId: OTHER_AGENT_ID,
258
+ requestedByUserId: owner.id,
259
+ });
260
+
261
+ const denied = await runAction<{ error: string }>(
262
+ appId,
263
+ { input: {}, parentTaskId: theirs.id },
264
+ { bearer: userToken },
265
+ );
266
+ expect(denied.status).toBe(403);
267
+ expect(denied.body.error).toBe(SESSION_WRITE_DENIED);
268
+ });
269
+
270
+ test("rejects parentTaskId on a non-task action instead of dropping it", async () => {
271
+ const appId = await createChainedApp();
272
+ const parent = await createTask(AGENT_ID, "Previous turn of the chain");
273
+
274
+ const rejected = await runAction<{ error: string }>(
275
+ appId,
276
+ { input: {}, parentTaskId: parent.id },
277
+ { action: SYNC_ACTION },
278
+ );
279
+ expect(rejected.status).toBe(400);
280
+ expect(rejected.body.error).toBe("parentTaskId is only supported for task-kind actions");
281
+ });
282
+
283
+ test("rejects an empty parentTaskId", async () => {
284
+ const appId = await createChainedApp();
285
+ const rejected = await runAction<{ error: string }>(appId, { input: {}, parentTaskId: "" });
286
+ expect(rejected.status).toBe(400);
287
+ });
288
+
289
+ test("still creates a parentless task when parentTaskId is omitted", async () => {
290
+ const appId = await createChainedApp();
291
+ const started = await runAction<{ ok: boolean; taskId: string }>(appId, { input: {} });
292
+ expect(started.status).toBe(200);
293
+ expect((await getTaskById(started.body.taskId))?.parentTaskId ?? null).toBeNull();
294
+ });
295
+ });
@@ -371,6 +371,53 @@ describe("runStopHookSessionSummary", () => {
371
371
  expect(consoleErrors.length).toBe(0);
372
372
  });
373
373
 
374
+ test.each(["true", "TRUE"])("SKIP_SESSION_SUMMARY=%s → no runSummarize call", async (value) => {
375
+ const transcriptPath = await writeTempTranscript(longTranscript());
376
+
377
+ let runSummarizeCalled = false;
378
+ const deps: RunStopHookSessionSummaryDeps = {
379
+ runSummarize: async () => {
380
+ runSummarizeCalled = true;
381
+ throw new Error("should not be called");
382
+ },
383
+ };
384
+
385
+ await runStopHookSessionSummary(
386
+ {
387
+ agentId: "agent-claude-1",
388
+ transcriptPath,
389
+ env: makeEnv({ SKIP_SESSION_SUMMARY: value }),
390
+ },
391
+ deps,
392
+ );
393
+
394
+ expect(runSummarizeCalled).toBe(false);
395
+ expect(fetchCalls.length).toBe(0);
396
+ });
397
+
398
+ test.each(["0", "false"])("SKIP_SESSION_SUMMARY=%s → summary still runs", async (value) => {
399
+ const transcriptPath = await writeTempTranscript(longTranscript());
400
+
401
+ let runSummarizeCalled = false;
402
+ const deps: RunStopHookSessionSummaryDeps = {
403
+ runSummarize: async () => {
404
+ runSummarizeCalled = true;
405
+ return null;
406
+ },
407
+ };
408
+
409
+ await runStopHookSessionSummary(
410
+ {
411
+ agentId: "agent-claude-1",
412
+ transcriptPath,
413
+ env: makeEnv({ SKIP_SESSION_SUMMARY: value }),
414
+ },
415
+ deps,
416
+ );
417
+
418
+ expect(runSummarizeCalled).toBe(true);
419
+ });
420
+
374
421
  test("short transcript (≤100 chars) → no runSummarize call, no POST", async () => {
375
422
  const transcriptPath = await writeTempTranscript("tiny");
376
423
 
@@ -2294,6 +2294,48 @@ describe("CodexSession session-end summarization", () => {
2294
2294
  expect(indexCalls.length).toBe(0);
2295
2295
  });
2296
2296
 
2297
+ test.each(["true", "TRUE"])("SKIP_SESSION_SUMMARY=%s → no runSummarize call", async (value) => {
2298
+ process.env.SKIP_SESSION_SUMMARY = value;
2299
+ const events = buildSummaryEvents();
2300
+
2301
+ let runSummarizeCalls = 0;
2302
+ const deps: SummarizeSessionForCodexDeps = {
2303
+ runSummarize: async () => {
2304
+ runSummarizeCalls += 1;
2305
+ return null;
2306
+ },
2307
+ };
2308
+
2309
+ const config = testConfig({
2310
+ logFile: join(tmpLogDir, `skip-${value}.log`),
2311
+ cwd: "",
2312
+ });
2313
+ await runSessionWithFakeThreadAndDeps(events, config, deps);
2314
+
2315
+ expect(runSummarizeCalls).toBe(0);
2316
+ });
2317
+
2318
+ test.each(["0", "false"])("SKIP_SESSION_SUMMARY=%s → summary still runs", async (value) => {
2319
+ process.env.SKIP_SESSION_SUMMARY = value;
2320
+ const events = buildSummaryEvents();
2321
+
2322
+ let runSummarizeCalls = 0;
2323
+ const deps: SummarizeSessionForCodexDeps = {
2324
+ runSummarize: async () => {
2325
+ runSummarizeCalls += 1;
2326
+ return null;
2327
+ },
2328
+ };
2329
+
2330
+ const config = testConfig({
2331
+ logFile: join(tmpLogDir, `no-skip-${value}.log`),
2332
+ cwd: "",
2333
+ });
2334
+ await runSessionWithFakeThreadAndDeps(events, config, deps);
2335
+
2336
+ expect(runSummarizeCalls).toBe(1);
2337
+ });
2338
+
2297
2339
  test("missing taskId → no runSummarize call, no POST", async () => {
2298
2340
  const events = buildSummaryEvents();
2299
2341
 
@@ -10,7 +10,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
10
10
  import { unlink } from "node:fs/promises";
11
11
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
12
12
  import { closeDb, initDb, upsertKv } from "../be/db";
13
- import { handleCodexOAuthKeepWarm } from "../http/codex-oauth-keep-warm";
13
+ import { validateConfigValue } from "../be/swarm-config-guard";
14
+ import { handleCodexOAuthKeepWarm, keepWarmMaxAgeMs } from "../http/codex-oauth-keep-warm";
14
15
  import { handleOAuthLocks } from "../http/oauth-locks";
15
16
  import { resetFetchForTesting, setFetchForTesting } from "../providers/codex-oauth/flow.js";
16
17
  import type { CodexOAuthCredentials } from "../providers/codex-oauth/types.js";
@@ -176,6 +177,70 @@ describe("POST /api/oauth/keep-warm/codex", () => {
176
177
  expect(slots.get(1)?.access).toBe("example-at_keepwarm");
177
178
  });
178
179
 
180
+ it("refreshes a 6-day-old slot by default, before the ~6d20h point where OpenAI ended a session", async () => {
181
+ const slots = new Map<number, CodexOAuthCredentials>([
182
+ [
183
+ 0,
184
+ {
185
+ access: "at_six_days",
186
+ refresh: "rt_six_days",
187
+ expires: Date.now() + 4 * 24 * 60 * 60 * 1000, // issued ~6 days ago (10d TTL)
188
+ accountId: "acc-six",
189
+ },
190
+ ],
191
+ ]);
192
+ installMockTransport(slots);
193
+ setFetchForTesting(
194
+ async () =>
195
+ new Response(
196
+ JSON.stringify({
197
+ access_token: "example-at_six_days_new",
198
+ refresh_token: "example-rt_six_days_new",
199
+ expires_in: 864000,
200
+ }),
201
+ { status: 200, headers: { "Content-Type": "application/json" } },
202
+ ),
203
+ );
204
+
205
+ const { req, res, captured } = fakeReqRes();
206
+ await handleCodexOAuthKeepWarm(req, res, ["api", "oauth", "keep-warm", "codex"]);
207
+
208
+ const body = JSON.parse(captured.body) as { results: Array<Record<string, unknown>> };
209
+ expect(body.results[0]).toMatchObject({ slot: 0, outcome: "refreshed" });
210
+ expect(slots.get(0)?.refresh).toBe("example-rt_six_days_new");
211
+ });
212
+
213
+ it("honors CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS read at sweep time", async () => {
214
+ const saved = process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS;
215
+ process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS = "168";
216
+ try {
217
+ const slots = new Map<number, CodexOAuthCredentials>([
218
+ [
219
+ 0,
220
+ {
221
+ access: "at_six_days",
222
+ refresh: "rt_six_days",
223
+ expires: Date.now() + 4 * 24 * 60 * 60 * 1000, // issued ~6 days ago, under 168h
224
+ accountId: "acc-six",
225
+ },
226
+ ],
227
+ ]);
228
+ installMockTransport(slots);
229
+ setFetchForTesting(async () => {
230
+ throw new Error("no /oauth/token exchange should happen under a 168h threshold");
231
+ });
232
+
233
+ const { req, res, captured } = fakeReqRes();
234
+ await handleCodexOAuthKeepWarm(req, res, ["api", "oauth", "keep-warm", "codex"]);
235
+
236
+ const body = JSON.parse(captured.body) as { results: Array<Record<string, unknown>> };
237
+ expect(body.results[0]).toMatchObject({ slot: 0, outcome: "warm" });
238
+ } finally {
239
+ if (saved === undefined) delete process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS;
240
+ else process.env.CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS = saved;
241
+ }
242
+ });
243
+
179
244
  it("skips a slot with an active codex-auth-watch bench marker", async () => {
180
245
  const keySuffix = "c-old"; // last 5 of accountId (no chatgpt_user_id in this fixture's access token)
181
246
  await upsertKv({
@@ -284,6 +349,25 @@ describe("POST /api/oauth/keep-warm/codex", () => {
284
349
  }
285
350
  });
286
351
 
352
+ it("keepWarmMaxAgeMs defaults to 120h and ignores invalid or out-of-range values", () => {
353
+ const hour = 60 * 60 * 1000;
354
+ expect(keepWarmMaxAgeMs({})).toBe(120 * hour);
355
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: " 72 " })).toBe(72 * hour);
356
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: "216" })).toBe(216 * hour);
357
+ for (const bad of ["0", "217", "1.5", "-3", "abc", ""]) {
358
+ expect(keepWarmMaxAgeMs({ CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS: bad })).toBe(120 * hour);
359
+ }
360
+ });
361
+
362
+ it("the config API rejects the values keepWarmMaxAgeMs would fall back from", () => {
363
+ const key = "CODEX_OAUTH_KEEP_WARM_MAX_AGE_HOURS";
364
+ expect(validateConfigValue(key, "1")).toBeNull();
365
+ expect(validateConfigValue(key, "216")).toBeNull();
366
+ expect(validateConfigValue(key, "0")).toContain("between 1 and 216");
367
+ expect(validateConfigValue(key, "217")).toContain("between 1 and 216");
368
+ expect(validateConfigValue(key, "1.5")).toContain("integer");
369
+ });
370
+
287
371
  it("does not match unrelated paths", async () => {
288
372
  const { req, res } = fakeReqRes();
289
373
  const handled = await handleCodexOAuthKeepWarm(req, res, [
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { createServer, type Server } from "node:http";
3
3
  import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
4
+ import { mintAgentToken, revokeAgentTokens } from "../be/users";
4
5
  import { handleConfig } from "../http/config";
5
6
  import { handleCore } from "../http/core";
6
7
  import { handleSessions } from "../http/sessions";
@@ -116,6 +117,19 @@ describe("config HTTP session-token authorization", () => {
116
117
  expect((await response.json()).configs[0].value).toBe("global");
117
118
  });
118
119
 
120
+ test("agent token resolved config without agentId reads its own agent scope", async () => {
121
+ const token = await mintAgentToken(workerId);
122
+ try {
123
+ const response = await fetch(`${baseUrl}/api/config/resolved?includeSecrets=true`, {
124
+ headers: headers(token.plaintext),
125
+ });
126
+ expect(response.status).toBe(200);
127
+ expect(await response.text()).toContain(SECRET);
128
+ } finally {
129
+ await revokeAgentTokens(workerId);
130
+ }
131
+ });
132
+
119
133
  for (const method of ["PUT", "DELETE"] as const) {
120
134
  test(`${method} cannot escalate a non-lead session by changing only X-Agent-ID`, async () => {
121
135
  const token = await mint(workerId);
@@ -103,8 +103,8 @@ describe("docs API index footprint", () => {
103
103
  expect(spec.paths["/api/agents/{id}/runtime"]?.get).toBeDefined();
104
104
 
105
105
  const generatedKeys = await generatedOperationKeys();
106
- expect(operationKeys).toHaveLength(437);
107
- expect(generatedKeys).toHaveLength(437);
106
+ expect(operationKeys).toHaveLength(440);
107
+ expect(generatedKeys).toHaveLength(440);
108
108
  expect(new Set(generatedKeys)).toEqual(new Set(operationKeys));
109
109
  });
110
110
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { validateConfigValue } from "../be/swarm-config-guard";
3
- import { isEnvFlagEnabled, parseEnvFlag } from "../utils/env-flag";
3
+ import { isEnvFlagEnabled, isSessionSummarySkipped, parseEnvFlag } from "../utils/env-flag";
4
4
 
5
5
  describe("parseEnvFlag", () => {
6
6
  test("accepts both truthy serializations", () => {
@@ -199,3 +199,17 @@ describe("swarm-config-guard: Configuration-page value validation", () => {
199
199
  expect(validateConfigValue("SOME_RANDOM_KEY", "whatever")).toBeNull();
200
200
  });
201
201
  });
202
+
203
+ describe("isSessionSummarySkipped", () => {
204
+ test.each(["1", "true", "TRUE", " True "])("SKIP_SESSION_SUMMARY=%p skips", (value) => {
205
+ expect(isSessionSummarySkipped({ SKIP_SESSION_SUMMARY: value })).toBe(true);
206
+ });
207
+
208
+ test.each(["0", "false", "FALSE", ""])("SKIP_SESSION_SUMMARY=%p keeps the summary", (value) => {
209
+ expect(isSessionSummarySkipped({ SKIP_SESSION_SUMMARY: value })).toBe(false);
210
+ });
211
+
212
+ test("unset keeps the summary", () => {
213
+ expect(isSessionSummarySkipped({})).toBe(false);
214
+ });
215
+ });
@@ -6,8 +6,8 @@
6
6
  * github.cancelOnUnassign — PR unassigned + issue unassigned
7
7
  * github.cancelOnReviewRequestRemoved — PR review_request_removed
8
8
  *
9
- * Absent / any value ≠ "false" → cancel (current behavior, default).
10
- * Value "false" → leave task untouched, return { created: false }.
9
+ * Absent / empty / unrecognized values → cancel (default).
10
+ * Values "false" / "0" (case-insensitive, trimmed) → leave task untouched.
11
11
  */
12
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { unlink } from "node:fs/promises";
@@ -122,8 +122,15 @@ describe("PR unassigned — github.cancelOnUnassign", () => {
122
122
  expect(await getTaskStatus(taskId)).toBe("failed");
123
123
  });
124
124
 
125
- test("config = 'false': unassign leaves task untouched", async () => {
126
- await setConfigFlag("github.cancelOnUnassign", "false");
125
+ test.each([
126
+ "false",
127
+ "FALSE",
128
+ "False",
129
+ "0",
130
+ " false ",
131
+ " 0 ",
132
+ ])("config = %j: unassign leaves task untouched", async (value) => {
133
+ await setConfigFlag("github.cancelOnUnassign", value);
127
134
  const taskId = await seedTask(BASE_PR.number, "pr");
128
135
  expect(await getTaskStatus(taskId)).toBe("pending");
129
136
 
@@ -162,8 +169,15 @@ describe("issue unassigned — github.cancelOnUnassign", () => {
162
169
  expect(await getTaskStatus(taskId)).toBe("failed");
163
170
  });
164
171
 
165
- test("config = 'false': unassign leaves task untouched", async () => {
166
- await setConfigFlag("github.cancelOnUnassign", "false");
172
+ test.each([
173
+ "false",
174
+ "FALSE",
175
+ "False",
176
+ "0",
177
+ " false ",
178
+ " 0 ",
179
+ ])("config = %j: unassign leaves task untouched", async (value) => {
180
+ await setConfigFlag("github.cancelOnUnassign", value);
167
181
  const taskId = await seedTask(BASE_ISSUE.number, "issue");
168
182
  expect(await getTaskStatus(taskId)).toBe("pending");
169
183
 
@@ -201,8 +215,15 @@ describe("PR review_request_removed — github.cancelOnReviewRequestRemoved", ()
201
215
  expect(await getTaskStatus(taskId)).toBe("failed");
202
216
  });
203
217
 
204
- test("config = 'false': review removal leaves task untouched", async () => {
205
- await setConfigFlag("github.cancelOnReviewRequestRemoved", "false");
218
+ test.each([
219
+ "false",
220
+ "FALSE",
221
+ "False",
222
+ "0",
223
+ " false ",
224
+ " 0 ",
225
+ ])("config = %j: review removal leaves task untouched", async (value) => {
226
+ await setConfigFlag("github.cancelOnReviewRequestRemoved", value);
206
227
  const taskId = await seedTask(BASE_PR.number, "pr");
207
228
  expect(await getTaskStatus(taskId)).toBe("pending");
208
229
 
@@ -573,9 +573,16 @@ describe("no-op approved reviews", () => {
573
573
  }
574
574
  });
575
575
 
576
- test("GITHUB_SKIP_NOOP_APPROVALS=false: empty-body approval still creates a task", async () => {
576
+ test.each([
577
+ "false",
578
+ "FALSE",
579
+ "False",
580
+ "0",
581
+ " false ",
582
+ " 0 ",
583
+ ])("GITHUB_SKIP_NOOP_APPROVALS=%j: empty-body approval still creates a task", async (value) => {
577
584
  const previous = process.env.GITHUB_SKIP_NOOP_APPROVALS;
578
- process.env.GITHUB_SKIP_NOOP_APPROVALS = "false";
585
+ process.env.GITHUB_SKIP_NOOP_APPROVALS = value;
579
586
  const fetchSpy = mockFetchForApproval({ reviews: [APPROVED_BY_REVIEWER] });
580
587
 
581
588
  try {