@xandreed/core 0.13.0 → 0.14.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.
Files changed (148) hide show
  1. package/README.md +1 -1
  2. package/dist/conformance.functions.d.ts +34 -0
  3. package/dist/conformance.functions.js +25 -0
  4. package/dist/domain/bash.entity.d.ts +82 -0
  5. package/dist/domain/bash.entity.functions.d.ts +17 -0
  6. package/dist/domain/bash.entity.functions.js +26 -0
  7. package/dist/domain/bash.entity.functions.test.d.ts +1 -0
  8. package/dist/domain/bash.entity.js +30 -0
  9. package/dist/domain/file-system.entity.d.ts +91 -0
  10. package/dist/domain/file-system.entity.functions.d.ts +18 -0
  11. package/dist/domain/file-system.entity.functions.js +30 -0
  12. package/dist/domain/file-system.entity.js +37 -0
  13. package/dist/domain/loop-event.entity.d.ts +9 -0
  14. package/dist/domain/media.entity.d.ts +79 -0
  15. package/dist/domain/media.entity.functions.d.ts +76 -0
  16. package/dist/domain/media.entity.functions.js +110 -0
  17. package/dist/domain/media.entity.js +38 -0
  18. package/dist/domain/media.entity.test.d.ts +1 -0
  19. package/dist/domain/message.entity.d.ts +157 -5
  20. package/dist/domain/message.entity.js +8 -4
  21. package/dist/domain/model-catalog.entity.d.ts +2 -0
  22. package/dist/domain/model-catalog.entity.js +2 -1
  23. package/dist/domain/token-usage.entity.d.ts +45 -1
  24. package/dist/domain/token-usage.entity.functions.d.ts +30 -1
  25. package/dist/domain/token-usage.entity.functions.js +24 -4
  26. package/dist/domain/token-usage.entity.js +19 -2
  27. package/dist/domain/token-usage.entity.test.d.ts +1 -0
  28. package/dist/harness/capability.entity.functions.js +2 -1
  29. package/dist/harness/plugin.adapter.d.ts +3 -0
  30. package/dist/harness/plugin.adapter.js +7 -1
  31. package/dist/harness/skill-files.entity.d.ts +101 -0
  32. package/dist/harness/skill-files.entity.functions.d.ts +58 -0
  33. package/dist/harness/skill-files.entity.functions.js +150 -0
  34. package/dist/harness/skill-files.entity.functions.test.d.ts +1 -0
  35. package/dist/harness/skill-files.entity.js +25 -0
  36. package/dist/index.d.ts +48 -8
  37. package/dist/index.js +67 -6
  38. package/dist/loop/mapping.d.ts +12 -6
  39. package/dist/loop/mapping.js +30 -6
  40. package/dist/loop/stepContext.d.ts +8 -0
  41. package/dist/loop/stepContext.js +1 -1
  42. package/dist/loop/traceContent.d.ts +29 -0
  43. package/dist/loop/traceContent.js +4 -0
  44. package/dist/loop/traceContent.test.d.ts +1 -0
  45. package/dist/memory/memory-log.entity.d.ts +152 -14
  46. package/dist/memory/memory-log.entity.functions.d.ts +35 -5
  47. package/dist/memory/memory-log.entity.functions.js +59 -20
  48. package/dist/memory/memory-log.entity.js +19 -6
  49. package/dist/memory/memory-session.js +10 -6
  50. package/dist/memory/memory.conformance.d.ts +8 -1
  51. package/dist/memory/memory.conformance.js +32 -0
  52. package/dist/models/auth-store.conformance.d.ts +13 -0
  53. package/dist/models/auth-store.conformance.js +73 -0
  54. package/dist/models/auth-store.conformance.test.d.ts +1 -0
  55. package/dist/models/model-catalog.conformance.d.ts +15 -0
  56. package/dist/models/model-catalog.conformance.js +27 -0
  57. package/dist/models/model-catalog.conformance.test.d.ts +1 -0
  58. package/dist/models/settings-store.conformance.d.ts +26 -0
  59. package/dist/models/settings-store.conformance.js +80 -0
  60. package/dist/models/settings-store.conformance.test.d.ts +1 -0
  61. package/dist/ports/activity.port.d.ts +35 -0
  62. package/dist/ports/activity.port.js +7 -0
  63. package/dist/ports/auth-store.port.d.ts +7 -5
  64. package/dist/ports/bash.port.d.ts +22 -0
  65. package/dist/ports/bash.port.js +4 -0
  66. package/dist/ports/capability.port.d.ts +11 -1
  67. package/dist/ports/file-system.port.d.ts +44 -15
  68. package/dist/ports/file-system.port.js +1 -7
  69. package/dist/ports/memory.port.d.ts +13 -1
  70. package/dist/ports/model-catalog.port.d.ts +4 -1
  71. package/dist/ports/run-context.port.d.ts +6 -1
  72. package/dist/ports/session-log.port.d.ts +4 -1
  73. package/dist/ports/sessions.port.d.ts +35 -6
  74. package/dist/ports/settings-store.port.d.ts +11 -7
  75. package/dist/ports/step-loop.port.d.ts +30 -2
  76. package/dist/ports/tasks.port.d.ts +5 -1
  77. package/dist/ports/turn-admission.port.d.ts +8 -2
  78. package/dist/ports/turn-events.port.d.ts +8 -1
  79. package/dist/ports/turn-scope.port.d.ts +19 -1
  80. package/dist/ports/turn.port.d.ts +70 -5
  81. package/dist/ports/utility-llm.port.d.ts +2 -0
  82. package/dist/session/activity.entity.d.ts +42 -0
  83. package/dist/session/activity.entity.functions.d.ts +21 -0
  84. package/dist/session/activity.entity.functions.js +23 -0
  85. package/dist/session/activity.entity.functions.test.d.ts +1 -0
  86. package/dist/session/activity.entity.js +15 -0
  87. package/dist/session/chassis.js +2 -1
  88. package/dist/session/session-event.entity.d.ts +68 -6
  89. package/dist/session/session-event.entity.functions.d.ts +34 -2
  90. package/dist/session/session-event.entity.functions.js +54 -16
  91. package/dist/session/session-event.entity.js +19 -2
  92. package/dist/session/session-log.conformance.d.ts +4 -3
  93. package/dist/session/session-log.conformance.js +16 -14
  94. package/dist/session/session-log.recovery.conformance.d.ts +40 -0
  95. package/dist/session/session-log.recovery.conformance.js +143 -0
  96. package/dist/session/session-log.recovery.conformance.test.d.ts +1 -0
  97. package/dist/session/sessions.conformance.d.ts +79 -0
  98. package/dist/session/sessions.conformance.js +355 -0
  99. package/dist/session/sessions.entity.d.ts +36 -3
  100. package/dist/session/sessions.entity.functions.d.ts +42 -0
  101. package/dist/session/sessions.entity.functions.js +26 -0
  102. package/dist/session/sessions.entity.js +17 -4
  103. package/dist/session/sessions.entity.test.d.ts +1 -0
  104. package/dist/session/tasks.conformance.d.ts +70 -0
  105. package/dist/session/tasks.conformance.js +213 -0
  106. package/dist/session/tasks.conformance.test.d.ts +1 -0
  107. package/dist/turn/model-request.entity.d.ts +4 -0
  108. package/dist/turn/model-request.entity.js +7 -2
  109. package/dist/turn/step-loop.conformance.d.ts +4 -1
  110. package/dist/turn/step-loop.conformance.js +73 -7
  111. package/dist/turn/tool-registry.conformance.d.ts +38 -0
  112. package/dist/turn/tool-registry.conformance.js +206 -0
  113. package/dist/turn/turn-bus.js +4 -3
  114. package/dist/turn/turn-event.entity.d.ts +134 -34
  115. package/dist/turn/turn-event.entity.functions.d.ts +11 -0
  116. package/dist/turn/turn-event.entity.functions.js +9 -1
  117. package/dist/turn/turn-event.entity.js +76 -3
  118. package/dist/turn/turn-event.entity.test.d.ts +1 -0
  119. package/dist/turn/turn-lifecycle.d.ts +46 -8
  120. package/dist/turn/turn-lifecycle.js +33 -3
  121. package/dist/turn/turn-lifecycle.test.d.ts +1 -0
  122. package/dist/turn/turn-run.d.ts +15 -3
  123. package/dist/turn/turn-run.js +37 -9
  124. package/dist/turn/turn.adapter.d.ts +6 -5
  125. package/dist/turn/turn.adapter.js +15 -3
  126. package/dist/turn/turn.conformance.d.ts +11 -2
  127. package/dist/turn/turn.conformance.js +63 -2
  128. package/dist/turn/user-message.entity.d.ts +14 -4
  129. package/dist/turn/user-message.entity.functions.d.ts +21 -0
  130. package/dist/turn/user-message.entity.functions.js +24 -0
  131. package/dist/turn/user-message.entity.js +6 -8
  132. package/dist/turn/user-message.entity.test.d.ts +1 -0
  133. package/dist/util/glob.d.ts +30 -0
  134. package/dist/util/glob.js +91 -0
  135. package/dist/util/glob.test.d.ts +1 -0
  136. package/dist/workspace/bash.conformance.d.ts +37 -0
  137. package/dist/workspace/bash.conformance.js +120 -0
  138. package/dist/workspace/bash.conformance.test.d.ts +1 -0
  139. package/dist/workspace/file-system.conformance.d.ts +38 -0
  140. package/dist/workspace/file-system.conformance.js +254 -0
  141. package/dist/workspace/file-system.conformance.test.d.ts +1 -0
  142. package/dist/workspace/file-system.memory.adapter.d.ts +23 -0
  143. package/dist/workspace/file-system.memory.adapter.js +132 -0
  144. package/package.json +1 -1
  145. package/dist/harness/skill-files.d.ts +0 -17
  146. package/dist/harness/skill-files.js +0 -25
  147. package/dist/ports/shell.port.d.ts +0 -32
  148. package/dist/ports/shell.port.js +0 -16
