@intentius/chant-lexicon-fountain 0.72.2 → 0.72.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.
@@ -1 +1 @@
1
- {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/acp/host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAClF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,uDAAuD;IACvD,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,8CAA8C;IAC9C,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,yCAAyC;AACzC,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACxC,oDAAoD;IACpD,OAAO,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACrE,6FAA6F;IAC7F,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChG,uFAAuF;IACvF,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAED,0EAA0E;AAC1E,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAuCtD"}
1
+ {"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../../src/acp/host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,6BAA6B,CAAC;AAClF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,WAAW,QAAQ;IACvB,uDAAuD;IACvD,MAAM,CAAC,EAAE,QAAQ,CAAC;IAClB,4EAA4E;IAC5E,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,8CAA8C;IAC9C,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,yCAAyC;AACzC,MAAM,WAAW,SAAS;IACxB,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACxC,oDAAoD;IACpD,OAAO,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACrE,6FAA6F;IAC7F,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAChG,uFAAuF;IACvF,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAED,0EAA0E;AAC1E,eAAO,MAAM,YAAY,cAAc,CAAC;AAExC;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAwDtD"}
@@ -1 +1 @@
1
- {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/acp/serve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAQH,wFAAwF;AACxF,wBAAsB,iBAAiB,CAAC,IAAI,GAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa/F"}
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/acp/serve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AASH,wFAAwF;AACxF,wBAAsB,iBAAiB,CAAC,IAAI,GAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAqB/F"}
@@ -22,7 +22,13 @@
22
22
  * `sandbox_mode: "persistent"` because the environment's checkout and tool
23
23
  * cache have to survive a turn ending, `permission_policy: { default:
24
24
  * "auto_allow" }` because there is nobody at the keyboard to answer a
25
- * permission card and chant's own gates are where a human belongs, no `model`
25
+ * permission card. Chant's own gates are still where a human belongs, and
26
+ * that is enforced rather than assumed: a gate resolved from this session
27
+ * would be answering a gate this session produced, which `recordGateApproval`
28
+ * refuses (chant#2384's origin rule, reached through chant#2400). So
29
+ * `auto_allow` decides ordinary tool calls and cannot clear a gate — the
30
+ * resolution has to arrive on a channel this agent is not on, normally
31
+ * `chant approve` at a shell, no `model`
26
32
  * because an `acp` agent's model is whatever the command it launches decides
27
33
  * to use, and no skills because a steward's competence is chant's op
28
34
  * definitions rather than prose.
@@ -1 +1 @@
1
- {"version":3,"file":"steward.d.ts","sourceRoot":"","sources":["../../src/composites/steward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAK5F;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;AAQnE,kFAAkF;AAClF,MAAM,WAAW,kBAAkB;IACjC,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,WAAW,EAAE,YAAY,CAAC,OAAO,WAAW,CAAC,CAAC;IAC9C,8EAA8E;IAC9E,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IACnC;;;;OAIG;IACH,GAAG,EAAE,SAAS,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IAClC,QAAQ,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;IACxC,4DAA4D;IAC5D,SAAS,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,EAAE,CAAC;IAC3C,OAAO,CAAC,EAAE,YAAY,CAAC,OAAO,OAAO,CAAC,CAAC;CACxC;AAED,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,cAAc,CAAC;AAiBnD,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE3D;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AA8BD,wBAAgB,OAAO,CAAC,IAAI,EAAE,WAAW,GAAG,gBAAgB,CAsF3D"}
1
+ {"version":3,"file":"steward.d.ts","sourceRoot":"","sources":["../../src/composites/steward.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8CG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAChE,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAK5F;;;;;;;;;GASG;AACH,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;AAQnE,kFAAkF;AAClF,MAAM,WAAW,kBAAkB;IACjC,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,WAAW,EAAE,YAAY,CAAC,OAAO,WAAW,CAAC,CAAC;IAC9C,8EAA8E;IAC9E,KAAK,CAAC,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IACnC;;;;OAIG;IACH,GAAG,EAAE,SAAS,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,kBAAkB,CAAC;IAC7B,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAC,OAAO,KAAK,CAAC,CAAC;IAClC,QAAQ,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,CAAC;IACxC,4DAA4D;IAC5D,SAAS,EAAE,YAAY,CAAC,OAAO,QAAQ,CAAC,EAAE,CAAC;IAC3C,OAAO,CAAC,EAAE,YAAY,CAAC,OAAO,OAAO,CAAC,CAAC;CACxC;AAED,mEAAmE;AACnE,eAAO,MAAM,uBAAuB,cAAc,CAAC;AAiBnD,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE3D;AAED,iFAAiF;AACjF,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AA8BD,wBAAgB,OAAO,CAAC,IAAI,EAAE,WAAW,GAAG,gBAAgB,CAsF3D"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "600e74f7ef572c9b8baf266c4584eafc4b7d96b9834a9d166a8d419a1a9e3a0b",
4
+ "manifest.json": "22fc085ff69da74c44cb495ae9fdc594f95ec183191e8bef2b9884cfaf7d75da",
5
5
  "meta.json": "ed2c507b18806e3eac6641d9b8fc85cd59401a86ab39ba39405869fab91ba729",
6
6
  "types/index.d.ts": "242196b012731a2fb7b586159ab234bf33f63b1653e49fc625b1cff0b3f7f1f3",
7
7
  "rules/ftn001-no-secret-literals.ts": "a417b194cd7039911cca9a0f14c49b042dfa6b1ee48517f7c2e22b16590a8599",
@@ -22,5 +22,5 @@
22
22
  "skills/chant-fountain-ops.md": "206b9088be0c43807df32de39949888bd28eefd870a191743e051814c94b2d0c",
23
23
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
24
24
  },
25
- "composite": "66890c794d939ed2f94154068924345c167a15a259773d22e71786a7150053e3"
25
+ "composite": "47bc6b0b254f3c5aa1b838d07c086731e1de903f915528ce0090ab9cb6c031fe"
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.72.2",
3
+ "version": "0.72.4",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.16.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.72.2",
3
+ "version": "0.72.4",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.72.2",
53
+ "@intentius/chant": "^0.72.4",
54
54
  "typescript": "^5.9.3",
55
55
  "zod": "^4.3.6"
56
56
  },
@@ -0,0 +1,81 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ /**
6
+ * chant#2400 — an ACP session cannot clear a gate it produced.
7
+ *
8
+ * The `Steward` composite ships `permission_policy: { default: "auto_allow" }`,
9
+ * with a comment explaining that chant's own gates are where a human belongs.
10
+ * On the `--durable-requests` path that was not true. A gate became a
11
+ * `session/request_permission` (`./turn.ts`'s `gatedReply`), `auto_allow`
12
+ * answered it without anyone, and `AcpServer.resume` then wrote a real gate
13
+ * resolution and re-ran the command, which walked through the gate. The
14
+ * composite's doc described a separation its configuration removed, which is
15
+ * how it survived review: a reader finds an argument rather than a smell.
16
+ *
17
+ * chant#2384 did not close it, and the reason is the interesting part.
18
+ * `createChantHost`'s `resolveGate` called `appendGateResolution` DIRECTLY,
19
+ * writing a line that looked like an approval while skipping every check one
20
+ * carries: #2300's plan binding, so the resolution named no plan and
21
+ * authorised whatever the next run produced rather than what anyone read; the
22
+ * standing-pending-fact requirement, so it could answer a gate nothing had
23
+ * reached; and #2384's origin rule, which is the one that bites here.
24
+ *
25
+ * Routed through `recordGateApproval`, the same function `chant approve`
26
+ * calls, the rule applies: the pending fact and the resolution are both
27
+ * authored by the same ACP session, so it refuses. The property is now a
28
+ * consequence of the mechanism rather than of how a composite is configured.
29
+ */
30
+ const HOST = fileURLToPath(new URL("./host.ts", import.meta.url));
31
+ const STEWARD = fileURLToPath(new URL("../composites/steward.ts", import.meta.url));
32
+
33
+ describe("an ACP gate resolution goes through the same door as chant approve (chant#2400)", () => {
34
+ const host = readFileSync(HOST, "utf8");
35
+
36
+ test("resolveGate calls recordGateApproval, not appendGateResolution", () => {
37
+ expect(host).toContain("recordGateApproval");
38
+
39
+ // The direct call is the defect. Writing the ledger line without the
40
+ // checks around it produces a record indistinguishable from an approval,
41
+ // which is worse than failing to write one.
42
+ //
43
+ // Matched on the import rather than the bare name, so the comment above
44
+ // `resolveGate` can go on explaining what it no longer does. A test that
45
+ // fails on its own subject being mentioned teaches people to stop
46
+ // mentioning it.
47
+ const importsLedgerWriter = /import\("@intentius\/chant\/lifecycle\/gate-ledger"\)/.test(host)
48
+ && /\bappendGateResolution\s*[,}]/.test(host);
49
+ expect(
50
+ importsLedgerWriter,
51
+ "resolveGate must not append a resolution directly — that skips the plan binding, the " +
52
+ "standing-pending-fact requirement and the origin rule",
53
+ ).toBe(false);
54
+ });
55
+
56
+ test("it names the ACP channel, so the origin rule has something to compare", () => {
57
+ // Without an origin the rule cannot fire: `sameOriginRefusal` matches
58
+ // positively, so an unlabelled resolution is never refused.
59
+ expect(host).toMatch(/origin:\s*"acp"/);
60
+ });
61
+
62
+ test("a refusal is surfaced rather than swallowed", () => {
63
+ // `recordGateApproval` reports failure by returning `{ ok: false }` and
64
+ // printing. A caller that ignored that would carry on and re-run the
65
+ // command, which is exactly the bypass this closes.
66
+ expect(host).toMatch(/outcome\.ok/);
67
+ expect(host).toContain("chant approve");
68
+ });
69
+
70
+ test("the Steward's doc no longer claims a separation its configuration removes", () => {
71
+ const steward = readFileSync(STEWARD, "utf8");
72
+
73
+ // The configuration is unchanged and deliberately so: `auto_allow` is right
74
+ // for ordinary tool calls on a machine with nobody at the keyboard. What
75
+ // changed is that it can no longer clear a gate, and the doc has to say
76
+ // which of those it means.
77
+ expect(steward).toContain('permission_policy: { default: "auto_allow" }');
78
+ expect(steward).toMatch(/cannot clear a gate/);
79
+ expect(steward).toMatch(/chant#2384|origin rule/);
80
+ });
81
+ });
package/src/acp/host.ts CHANGED
@@ -94,11 +94,28 @@ export function createChantHost(cwd: string): ChantHost {
94
94
  },
95
95
 
96
96
  async resolveGate(op, gate, resolvedBy) {
97
- const { appendGateResolution } = await import("@intentius/chant/lifecycle/gate-ledger");
98
- await appendGateResolution(
99
- { op, gate, resolvedBy, timestamp: new Date().toISOString() },
100
- { cwd },
101
- );
97
+ // chant#2400 — through `recordGateApproval`, the same function `chant
98
+ // approve` calls, rather than straight to `appendGateResolution`.
99
+ //
100
+ // Going direct wrote a line that looked like an approval and was missing
101
+ // every check one carries. It skipped #2300's plan binding, so the
102
+ // resolution named no plan and authorised whatever the next run produced
103
+ // rather than what anyone had read. It skipped the standing-pending-fact
104
+ // requirement, so it could answer a gate nothing had reached. And it
105
+ // skipped chant#2384's origin rule, which is the one that matters here:
106
+ // the pending fact and this resolution are both authored by the same ACP
107
+ // session, so the model that produced the gate was resolving it.
108
+ //
109
+ // With the rule applied, that case now refuses, which is the point. A
110
+ // Steward's gate reaches a person or it does not clear.
111
+ const { recordGateApproval } = await import("@intentius/chant/cli/handlers/operator");
112
+ const outcome = await recordGateApproval(op, gate, { actor: resolvedBy, origin: "acp" });
113
+ if (!outcome.ok) {
114
+ throw new Error(
115
+ `the resolution for gate "${gate}" on "${op}" was refused. A gate reached over ACP cannot ` +
116
+ `also be resolved over ACP — approve it with \`chant approve ${op} ${gate}\` at a shell.`,
117
+ );
118
+ }
102
119
  },
103
120
  };
104
121
  }
package/src/acp/serve.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import { readFileSync } from "node:fs";
13
+ import { setGateOrigin } from "@intentius/chant/lifecycle/gate-origin";
13
14
  import { dirname, join } from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { AcpServer } from "./server";
@@ -17,6 +18,14 @@ import { streamTransport } from "./jsonrpc";
17
18
 
18
19
  /** Serve until the client closes stdin, which is how it says the connection is over. */
19
20
  export async function serveAcpOverStdio(opts: { durableRequests?: boolean } = {}): Promise<void> {
21
+ // chant#2384 — every prompt this session carries is authored by the model,
22
+ // and `approve` is a verb in chant's registry like any other, so nothing in
23
+ // the transport stops `chant run <op>` being followed by
24
+ // `chant approve <op> <gate>` as the next prompt. Declaring the channel is
25
+ // what makes the gate ledger able to tell that apart from a person at a
26
+ // shell; the same-origin rule then refuses it.
27
+ setGateOrigin("acp");
28
+
20
29
  const server = new AcpServer({
21
30
  durableRequests: opts.durableRequests ?? false,
22
31
  version: lexiconVersion(),
@@ -22,7 +22,13 @@
22
22
  * `sandbox_mode: "persistent"` because the environment's checkout and tool
23
23
  * cache have to survive a turn ending, `permission_policy: { default:
24
24
  * "auto_allow" }` because there is nobody at the keyboard to answer a
25
- * permission card and chant's own gates are where a human belongs, no `model`
25
+ * permission card. Chant's own gates are still where a human belongs, and
26
+ * that is enforced rather than assumed: a gate resolved from this session
27
+ * would be answering a gate this session produced, which `recordGateApproval`
28
+ * refuses (chant#2384's origin rule, reached through chant#2400). So
29
+ * `auto_allow` decides ordinary tool calls and cannot clear a gate — the
30
+ * resolution has to arrive on a channel this agent is not on, normally
31
+ * `chant approve` at a shell, no `model`
26
32
  * because an `acp` agent's model is whatever the command it launches decides
27
33
  * to use, and no skills because a steward's competence is chant's op
28
34
  * definitions rather than prose.
@@ -585,11 +585,23 @@ describe("tailConversation", () => {
585
585
  json: { data: { id: "conv-1", status: "running", turn_count: 1 } },
586
586
  },
587
587
  });
588
- // The turn thinks for a while, then finishes: several polls, then the event
589
- // the polls were waiting through.
588
+ // The turn thinks until the poller has had more than one look at it, then
589
+ // finishes: several polls, then the event the polls were waiting through.
590
+ //
591
+ // The wait is on the poll count rather than on a fixed sleep. This used to
592
+ // sleep 25ms and assert that more than one 1ms poll had fit inside it,
593
+ // which is a wall-clock race the full suite loses every so often: a worker
594
+ // stalled past the sleep leaves the first event already due, exactly one
595
+ // poll lands, and `calls.length` is 1. Waiting on the number the assertion
596
+ // is about makes the same claim without timing it. The deadline keeps a
597
+ // genuine regression — a tail that stops polling — a loud failure here
598
+ // instead of a hang.
599
+ const deadline = Date.now() + 5_000;
590
600
  const sse: FountainSse = () => ({
591
601
  async *[Symbol.asyncIterator]() {
592
- await new Promise((resolve) => setTimeout(resolve, 25));
602
+ while (calls.length < 2 && Date.now() < deadline) {
603
+ await new Promise((resolve) => setTimeout(resolve, 1));
604
+ }
593
605
  yield sseEvent("1", { stream: "stdout", blocks: [{ kind: "text", body: JSON.stringify(RECORD) }] });
594
606
  yield sseEvent("2", { stream: "stage", stage: "turn", state: "done" });
595
607
  },