@@ -0,0 +1,70 @@
1
+ import { Context, Effect, Option, Scope } from "effect";
2
+ import type { ConformanceCheck } from "../conformance.entity.js";
3
+ import type { TaskActivity } from "../ports/activity.port.js";
4
+ import type { Sessions } from "../ports/sessions.port.js";
5
+ import { TaskExecutor, TaskRunner } from "../ports/tasks.port.js";
6
+ import type { Tasks } from "../ports/tasks.port.js";
7
+ type Runner = Context.Service.Shape<typeof TaskRunner>;
8
+ type Executor = Context.Service.Shape<typeof TaskExecutor>;
9
+ /** A task runner as the kit builds one. */
10
+ export interface TaskRunnerFixture {
11
+ readonly runner: Runner;
12
+ /** Its `TaskActivity`, when it provides one: then checked against the work it runs. */
13
+ readonly activity: Option.Option<Context.Service.Shape<typeof TaskActivity>>;
14
+ }
15
+ /**
16
+ * The TaskRunner contract, as checks: `run` returns once the work is handed
17
+ * over, before it ends, and the work runs once; it goes on when the caller
18
+ * that handed it over is interrupted and its scope closed; the deadline it
19
+ * is told is none or not in the past; it runs without the caller's
20
+ * services (a task outlives the turn that started it); with a
21
+ * `TaskActivity`, the work counts from the moment `run` hands it over until
22
+ * it ends, and `idle` waits for all of it; and closing the scope the runner
23
+ * was made in interrupts the work still running (it counts no more), so a
24
+ * task's turn ends interrupted rather than lost.
25
+ *
26
+ * `make` is run afresh for each check, in a scope the kit closes. A runner
27
+ * that hands work to something outside this process's scope (a serverless
28
+ * `waitUntil`) cannot interrupt it: give it `{ interruptsOnClose: false }`
29
+ * to leave that check out. The checks wait up to two seconds on the live
30
+ * clock for work they expect, so run the kit on the live clock.
31
+ */
32
+ export declare const taskRunnerConformance: (make: Effect.Effect<TaskRunnerFixture, unknown, Scope.Scope>, options?: {
33
+ readonly interruptsOnClose?: boolean;
34
+ }) => ReadonlyArray<ConformanceCheck>;
35
+ /** What the kit's executor gives the tasks backend. */
36
+ export interface TasksDependencies {
37
+ readonly runner: Runner;
38
+ readonly executor: Executor;
39
+ }
40
+ /** A tasks backend as the kit builds one: its `Tasks` and the `Sessions` they run over. */
41
+ export interface TasksFixture {
42
+ readonly sessions: Context.Service.Shape<typeof Sessions>;
43
+ readonly tasks: Context.Service.Shape<typeof Tasks>;
44
+ }
45
+ /**
46
+ * The Tasks contract, as checks: a task runs as one turn of a child session
47
+ * (a spawn starts empty; a fork's history is the parent's up to its last
48
+ * closed turn), its result reaches the parent's inbox once and the parent
49
+ * reacts to it once, after the turn in flight; the parent records
50
+ * `task.started` once per task, and starting a task again runs it once; a
51
+ * failed turn's result says why; a cancel stops the child's turn and
52
+ * delivers nothing; a task does not start tasks; and `reconcile` starts a
53
+ * task whose runner never ran it, and delivers, once, a finished result
54
+ * whose delivery was lost. The kit loses one by filling the parent's inbox
55
+ * until `deliver` fails with `InboxFull` before the task ends: that check
56
+ * relies on the backend leaving a result the full inbox refused for a later
57
+ * `reconcile`, and runs only over a `Sessions` whose inbox refuses an item
58
+ * within 256 deliveries (one that takes them all passes it unchecked).
59
+ *
60
+ * `make` builds the backend for each check over the kit's own `TaskRunner`
61
+ * (each piece of work on a fiber the kit waits for, with the services the
62
+ * check had when it built the runner and none of the caller's, as a
63
+ * conforming runner runs it; or dropped, as by a runner that died) and
64
+ * `TaskExecutor` (a child turn answers
65
+ * `found: <instructions>` unless the check says otherwise; a reaction
66
+ * records the turn it ran on). The checks wait up to two seconds on the live
67
+ * clock for background work to end, so run the kit on the live clock.
68
+ */
69
+ export declare const tasksConformance: (make: (dependencies: TasksDependencies) => Effect.Effect<TasksFixture, unknown, Scope.Scope>) => ReadonlyArray<ConformanceCheck>;
70
+ export {};
@@ -0,0 +1,213 @@
1
+ import { Clock, Context, Deferred, Effect, Exit, Fiber, FiberSet, Option, Ref, Scope } from "effect";
2
+ import { check, eventually, expect, fail, freshId, kindsOf, settles } from "../conformance.functions.js";
3
+ import { HarnessError } from "../harness/plugin.entity.js";
4
+ import { TaskExecutor, TaskRunner } from "../ports/tasks.port.js";
5
+ import { UserMessage } from "../turn/user-message.entity.js";
6
+ const WAIT = "2 seconds", within = (id, message) => (effect) => Effect.timeoutOrElse(effect, { duration: WAIT, orElse: () => fail(id, message) }), CallerProbe = Context.Reference("efferent/conformance/TaskRunnerCaller", { defaultValue: () => "none" });
7
+ export const taskRunnerConformance = (make, options = {}) => {
8
+ const scoped = (name, id, body) => check(name, id, () => Effect.scoped(make.pipe(Effect.flatMap(body)))), handOver = (runner, id, work) => runner.run(work).pipe(within(id, `run did not return within ${WAIT} of being called: it must return once the work is handed over, not when the work ends`));
9
+ return [
10
+ scoped("run returns once the work is handed over, before it ends, and the work runs once", "hand-over", ({ runner }) => Effect.gen(function* () {
11
+ const runs = yield* Ref.make(0), release = yield* Deferred.make(), ended = yield* Deferred.make(), handing = yield* Effect.forkChild(runner.run(() => Ref.update(runs, (count) => count + 1).pipe(Effect.andThen(Deferred.await(release)), Effect.andThen(Deferred.succeed(ended, void 0))))), returned = yield* Effect.timeoutOption(Fiber.await(handing), WAIT);
12
+ yield* Deferred.succeed(release, void 0);
13
+ yield* expect("hand-over", Option.isSome(returned), `run did not return within ${WAIT} while its work waited: it runs the work instead of handing it over`);
14
+ yield* expect("hand-over", Option.exists(returned, Exit.isSuccess), "run failed");
15
+ yield* Deferred.await(ended).pipe(within("hand-over", `the work never ended within ${WAIT} of being released`));
16
+ yield* Effect.yieldNow;
17
+ const count = yield* Ref.get(runs);
18
+ yield* expect("hand-over", count === 1, `the work ran ${count} times`);
19
+ })),
20
+ scoped("the work goes on when the caller that handed it over is interrupted and its scope closed", "outlives-caller", ({ runner }) => Effect.gen(function* () {
21
+ const started = yield* Deferred.make(), release = yield* Deferred.make(), ended = yield* Deferred.make(), handed = yield* Deferred.make(), caller = yield* Effect.forkChild(Effect.scoped(runner.run(() => Deferred.succeed(started, void 0).pipe(Effect.andThen(Deferred.await(release)), Effect.andThen(Deferred.succeed(ended, void 0))))).pipe(Effect.andThen(Deferred.succeed(handed, void 0)), Effect.andThen(Effect.never)));
22
+ yield* Deferred.await(started).pipe(within("outlives-caller", `the work did not start within ${WAIT}`));
23
+ yield* Deferred.await(handed).pipe(within("outlives-caller", `run did not return within ${WAIT} while its work waited`));
24
+ yield* Fiber.interrupt(caller);
25
+ yield* Deferred.succeed(release, void 0);
26
+ yield* Deferred.await(ended).pipe(within("outlives-caller", "the work stopped with the caller that handed it over"));
27
+ })),
28
+ scoped("the work is told a deadline that is none or not in the past", "deadline", ({ runner }) => Effect.gen(function* () {
29
+ const told = yield* Deferred.make(), before = yield* Clock.currentTimeMillis;
30
+ yield* handOver(runner, "deadline", (deadline) => Deferred.succeed(told, deadline).pipe(Effect.asVoid));
31
+ const deadline = yield* Deferred.await(told).pipe(within("deadline", `the work did not run within ${WAIT}`));
32
+ yield* expect("deadline", Option.match(deadline, { onNone: () => !0, onSome: (at) => at >= before }), `the work was told a deadline ${Option.getOrNull(deadline)} before the hand-over at ${before}`);
33
+ })),
34
+ scoped("the work runs without the services of the caller that handed it over", "isolation", ({ runner }) => Effect.gen(function* () {
35
+ const seen = yield* Deferred.make();
36
+ yield* handOver(runner, "isolation", () => Effect.service(CallerProbe).pipe(Effect.flatMap((value) => Deferred.succeed(seen, value)), Effect.asVoid)).pipe(Effect.provideService(CallerProbe, "the caller's"));
37
+ const value = yield* Deferred.await(seen).pipe(within("isolation", `the work did not run within ${WAIT}`));
38
+ yield* expect("isolation", value !== "the caller's", "the work ran with the services of the caller that handed it over (the turn that starts a task ends before it)");
39
+ })),
40
+ scoped("TaskActivity counts the work from the moment run hands it over until it ends, and idle waits for all of it", "activity", ({ runner, activity }) => Option.match(activity, {
41
+ onNone: () => Effect.void,
42
+ onSome: (probe) => Effect.gen(function* () {
43
+ yield* expect("activity", (yield* probe.running) === 0, "a new runner counts work running");
44
+ yield* expect("activity", yield* settles(yield* Effect.forkChild(probe.idle)), "idle does not complete with nothing running");
45
+ const releases = [yield* Deferred.make(), yield* Deferred.make()];
46
+ yield* handOver(runner, "activity", () => Deferred.await(releases[0]));
47
+ const afterOne = yield* probe.running;
48
+ yield* expect("activity", afterOne === 1, `right after run returned, ${afterOne} pieces of work count (the work counts from the hand-over)`);
49
+ yield* handOver(runner, "activity", () => Deferred.await(releases[1]));
50
+ yield* expect("activity", (yield* probe.running) === 2, `with two pieces of work handed over, ${yield* probe.running} count`);
51
+ const idle = yield* Effect.forkChild(probe.idle);
52
+ yield* expect("activity", !(yield* settles(idle)), "idle completed while work was running");
53
+ yield* Deferred.succeed(releases[0], void 0);
54
+ const one = yield* eventually(probe.running, (count) => count === 1);
55
+ yield* expect("activity", one === 1, `after one piece of work ended, ${one} count`);
56
+ yield* expect("activity", !(yield* settles(idle)), "idle completed while work was still running");
57
+ yield* Deferred.succeed(releases[1], void 0);
58
+ yield* Fiber.join(idle).pipe(within("activity", `idle did not complete within ${WAIT} of the last work ending`));
59
+ yield* expect("activity", (yield* probe.running) === 0, `after all the work ended, ${yield* probe.running} pieces count`);
60
+ })
61
+ })),
62
+ ...options.interruptsOnClose === !1 ? [] : [
63
+ check("closing the scope the runner was made in interrupts the work still running, which then counts no more", "scope-close", () => Effect.scoped(Effect.gen(function* () {
64
+ const runnerScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)), fixture = yield* make.pipe(Scope.provide(runnerScope)), started = yield* Deferred.make(), interrupted = yield* Deferred.make();
65
+ yield* Effect.forkChild(fixture.runner.run(() => Deferred.succeed(started, void 0).pipe(Effect.andThen(Effect.never), Effect.onInterrupt(() => Deferred.succeed(interrupted, void 0)))));
66
+ yield* Deferred.await(started).pipe(within("scope-close", `the work did not start within ${WAIT}`));
67
+ yield* Scope.close(runnerScope, Exit.void);
68
+ yield* Deferred.await(interrupted).pipe(within("scope-close", `the work was not interrupted within ${WAIT} of the runner's scope closing`));
69
+ yield* Option.match(fixture.activity, {
70
+ onNone: () => Effect.void,
71
+ onSome: (probe) => eventually(probe.running, (count) => count === 0).pipe(Effect.flatMap((count) => expect("scope-close", count === 0, `after the runner's scope closed, ${count} pieces of work count`)))
72
+ });
73
+ })))
74
+ ]
75
+ ];
76
+ };
77
+ const ofKind = (events, kind) => events.filter((event) => event.kind === kind), reasons = (events) => ofKind(events, "turn.ended").map((event) => String(event.data.reason)).join(), FILL = 256, fillInbox = (sessions, parent, n = 0) => n >= FILL ? Effect.succeed(!1) : sessions.deliver(parent, { id: `fill-${n}`, source: { kind: "conformance", id: `fill-${n}` }, content: `filler ${n}` }).pipe(Effect.as(!1), Effect.catchTag("InboxFull", () => Effect.succeed(!0)), Effect.flatMap((full) => full ? Effect.succeed(!0) : fillInbox(sessions, parent, n + 1)));
78
+ export const tasksConformance = (make) => {
79
+ const harness = (owner, childTurn = (_writer, task) => Effect.succeed({ outcome: "completed", reply: Option.some(`found: ${task.instructions}`) })) => Effect.gen(function* () {
80
+ const fibers = yield* FiberSet.make(), built = yield* Effect.context(), dropping = yield* Ref.make(!1), turns = yield* Ref.make([]), reactions = yield* Ref.make([]), runner = TaskRunner.of({
81
+ run: (work) => Ref.get(dropping).pipe(Effect.flatMap((drop) => drop ? Effect.void : FiberSet.run(fibers, work(Option.none()).pipe(Effect.updateContext((_) => built))).pipe(Effect.asVoid)))
82
+ }), react = (writer) => Ref.update(reactions, (all) => [...all, writer.admitted]), executor = TaskExecutor.of({
83
+ turn: (writer, task) => writer.history([]).pipe(Effect.flatMap((history) => Ref.update(turns, (all) => [...all, { admitted: writer.admitted, task, history: kindsOf(history) }])), Effect.andThen(childTurn(writer, task))),
84
+ react
85
+ }), fixture = yield* make({ runner, executor }), parent = yield* fixture.sessions.create({ owner }).pipe(Effect.map((view) => ({ id: view.header.id, owner })));
86
+ return {
87
+ ...fixture,
88
+ parent,
89
+ dropping,
90
+ turns: Ref.get(turns),
91
+ reactions: Ref.get(reactions),
92
+ react,
93
+ idle: (id) => FiberSet.awaitEmpty(fibers).pipe(within(id, `the background work did not end within ${WAIT}`)),
94
+ read: (address) => fixture.sessions.read(address)
95
+ };
96
+ }), scoped = (name, id, body) => check(name, id, (owner) => Effect.scoped(body(owner))), done = { reason: "completed", failure: Option.none() };
97
+ return [
98
+ scoped("a task runs as one turn of a child session, its result reaches the parent's inbox once, and the parent reacts once", "run", (owner) => Effect.gen(function* () {
99
+ const h = yield* harness(owner), view = yield* h.tasks.start(h.parent, { instructions: "look it up", mode: "spawn" });
100
+ yield* expect("run", view.parent === h.parent.id && view.child === view.taskId && view.instructions === "look it up" && view.mode === "spawn" && ["pending", "running", "completed"].includes(view.status), `start returned ${view.status} for ${view.child} under ${view.parent}`);
101
+ yield* h.idle("run");
102
+ const status = yield* h.tasks.status(h.parent, view.taskId);
103
+ yield* expect("run", status.status === "completed" && Option.contains(status.reply, "found: look it up") && status.delivered && Option.isNone(status.failure), `the finished task reads ${status.status}, reply ${Option.getOrNull(status.reply)}, delivered ${status.delivered}`);
104
+ const child = { id: view.child, owner }, lineage = (yield* h.sessions.get(child)).header.parent;
105
+ yield* expect("run", Option.exists(lineage, (at) => at.id === h.parent.id), "the task's session is not a child of the parent");
106
+ const childEvents = yield* h.read(child);
107
+ yield* expect("run", ofKind(childEvents, "turn.started").length === 1 && reasons(childEvents) === "completed", `the child holds ${ofKind(childEvents, "turn.started").length} turns, ended ${reasons(childEvents)}`);
108
+ const ran = yield* h.turns;
109
+ yield* expect("run", ran.length === 1 && ran[0].admitted.session.id === view.child && ran[0].task.taskId === view.taskId && ran[0].task.instructions === "look it up" && ran[0].history === "", `the executor ran ${ran.length} child turns, the first with history [${ran[0]?.history ?? ""}]`);
110
+ const reacted = yield* h.reactions;
111
+ yield* expect("run", reacted.length === 1 && reacted[0].origin === "inbox" && reacted[0].session.id === h.parent.id && reacted[0].claimed.length === 1 && reacted[0].claimed[0].content.includes("found: look it up"), `the parent reacted ${reacted.length} times${reacted.length > 0 ? `, to ${reacted[0].claimed.length} items` : ""}`);
112
+ const parentEvents = yield* h.read(h.parent), started = ofKind(parentEvents, "task.started");
113
+ yield* expect("run", started.length === 1 && started[0].data.taskId === view.taskId, `the parent records ${started.length} task.started`);
114
+ yield* expect("run", ofKind(parentEvents, "inbox.queued").length === 1, `the parent's inbox took ${ofKind(parentEvents, "inbox.queued").length} deliveries`);
115
+ const listed = (yield* h.tasks.list(h.parent)).map((task) => task.taskId).join();
116
+ yield* expect("run", listed === view.taskId, `the parent lists [${listed}]`);
117
+ })),
118
+ scoped("starting a task again (by its id, while it runs or after) runs it once", "start-once", (owner) => Effect.gen(function* () {
119
+ const h = yield* harness(owner), taskId = yield* freshId, input = { taskId, instructions: "once", mode: "spawn" }, first = yield* h.tasks.start(h.parent, input), again = yield* h.tasks.start(h.parent, input);
120
+ yield* h.idle("start-once");
121
+ const after = yield* h.tasks.start(h.parent, input);
122
+ yield* h.idle("start-once");
123
+ yield* expect("start-once", [first, again, after].every((view) => view.taskId === taskId), "a start of the same task returned another task");
124
+ const counts = {
125
+ listed: (yield* h.tasks.list(h.parent)).length,
126
+ turns: (yield* h.turns).length,
127
+ reactions: (yield* h.reactions).length,
128
+ started: ofKind(yield* h.read(h.parent), "task.started").length,
129
+ delivered: ofKind(yield* h.read(h.parent), "inbox.queued").length
130
+ };
131
+ yield* expect("start-once", Object.values(counts).every((count) => count === 1), `three starts of one task gave ${JSON.stringify(counts)}`);
132
+ })),
133
+ scoped("a result that arrives during the parent's turn waits for it, and a fork's history is the parent's up to its last closed turn", "after-turn", (owner) => Effect.gen(function* () {
134
+ const h = yield* harness(owner), say = (text) => ({ _tag: "User", userMessage: new UserMessage({ text }), runId: `run-${text}`, key: text, command: {} });
135
+ yield* Effect.scoped(h.sessions.begin(h.parent, say("one")).pipe(Effect.tap((writer) => writer.end(done))));
136
+ const open = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void)), writer = yield* h.sessions.begin(h.parent, say("two")).pipe(Scope.provide(open)), view = yield* h.tasks.start(h.parent, { instructions: "compare", mode: "fork" });
137
+ yield* h.idle("after-turn");
138
+ const status = yield* h.tasks.status(h.parent, view.taskId);
139
+ yield* expect("after-turn", status.status === "completed" && status.delivered, `the task finished as ${status.status}, delivered ${status.delivered}`);
140
+ yield* expect("after-turn", (yield* h.reactions).length === 0, "the parent reacted while its turn was open");
141
+ const ended = yield* writer.end(done);
142
+ yield* expect("after-turn", ended.pending === 1, `the parent's turn ended with ${ended.pending} items waiting`);
143
+ yield* h.sessions.drain(h.parent, h.react);
144
+ yield* expect("after-turn", (yield* h.reactions).length === 1, `after the parent's turn the drain reacted ${(yield* h.reactions).length} times`);
145
+ const history = (yield* h.turns)[0]?.history ?? "none";
146
+ yield* expect("after-turn", history.startsWith("turn.started@1,") && history.endsWith("turn.ended@1") && !history.includes("@2"), `the fork's child turn saw the history [${history}]`);
147
+ })),
148
+ scoped("a failed child turn is a failed task that says why, and the parent still hears of it", "failed", (owner) => Effect.gen(function* () {
149
+ const h = yield* harness(owner, () => Effect.fail(new HarnessError({ code: "conformance.down", message: "the model is down" }))), view = yield* h.tasks.start(h.parent, { instructions: "doomed", mode: "spawn" });
150
+ yield* h.idle("failed");
151
+ const status = yield* h.tasks.status(h.parent, view.taskId);
152
+ yield* expect("failed", status.status === "failed" && Option.exists(status.failure, (failure) => failure.code === "conformance.down") && status.delivered, `the task reads ${status.status}, failure ${JSON.stringify(Option.getOrNull(status.failure))}, delivered ${status.delivered}`);
153
+ yield* expect("failed", reasons(yield* h.read({ id: view.child, owner })) === "failed", "the child's turn did not end failed");
154
+ yield* expect("failed", (yield* h.reactions).length === 1, `the parent reacted ${(yield* h.reactions).length} times`);
155
+ })),
156
+ scoped("a cancel stops the child's turn and delivers nothing; a finished or unknown task is not cancelled", "cancel", (owner) => Effect.gen(function* () {
157
+ const entered = yield* Deferred.make(), h = yield* harness(owner, () => Deferred.succeed(entered, void 0).pipe(Effect.andThen(Effect.never))), view = yield* h.tasks.start(h.parent, { instructions: "forever", mode: "spawn" });
158
+ yield* Deferred.await(entered).pipe(within("cancel", `the child's turn did not start within ${WAIT}`));
159
+ const running = yield* h.tasks.status(h.parent, view.taskId);
160
+ yield* expect("cancel", running.status === "running", `a task in its turn reads ${running.status}`);
161
+ yield* expect("cancel", yield* h.tasks.cancel(h.parent, view.taskId), "cancelling a running task answered false");
162
+ yield* h.idle("cancel");
163
+ const cancelled = yield* h.tasks.status(h.parent, view.taskId);
164
+ yield* expect("cancel", cancelled.status === "cancelled" && !cancelled.delivered, `the cancelled task reads ${cancelled.status}, delivered ${cancelled.delivered}`);
165
+ yield* expect("cancel", reasons(yield* h.read({ id: view.child, owner })) === "cancelled", "the child's turn did not end cancelled");
166
+ yield* expect("cancel", (yield* h.reactions).length === 0 && ofKind(yield* h.read(h.parent), "inbox.queued").length === 0, "the cancelled task delivered something");
167
+ yield* expect("cancel", !(yield* h.tasks.cancel(h.parent, view.taskId)), "cancelling a cancelled task answered true");
168
+ const unknown = [yield* Effect.flip(h.tasks.cancel(h.parent, "no-such-task")), yield* Effect.flip(h.tasks.status(h.parent, "no-such-task"))];
169
+ yield* expect("cancel", unknown.every((error) => error._tag === "TaskMissing"), `an unknown task gave ${unknown.map((error) => error._tag).join()}`);
170
+ })),
171
+ scoped("a task does not start tasks", "depth", (owner) => Effect.gen(function* () {
172
+ const h = yield* harness(owner), view = yield* h.tasks.start(h.parent, { instructions: "first", mode: "spawn" });
173
+ yield* h.idle("depth");
174
+ const nested = yield* Effect.flip(h.tasks.start({ id: view.child, owner }, { instructions: "deeper", mode: "spawn" }));
175
+ yield* expect("depth", nested._tag === "TaskRefused" && nested.reason === "depth", `a task's session starting a task gave ${nested._tag}`);
176
+ })),
177
+ scoped("reconcile starts a task its runner never ran, which then delivers; done, it finds nothing more", "reconcile", (owner) => Effect.gen(function* () {
178
+ const h = yield* harness(owner);
179
+ yield* Ref.set(h.dropping, !0);
180
+ const view = yield* h.tasks.start(h.parent, { instructions: "never ran", mode: "spawn" }), lost = yield* h.tasks.status(h.parent, view.taskId);
181
+ yield* expect("reconcile", lost.status === "pending", `a task whose runner dropped it reads ${lost.status}`);
182
+ yield* Ref.set(h.dropping, !1);
183
+ const first = yield* h.tasks.reconcile(h.parent);
184
+ yield* expect("reconcile", first.started === 1 && first.delivered === 0, `reconcile answered ${JSON.stringify(first)}`);
185
+ yield* h.idle("reconcile");
186
+ const status = yield* h.tasks.status(h.parent, view.taskId);
187
+ yield* expect("reconcile", status.status === "completed" && status.delivered && (yield* h.reactions).length === 1, `the reconciled task reads ${status.status}, delivered ${status.delivered}`);
188
+ const second = yield* h.tasks.reconcile(h.parent);
189
+ yield* h.idle("reconcile");
190
+ yield* expect("reconcile", second.started === 0 && second.delivered === 0 && (yield* h.turns).length === 1, `reconciling again answered ${JSON.stringify(second)}`);
191
+ })),
192
+ scoped("reconcile delivers a finished result whose delivery was lost (the parent's inbox was full), once", "reconcile-delivery", (owner) => Effect.gen(function* () {
193
+ const h = yield* harness(owner);
194
+ if (!(yield* fillInbox(h.sessions, h.parent)))
195
+ return;
196
+ const view = yield* h.tasks.start(h.parent, { instructions: "lost on the way", mode: "spawn" });
197
+ yield* h.idle("reconcile-delivery");
198
+ const lost = yield* h.tasks.status(h.parent, view.taskId);
199
+ yield* expect("reconcile-delivery", lost.status === "completed" && !lost.delivered, `a task whose result the parent's full inbox refused reads ${lost.status}, delivered ${lost.delivered}`);
200
+ yield* h.sessions.drain(h.parent, h.react, { maxTurns: FILL + 1 });
201
+ yield* expect("reconcile-delivery", (yield* h.sessions.get(h.parent)).pending === 0, "draining the parent left items waiting");
202
+ const first = yield* h.tasks.reconcile(h.parent);
203
+ yield* expect("reconcile-delivery", first.delivered === 1 && first.started === 0, `reconcile answered ${JSON.stringify(first)}`);
204
+ yield* h.idle("reconcile-delivery");
205
+ const before = (yield* h.reactions).length;
206
+ yield* h.sessions.drain(h.parent, h.react);
207
+ const status = yield* h.tasks.status(h.parent, view.taskId), reacted = yield* h.reactions;
208
+ yield* expect("reconcile-delivery", status.delivered && reacted.length === before + 1 && (reacted.at(-1)?.claimed.some((claimed) => claimed.content.includes("found: lost on the way")) ?? !1), `after reconcile the task reads delivered ${status.delivered}, and the parent reacted ${reacted.length - before} times to it`);
209
+ const second = yield* h.tasks.reconcile(h.parent);
210
+ yield* expect("reconcile-delivery", second.started === 0 && second.delivered === 0 && (yield* h.turns).length === 1, `reconciling again answered ${JSON.stringify(second)}`);
211
+ }))
212
+ ];
213
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -37,6 +37,8 @@ export declare const MemoryRenderRecipe: Schema.Struct<{
37
37
  readonly media: Schema.Struct<{
38
38
  readonly mode: Schema.Literals<readonly ["none", "inline"]>;
39
39
  readonly maxImages: Schema.Int;
40
+ /** Base64 characters of the images sent as images; a header written before 0.14.0 reads `MEDIA_LIMITS.requestChars`. */
41
+ readonly maxChars: Schema.withDecodingDefaultKey<Schema.Int, never>;
40
42
  }>;
41
43
  }>;
42
44
  export type MemoryRenderRecipe = typeof MemoryRenderRecipe.Type;
@@ -67,6 +69,8 @@ export declare const ModelRequestHeader: Schema.Struct<{
67
69
  readonly media: Schema.Struct<{
68
70
  readonly mode: Schema.Literals<readonly ["none", "inline"]>;
69
71
  readonly maxImages: Schema.Int;
72
+ /** Base64 characters of the images sent as images; a header written before 0.14.0 reads `MEDIA_LIMITS.requestChars`. */
73
+ readonly maxChars: Schema.withDecodingDefaultKey<Schema.Int, never>;
70
74
  }>;
71
75
  }>;
72
76
  /** The last memory entry the build included (its cut); none for an empty log. */
@@ -1,4 +1,5 @@
1
- import { Schema } from "effect";
1
+ import { Effect, Schema } from "effect";
2
+ import { MEDIA_LIMITS } from "../domain/media.entity.js";
2
3
  import { ModelCallPolicy } from "../domain/model-call-policy.entity.js";
3
4
  import { EntryId } from "../memory/memory-log.entity.js";
4
5
  export const ModelRequestDescriptor = Schema.Struct({
@@ -23,7 +24,11 @@ export const ModelRequestDescriptor = Schema.Struct({
23
24
  replies: Schema.Boolean,
24
25
  stepContext: Schema.Literals(["tail", "none"]),
25
26
  digests: Schema.Boolean,
26
- media: Schema.Struct({ mode: Schema.Literals(["none", "inline"]), maxImages: Schema.Int })
27
+ media: Schema.Struct({
28
+ mode: Schema.Literals(["none", "inline"]),
29
+ maxImages: Schema.Int,
30
+ maxChars: Schema.Int.pipe(Schema.withDecodingDefaultKey(Effect.succeed(MEDIA_LIMITS.requestChars)))
31
+ })
27
32
  }), ModelRequestHeader = Schema.Struct({
28
33
  version: Schema.Literal(1),
29
34
  runId: Schema.String,
@@ -10,7 +10,10 @@ export declare const textReply: (text: string) => ReadonlyArray<Part>;
10
10
  * The `StepLoop` contract, as checks any implementation must pass:
11
11
  * per-step event order, recorded results on `step.ended`, planned batches
12
12
  * without a provider call, forced tool choices, awaiting verdicts, no call
13
- * after completion, and the step cap.
13
+ * after completion, the run's usage, the step cap, the request's
14
+ * `captureTraceContent` provided to its model calls as `CurrentTraceContent`,
15
+ * and a request refused over an image planned again and sent once more
16
+ * when `onMediaRejected` answers true, failing the step when it answers false.
14
17
  */
15
18
  export declare const stepLoopConformance: (loop: Context.Service.Shape<typeof StepLoop>) => ReadonlyArray<ConformanceCheck>;
16
19
  export {};
@@ -1,9 +1,12 @@
1
- import { LanguageModel, Tool, Toolkit } from "effect/ai";
1
+ import { AiError, LanguageModel, Tool, Toolkit } from "effect/ai";
2
2
  import { Context, Effect, Option, Ref, Schema, Stream } from "effect";
3
3
  import { ConformanceFailure } from "../conformance.entity.js";
4
+ import { sameUsage } from "../conformance.functions.js";
4
5
  import { Failure } from "../domain/failure.entity.js";
5
6
  import { HarnessError } from "../harness/plugin.entity.js";
7
+ import { sumUsage } from "../domain/token-usage.entity.functions.js";
6
8
  import { CurrentAgentStep } from "../loop/stepContext.js";
9
+ import { CurrentTraceContent } from "../loop/traceContent.js";
7
10
  import { entryId } from "../memory/memory-log.entity.functions.js";
8
11
  import { makeTurnEvents, makeTurnTasks } from "./turn-bus.js";
9
12
  const Probe = Tool.make("probe", {
@@ -17,11 +20,18 @@ export const probeCall = (id, n) => [
17
20
  { type: "tool-call", id, name: Probe.name, params: { n }, providerExecuted: !1 },
18
21
  { type: "finish", reason: "tool-calls", usage }
19
22
  ], textReply = (text) => [{ type: "text", text }, { type: "finish", reason: "stop", usage }];
20
- const incomplete = { complete: !1, awaiting: [], facts: {} }, observe = (loop, scenario) => Effect.scoped(Effect.gen(function* () {
23
+ const billed = (input, output) => ({
24
+ inputTokens: { total: input, uncached: input - 30, cacheRead: 20, cacheWrite: 10 },
25
+ outputTokens: { total: output, text: output - 3, reasoning: 3 }
26
+ }), withUsage = (reply, value) => reply.map((part) => part.type === "finish" ? { ...part, usage: value } : part), imageRefusal = AiError.make({
27
+ module: "Conformance",
28
+ method: "generateText",
29
+ reason: new AiError.InvalidRequestError({ description: "messages.0.content.0.image: the image could not be processed" })
30
+ }), incomplete = { complete: !1, awaiting: [], facts: {} }, observe = (loop, scenario) => Effect.scoped(Effect.gen(function* () {
21
31
  const scope = yield* Effect.scope, events = yield* makeTurnEvents({ maxDepth: 8 }), tasks = yield* makeTurnTasks(scope), seen = yield* Ref.make([]);
22
32
  yield* events.subscribe(Option.some, (event) => Ref.update(seen, (all) => [...all, event]));
23
- const calls = yield* Ref.make([]), dispatches = yield* Ref.make([]), model = yield* LanguageModel.make({
24
- generateText: (options) => Ref.modify(calls, (all) => [all.length, [...all, options.toolChoice]]).pipe(Effect.tap(() => scenario.providerCall ?? Effect.void), Effect.map((index) => [...scenario.script[index] ?? textReply("done")])),
33
+ const calls = yield* Ref.make([]), dispatches = yield* Ref.make([]), traceContent = yield* Ref.make([]), model = yield* LanguageModel.make({
34
+ generateText: (options) => Ref.modify(calls, (all) => [all.length, [...all, options.toolChoice]]).pipe(Effect.tap(() => scenario.providerCall ?? Effect.void), Effect.tap(() => Effect.service(CurrentTraceContent).pipe(Effect.flatMap((capture) => Ref.update(traceContent, (all) => [...all, capture])))), Effect.flatMap((index) => scenario.refuseFirst === !0 && index === 0 ? Effect.fail(imageRefusal) : Effect.succeed([...scenario.script[index] ?? textReply("done")]))),
25
35
  streamText: () => Stream.die("the conformance provider does not stream")
26
36
  }), invocations = yield* Ref.make(0), handlers = yield* typed.toHandlers({
27
37
  probe: ({ n }) => Effect.gen(function* () {
@@ -66,17 +76,31 @@ const incomplete = { complete: !1, awaiting: [], facts: {} }, observe = (loop, s
66
76
  handlers: Context.merge(handlers, Context.make(LanguageModel.LanguageModel, model)),
67
77
  limits: { maxSteps: scenario.maxSteps ?? 6, toolConcurrency: 1, streaming: !1, requireCompletion: !1 },
68
78
  initial: Option.fromNullishOr(scenario.initial),
69
- plan: () => Effect.succeed({ model: Option.none(), system: "conformance", messages: [{ role: "user", content: "go" }], toolChoice: Option.fromNullishOr(scenario.toolChoice) }),
79
+ plan: (info) => (scenario.planned?.(info.stepIndex) ?? Effect.void).pipe(Effect.as({
80
+ model: Option.none(),
81
+ system: "conformance",
82
+ messages: [{ role: "user", content: "go" }],
83
+ toolChoice: Option.fromNullishOr(scenario.toolChoice)
84
+ })),
70
85
  dispatch: (step) => Ref.update(dispatches, (all) => [...all, step]).pipe(Effect.andThen(scenario.dispatch?.(step) ?? Effect.void)),
71
86
  record,
87
+ onMediaRejected: scenario.onMediaRejected ?? (() => Effect.succeed(!1)),
72
88
  completion: (info) => scenario.completion?.(info.stepIndex, tasks) ?? Effect.succeed(incomplete),
73
89
  steering: Effect.succeed(Option.none()),
74
90
  correctives: Option.none(),
75
91
  events,
76
92
  tasks,
77
- cacheKey: Option.none()
93
+ cacheKey: Option.none(),
94
+ captureTraceContent: scenario.captureTraceContent ?? !1
95
+ };
96
+ return {
97
+ result: yield* loop.run(request).pipe(Effect.provideService(CurrentTraceContent, scenario.ambientTraceContent ?? !1)),
98
+ events: yield* Ref.get(seen),
99
+ providerCalls: yield* Ref.get(calls),
100
+ recorded: yield* Ref.get(recorded),
101
+ dispatches: yield* Ref.get(dispatches),
102
+ traceContent: yield* Ref.get(traceContent)
78
103
  };
79
- return { result: yield* loop.run(request), events: yield* Ref.get(seen), providerCalls: yield* Ref.get(calls), recorded: yield* Ref.get(recorded), dispatches: yield* Ref.get(dispatches) };
80
104
  }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "run", message: error.message })))), expect = (check, holds, message) => holds ? Effect.void : Effect.fail(new ConformanceFailure({ check, message })), stepOf = (event) => ("step" in event) ? Option.some(event.step) : Option.none(), indexOf = (events, tag, step) => events.findIndex((event) => event._tag === tag && Option.contains(stepOf(event), step));
81
105
  export const stepLoopConformance = (loop) => [
82
106
  {
@@ -154,6 +178,48 @@ export const stepLoopConformance = (loop) => [
154
178
  yield* expect("complete", providerCalls.length === 1 && result.outcome === "completed", `${providerCalls.length} provider calls, ${result.outcome}`);
155
179
  })
156
180
  },
181
+ {
182
+ name: "RunResult.usage is the sum of the run's assistant.message usage, cache writes and reasoning included",
183
+ run: Effect.gen(function* () {
184
+ const { events, result } = yield* observe(loop, { script: [withUsage(probeCall("c1", 1), billed(100, 10)), withUsage(textReply("done"), billed(140, 20))] }), published = events.flatMap((event) => event._tag === "assistant.message" ? [event.usage] : []), total = sumUsage(published);
185
+ yield* expect("usage", published.length === 2 && sameUsage(result.usage, total), `the run reported ${JSON.stringify(result.usage)}, its ${published.length} assistant messages ${JSON.stringify(total)}`);
186
+ yield* expect("usage", total.inputTokens === 240 && total.cacheReadTokens === 40 && total.cacheWriteTokens === 20 && total.outputTokens === 30 && total.reasoningTokens === 6 && total.totalTokens === 270, `the assistant messages did not carry the provider's usage: ${JSON.stringify(total)}`);
187
+ })
188
+ },
189
+ {
190
+ name: "provides the request's captureTraceContent to its model calls as CurrentTraceContent, whatever the caller's",
191
+ run: Effect.gen(function* () {
192
+ const script = [probeCall("c1", 1), textReply("done")], on = yield* observe(loop, { script, captureTraceContent: !0, ambientTraceContent: !1 }), off = yield* observe(loop, { script, captureTraceContent: !1, ambientTraceContent: !0 });
193
+ yield* expect("trace-content", on.traceContent.length === 2 && on.traceContent.every((capture) => capture), `with captureTraceContent on under a caller's off, the model calls read ${JSON.stringify(on.traceContent)}`);
194
+ yield* expect("trace-content", off.traceContent.length === 2 && off.traceContent.every((capture) => !capture), `with captureTraceContent off under a caller's on, the model calls read ${JSON.stringify(off.traceContent)}`);
195
+ })
196
+ },
197
+ {
198
+ name: "a request refused over an image is planned again and sent once more when onMediaRejected answers true",
199
+ run: Effect.gen(function* () {
200
+ const reasons = yield* Ref.make([]), plans = yield* Ref.make([]), { dispatches, providerCalls, result } = yield* observe(loop, {
201
+ script: [],
202
+ refuseFirst: !0,
203
+ onMediaRejected: (reason) => Ref.update(reasons, (all) => [...all, reason]).pipe(Effect.as(!0)),
204
+ planned: (step) => Ref.update(plans, (all) => [...all, step])
205
+ }), asked = yield* Ref.get(reasons), planned = (yield* Ref.get(plans)).filter((step) => step === 0).length;
206
+ yield* expect("media-rejected", asked.length === 1 && asked.every((reason) => /image/i.test(reason)), `onMediaRejected was asked ${JSON.stringify(asked)}`);
207
+ yield* expect("media-rejected", planned === 2, `step 0 was planned ${planned} times, not twice`);
208
+ yield* expect("media-rejected", dispatches.join() === "0,0" && providerCalls.length === 2 && result.outcome === "completed", `dispatches ${dispatches.join()}, ${providerCalls.length} provider calls, ${result.outcome}`);
209
+ })
210
+ },
211
+ {
212
+ name: "a request refused over an image fails the step when onMediaRejected answers false",
213
+ run: Effect.gen(function* () {
214
+ const asked = yield* Ref.make(0), calls = yield* Ref.make(0), result = yield* Effect.result(observe(loop, {
215
+ script: [],
216
+ refuseFirst: !0,
217
+ providerCall: Ref.update(calls, (count) => count + 1),
218
+ onMediaRejected: () => Ref.update(asked, (count) => count + 1).pipe(Effect.as(!1))
219
+ })), [rejections, sent] = [yield* Ref.get(asked), yield* Ref.get(calls)];
220
+ yield* expect("media-rejected", result._tag === "Failure" && rejections === 1 && sent === 1, `${result._tag} after ${sent} provider calls, onMediaRejected asked ${rejections} times`);
221
+ })
222
+ },
157
223
  {
158
224
  name: "the step cap ends the run as partial",
159
225
  run: Effect.gen(function* () {
@@ -0,0 +1,38 @@
1
+ import { Context, Effect } from "effect";
2
+ import type { Scope } from "effect";
3
+ import type { ConformanceCheck } from "../conformance.entity.js";
4
+ import type { Capability } from "../ports/capability.port.js";
5
+ import { ToolRegistry } from "../ports/tool-registry.port.js";
6
+ type Registry = Context.Service.Shape<typeof ToolRegistry>;
7
+ /** What the kit asks of the registry it builds: the most calls a turn may make. */
8
+ export interface ToolRegistryLimits {
9
+ readonly maxCallsPerRun: number;
10
+ }
11
+ /**
12
+ * The ToolRegistry contract, as checks: the catalog and the run's tools
13
+ * hold every tool and skill the capabilities define; always-on skills are
14
+ * active once the matcher's choice is applied, other skills only once
15
+ * activated, an unknown skill is refused (`skills.unknown`), a skill whose
16
+ * permission is not granted is not activated, and the active set only grows,
17
+ * publishing `skills.activated` for what is new; a handled call publishes
18
+ * `tool.started`, its handler's `tool.progress` and `tool.completed` under
19
+ * one invocation id (`<runId>:call:<n>`), and the handler sees that call
20
+ * as `CurrentToolInvocation` and the services where the tools were opened;
21
+ * and a call refused before its handler runs (`inactive`, `forbidden`,
22
+ * `denied`, `call-limit`, `tool-call-limit`) takes its invocation id first,
23
+ * publishes one `tool.refused` with its input and no `tool.started`, never
24
+ * runs the handler, and fails with the refusal's message as its result.
25
+ * The registry publishes these events on the run's bus (`RunContext.events`)
26
+ * only, and never writes the journal itself: the turn journals them (core's
27
+ * `TurnLive` keeps `tool.progress` out and stores a `tool.refused` without
28
+ * its input). The kit reads the journal to catch a registry that writes it
29
+ * directly; what the turn does with the events is `TurnLive`'s, not the
30
+ * registry's.
31
+ *
32
+ * `make` builds a registry over the kit's capabilities for each check, with
33
+ * at most `maxCallsPerRun` calls per turn and no permissions granted unless
34
+ * the turn's `PermissionGrants` say so. The kit runs each turn over core's
35
+ * `TurnLive` with a writer that keeps the turn's events in memory.
36
+ */
37
+ export declare const toolRegistryConformance: (make: (capabilities: ReadonlyArray<Capability>, limits: ToolRegistryLimits) => Effect.Effect<Registry, unknown, Scope.Scope>) => ReadonlyArray<ConformanceCheck>;
38
+ export {};