@gjermundgaraba/effect-actions 0.11.0 → 0.12.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 (215) hide show
  1. package/README.md +101 -40
  2. package/dist/Action.d.mts +29 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +5 -8
  13. package/dist/ActionToolkit.mjs +3 -3
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +28 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +17 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +206 -426
  46. package/docs/ActionCli-failures.md +102 -0
  47. package/docs/ActionCli.md +206 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +202 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +178 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +100 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +142 -392
  59. package/docs/CONTEXT.md +39 -24
  60. package/docs/README.md +71 -35
  61. package/docs/Testing-failures.md +20 -0
  62. package/docs/Testing.md +186 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +34 -0
  65. package/docs/setup.md +87 -91
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +317 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +75 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +136 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +119 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +30 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +108 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +89 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +91 -0
  183. package/examples/notes-service/src/notes/handlers.ts +33 -0
  184. package/examples/notes-service/src/notes/store.ts +73 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +134 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +23 -5
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
@@ -0,0 +1,435 @@
1
+ import { describe, expect, it } from "@effect/vitest";
2
+ import { Deferred, Effect, Fiber, flow, Layer } from "effect";
3
+ import type { LanguageModel } from "effect/ai";
4
+ import { HttpClient, HttpClientRequest } from "effect/http";
5
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
6
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
8
+ import { Http } from "../../src/Api.js";
9
+ import { AssistantFailed, ConversationNotFound } from "../../src/assistant/Assistant.js";
10
+ import { memory } from "../../src/memory.js";
11
+ import { publicRoutes } from "../../src/routes.js";
12
+ import { down, scripted } from "../support/scripted.js";
13
+
14
+ // The assistant as its callers reach it: over HTTP, a request per turn, on the real routes in
15
+ // memory. Each test gives the routes the model it is about, in place of the demo one: the
16
+ // first `Layer.provide` is what the routes get.
17
+ const served = (model: Layer.Layer<LanguageModel.LanguageModel>) =>
18
+ Effect.provide(Testing.layer(publicRoutes).pipe(Layer.provide(model), Layer.provide(memory)));
19
+
20
+ const signedIn = (token: string) => ({
21
+ transformClient: HttpClient.mapRequest(
22
+ flow(HttpClientRequest.bearerToken(token), HttpClientRequest.setHeader("x-workspace", "acme")),
23
+ ),
24
+ });
25
+
26
+ // Ada may write in acme. Linus, in the same workspace, may only read.
27
+ const asAda = ActionHttp.client(Http, signedIn("ada"));
28
+
29
+ const asLinus = ActionHttp.client(Http, signedIn("linus"));
30
+
31
+ const call = (id: string, name: string, params: Readonly<Record<string, string>>) =>
32
+ ({ type: "tool-call", id, name, params }) as const;
33
+
34
+ const says = (text: string) => [{ type: "text", text }] as const;
35
+
36
+ const approving = (waiting: ReadonlyArray<{ readonly approvalId: string }>) =>
37
+ waiting.map(({ approvalId }) => ({ approvalId, approved: true }));
38
+
39
+ describe("the assistant, behind a route", () => {
40
+ it.effect("reads at once, and writes in the request that carries its member's approval", () =>
41
+ Effect.gen(function* () {
42
+ const ada = yield* asAda;
43
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
44
+
45
+ // One request, two requests to the model: the second lets it read its tool's result.
46
+ const read = yield* ada.askAssistant({ prompt: "What is open?" });
47
+
48
+ expect(read).toMatchObject({
49
+ text: "One issue is open.",
50
+ ran: [{ tool: "listIssues", failed: false }],
51
+ waiting: [],
52
+ });
53
+
54
+ // The same conversation, a request later. The write has not run: the turn ended on it.
55
+ const asked = yield* ada.askAssistant({
56
+ conversation: read.conversation,
57
+ prompt: "Close it.",
58
+ });
59
+
60
+ expect(asked).toMatchObject({
61
+ conversation: read.conversation,
62
+ ran: [],
63
+ waiting: [{ tool: "closeIssue", arguments: JSON.stringify({ id }) }],
64
+ });
65
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "open" });
66
+
67
+ const answered = yield* ada.answerAssistant({
68
+ conversation: asked.conversation,
69
+ answers: approving(asked.waiting),
70
+ });
71
+
72
+ expect(answered).toEqual({
73
+ conversation: asked.conversation,
74
+ text: "Closed.",
75
+ ran: [{ tool: "closeIssue", failed: false }],
76
+ waiting: [],
77
+ });
78
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "closed" });
79
+ }).pipe(
80
+ served(
81
+ scripted(
82
+ [call("1", "listIssues", { status: "open" })],
83
+ says("One issue is open."),
84
+ [call("2", "closeIssue", { id: "ACME-1" })],
85
+ says("Closed."),
86
+ ),
87
+ ),
88
+ ),
89
+ );
90
+
91
+ it.effect("lets only the member who was asked answer, or continue the conversation", () =>
92
+ Effect.gen(function* () {
93
+ const ada = yield* asAda;
94
+ const linus = yield* asLinus;
95
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
96
+ const asked = yield* ada.askAssistant({ prompt: "Close it." });
97
+ const { conversation } = asked;
98
+
99
+ expect(
100
+ yield* Effect.flip(
101
+ linus.answerAssistant({ conversation, answers: approving(asked.waiting) }),
102
+ ),
103
+ ).toEqual(new ConversationNotFound({ conversation }));
104
+ expect(yield* Effect.flip(linus.askAssistant({ conversation, prompt: "Go on." }))).toEqual(
105
+ new ConversationNotFound({ conversation }),
106
+ );
107
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "open" });
108
+ }).pipe(served(scripted([call("1", "closeIssue", { id: "ACME-1" })]))),
109
+ );
110
+
111
+ it.effect("runs an approved call as its caller: approval lets no one do what they may not", () =>
112
+ Effect.gen(function* () {
113
+ const ada = yield* asAda;
114
+ const linus = yield* asLinus;
115
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
116
+
117
+ // Anyone signed in may ask. The tool's own `authorize` then refuses the reader's write,
118
+ // as a result the model reads.
119
+ const asked = yield* linus.askAssistant({ prompt: "Close it." });
120
+
121
+ const answered = yield* linus.answerAssistant({
122
+ conversation: asked.conversation,
123
+ answers: approving(asked.waiting),
124
+ });
125
+
126
+ expect(answered).toMatchObject({
127
+ text: "You may not close issues.",
128
+ ran: [{ tool: "closeIssue", failed: true }],
129
+ });
130
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "open" });
131
+ }).pipe(
132
+ served(
133
+ scripted([call("1", "closeIssue", { id: "ACME-1" })], says("You may not close issues.")),
134
+ ),
135
+ ),
136
+ );
137
+
138
+ it.effect("ends the conversation when the model fails after an approved call ran", () =>
139
+ Effect.gen(function* () {
140
+ const ada = yield* asAda;
141
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
142
+ const asked = yield* ada.askAssistant({ prompt: "Say it is fixed." });
143
+
144
+ const answer = ada.answerAssistant({
145
+ conversation: asked.conversation,
146
+ answers: approving(asked.waiting),
147
+ });
148
+
149
+ // The approved call ran, then the model failed: the error says so.
150
+ expect(yield* Effect.flip(answer)).toBeInstanceOf(AssistantFailed);
151
+ expect((yield* ada.getIssue({ id })).comments).toEqual([{ author: "ada", text: "Fixed." }]);
152
+
153
+ // The same answer, sent again, runs nothing: its conversation is gone.
154
+ expect(yield* Effect.flip(answer)).toEqual(
155
+ new ConversationNotFound({ conversation: asked.conversation }),
156
+ );
157
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(1);
158
+ }).pipe(
159
+ served(scripted([call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." })], down)),
160
+ ),
161
+ );
162
+
163
+ it.effect("runs nothing for a denied call, and lists none as run", () =>
164
+ Effect.gen(function* () {
165
+ const ada = yield* asAda;
166
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
167
+ const asked = yield* ada.askAssistant({ prompt: "Close it." });
168
+
169
+ const denied = yield* ada.answerAssistant({
170
+ conversation: asked.conversation,
171
+ answers: asked.waiting.map(({ approvalId }) => ({ approvalId, approved: false })),
172
+ });
173
+
174
+ expect(denied).toMatchObject({ text: "Left open.", ran: [], waiting: [] });
175
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "open" });
176
+ }).pipe(served(scripted([call("1", "closeIssue", { id: "ACME-1" })], says("Left open.")))),
177
+ );
178
+
179
+ it.effect("lists no call of a reply the provider cut short: none of them ran", () =>
180
+ Effect.gen(function* () {
181
+ const ada = yield* asAda;
182
+
183
+ yield* ada.createIssue({ title: "Crash", body: "On save." });
184
+
185
+ expect(yield* ada.askAssistant({ prompt: "What is open?" })).toMatchObject({
186
+ text: "I was cut off.",
187
+ ran: [],
188
+ waiting: [],
189
+ });
190
+ }).pipe(
191
+ served(
192
+ scripted(
193
+ [
194
+ call("1", "listIssues", { status: "open" }),
195
+ { type: "finish", reason: "length", usage: { inputTokens: {}, outputTokens: {} } },
196
+ ],
197
+ says("I was cut off."),
198
+ ),
199
+ ),
200
+ ),
201
+ );
202
+
203
+ it.effect("runs the one call of two that is approved, and leaves the other open", () =>
204
+ Effect.gen(function* () {
205
+ const ada = yield* asAda;
206
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
207
+ const asked = yield* ada.askAssistant({ prompt: "Say it is fixed, and close it." });
208
+ const [first, second] = approving(asked.waiting);
209
+
210
+ expect(asked.waiting.map(({ tool }) => tool)).toEqual(["commentOnIssue", "closeIssue"]);
211
+
212
+ const answered = yield* ada.answerAssistant({
213
+ conversation: asked.conversation,
214
+ answers: first === undefined ? [] : [first],
215
+ });
216
+
217
+ // The call left open is listed again, in every turn until it is answered.
218
+ expect(answered).toMatchObject({ ran: [{ tool: "commentOnIssue" }] });
219
+ expect(answered.waiting).toEqual([
220
+ { approvalId: second?.approvalId, tool: "closeIssue", arguments: '{"id":"ACME-1"}' },
221
+ ]);
222
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "open" });
223
+
224
+ const closed = yield* ada.answerAssistant({
225
+ conversation: asked.conversation,
226
+ answers: second === undefined ? [] : [second],
227
+ });
228
+
229
+ expect(closed.waiting).toEqual([]);
230
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "closed" });
231
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(1);
232
+ }).pipe(
233
+ served(
234
+ scripted(
235
+ [
236
+ call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." }),
237
+ call("2", "closeIssue", { id: "ACME-1" }),
238
+ ],
239
+ says("Said."),
240
+ says("Closed."),
241
+ ),
242
+ ),
243
+ ),
244
+ );
245
+
246
+ it.effect("keeps listing a request left open while its member asks for something else", () =>
247
+ Effect.gen(function* () {
248
+ const ada = yield* asAda;
249
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
250
+ const first = yield* ada.askAssistant({ prompt: "Say it is fixed." });
251
+
252
+ const second = yield* ada.askAssistant({
253
+ conversation: first.conversation,
254
+ prompt: "Close it too.",
255
+ });
256
+
257
+ expect(second.waiting.map(({ tool }) => tool)).toEqual(["commentOnIssue", "closeIssue"]);
258
+
259
+ const [, close] = approving(second.waiting);
260
+
261
+ const closed = yield* ada.answerAssistant({
262
+ conversation: first.conversation,
263
+ answers: close === undefined ? [] : [close],
264
+ });
265
+
266
+ // Each answer runs its own call: the close ran, the comment still waits.
267
+ expect(closed.ran).toEqual([{ tool: "closeIssue", failed: false }]);
268
+ expect(closed.waiting).toEqual(first.waiting);
269
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "closed", comments: [] });
270
+ }).pipe(
271
+ served(
272
+ scripted(
273
+ [call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." })],
274
+ [call("2", "closeIssue", { id: "ACME-1" })],
275
+ says("Closed."),
276
+ ),
277
+ ),
278
+ ),
279
+ );
280
+
281
+ // A provider gives each call an id of its own. Where a model does not, Effect runs the last
282
+ // call with the id an approval names: the list shows that call, not the one first asked.
283
+ it.effect("lists, for a call id a model used twice, the call an approval would run", () =>
284
+ Effect.gen(function* () {
285
+ const ada = yield* asAda;
286
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
287
+ const first = yield* ada.askAssistant({ prompt: "Say it is fixed." });
288
+
289
+ const second = yield* ada.askAssistant({
290
+ conversation: first.conversation,
291
+ prompt: "Close it too.",
292
+ });
293
+
294
+ expect(second.waiting.map(({ tool }) => tool)).toEqual(["closeIssue", "closeIssue"]);
295
+
296
+ const answered = yield* ada.answerAssistant({
297
+ conversation: first.conversation,
298
+ answers: approving(first.waiting),
299
+ });
300
+
301
+ expect(answered).toMatchObject({ ran: [{ tool: "closeIssue" }], waiting: [] });
302
+ expect(yield* ada.getIssue({ id })).toMatchObject({ status: "closed", comments: [] });
303
+ }).pipe(
304
+ served(
305
+ scripted(
306
+ [call("same", "commentOnIssue", { id: "ACME-1", text: "Fixed." })],
307
+ [call("same", "closeIssue", { id: "ACME-1" })],
308
+ says("Closed."),
309
+ ),
310
+ ),
311
+ ),
312
+ );
313
+
314
+ it.effect("runs what its member approves in each turn of the demo model", () =>
315
+ Effect.gen(function* () {
316
+ const ada = yield* asAda;
317
+
318
+ yield* ada.createIssue({ title: "Crash", body: "On save." });
319
+
320
+ const first = yield* ada.askAssistant({ prompt: "Tidy up." });
321
+
322
+ yield* ada.answerAssistant({
323
+ conversation: first.conversation,
324
+ answers: approving(first.waiting),
325
+ });
326
+
327
+ const again = yield* ada.askAssistant({
328
+ conversation: first.conversation,
329
+ prompt: "Once more.",
330
+ });
331
+
332
+ const answered = yield* ada.answerAssistant({
333
+ conversation: first.conversation,
334
+ answers: approving(again.waiting),
335
+ });
336
+
337
+ expect(again.waiting.map(({ tool }) => tool)).toEqual(["closeIssue"]);
338
+ expect(answered).toMatchObject({ ran: [{ tool: "closeIssue" }], waiting: [] });
339
+ }).pipe(Effect.provide(Testing.layer(publicRoutes).pipe(Layer.provide(memory)))),
340
+ );
341
+
342
+ it.effect("refuses a request answering one approval twice, and runs nothing of it", () =>
343
+ Effect.gen(function* () {
344
+ const ada = yield* asAda;
345
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
346
+ const asked = yield* ada.askAssistant({ prompt: "Say it is fixed." });
347
+ const [answer] = approving(asked.waiting);
348
+
349
+ expect(
350
+ yield* Effect.flip(
351
+ ada.answerAssistant({
352
+ conversation: asked.conversation,
353
+ answers: answer === undefined ? [] : [answer, answer],
354
+ }),
355
+ ),
356
+ ).toBeInstanceOf(Action.InvalidInput);
357
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(0);
358
+
359
+ yield* ada.answerAssistant({
360
+ conversation: asked.conversation,
361
+ answers: approving(asked.waiting),
362
+ });
363
+
364
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(1);
365
+ }).pipe(
366
+ served(
367
+ scripted([call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." })], says("Said.")),
368
+ ),
369
+ ),
370
+ );
371
+
372
+ it.effect("runs an approved call once when its answer arrives twice at once", () =>
373
+ Effect.gen(function* () {
374
+ const ada = yield* asAda;
375
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
376
+ const asked = yield* ada.askAssistant({ prompt: "Say it is fixed." });
377
+
378
+ const answer = Effect.flip(
379
+ ada.answerAssistant({
380
+ conversation: asked.conversation,
381
+ answers: approving(asked.waiting),
382
+ }),
383
+ );
384
+
385
+ // A double click, or a client's retry. One turn runs at a time, and the one that waited
386
+ // finds its conversation ended by the one that failed.
387
+ const refusals = yield* Effect.all([answer, answer], { concurrency: 2 });
388
+
389
+ expect(refusals.map(({ _tag }) => _tag).toSorted()).toEqual([
390
+ "AssistantFailed",
391
+ "ConversationNotFound",
392
+ ]);
393
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(1);
394
+ }).pipe(
395
+ served(scripted([call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." })], down, down)),
396
+ ),
397
+ );
398
+
399
+ it.effect(
400
+ "ends the conversation of a turn that is interrupted after its approved call ran",
401
+ () => {
402
+ // Completed when the model is asked what to say about the approved call's result.
403
+ const thinking = Deferred.makeUnsafe<void>();
404
+
405
+ return Effect.gen(function* () {
406
+ const ada = yield* asAda;
407
+ const { id } = yield* ada.createIssue({ title: "Crash", body: "On save." });
408
+ const asked = yield* ada.askAssistant({ prompt: "Say it is fixed." });
409
+
410
+ const answer = ada.answerAssistant({
411
+ conversation: asked.conversation,
412
+ answers: approving(asked.waiting),
413
+ });
414
+
415
+ // The client goes away while the model is thinking, after the approved call ran.
416
+ const leaving = yield* Effect.forkChild(answer);
417
+
418
+ yield* Deferred.await(thinking);
419
+ yield* Fiber.interrupt(leaving);
420
+
421
+ expect(yield* Effect.flip(answer)).toEqual(
422
+ new ConversationNotFound({ conversation: asked.conversation }),
423
+ );
424
+ expect((yield* ada.getIssue({ id })).comments).toHaveLength(1);
425
+ }).pipe(
426
+ served(
427
+ scripted(
428
+ [call("1", "commentOnIssue", { id: "ACME-1", text: "Fixed." })],
429
+ Effect.andThen(Deferred.succeed(thinking, undefined), Effect.never),
430
+ ),
431
+ ),
432
+ );
433
+ },
434
+ );
435
+ });
@@ -0,0 +1,161 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { describe, expect, it } from "@effect/vitest";
3
+ import { Array, Effect, flow, Layer } from "effect";
4
+ import { HttpClient, HttpClientRequest } from "effect/http";
5
+ import * as Action from "@gjermundgaraba/effect-actions/Action";
6
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
7
+ import * as Testing from "@gjermundgaraba/effect-actions/Testing";
8
+ import { Http } from "../../src/Api.js";
9
+ import { GetAttachment } from "../../src/attachments/Attachments.js";
10
+ import { RateLimited } from "../../src/issues/Issues.js";
11
+ import { memory } from "../../src/memory.js";
12
+ import { publicRoutes } from "../../src/routes.js";
13
+
14
+ const served = Testing.layer(publicRoutes).pipe(Layer.provide(memory));
15
+
16
+ const signedIn = (token: string, workspace: string) =>
17
+ flow(HttpClientRequest.bearerToken(token), HttpClientRequest.setHeader("x-workspace", workspace));
18
+
19
+ const as = (token: string, workspace: string) => ({
20
+ transformClient: HttpClient.mapRequest(signedIn(token, workspace)),
21
+ });
22
+
23
+ // A one-pixel PNG stands in for a screenshot.
24
+ const screenshot = Buffer.from(
25
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
26
+ "base64",
27
+ );
28
+
29
+ const attached = Effect.gen(function* () {
30
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
31
+ const issue = yield* ada.createIssue({ title: "Crash", body: "" });
32
+
33
+ return yield* ada.attachImage({
34
+ issue: issue.id,
35
+ name: "crash.png",
36
+ image: { data: screenshot, mimeType: "image/png" },
37
+ });
38
+ });
39
+
40
+ describe("attachments, over HTTP and MCP", () => {
41
+ it.effect("sends an image as JSON over HTTP, and as an image block from the tool", () =>
42
+ Effect.gen(function* () {
43
+ const attachment = yield* attached;
44
+ const http = yield* ActionHttp.client(Http, as("ada", "acme"));
45
+ const mcp = yield* Testing.mcpClient([GetAttachment], as("ada", "acme"));
46
+
47
+ expect(attachment).toMatchObject({ name: "crash.png", mimeType: "image/png", size: 68 });
48
+
49
+ // Both clients decode the same success, the bytes a `Uint8Array` again.
50
+ const overHttp = yield* http.getAttachment({ id: attachment.id });
51
+ const overMcp = yield* mcp.getAttachment({ id: attachment.id });
52
+
53
+ expect(Buffer.from(overHttp.image.data).equals(screenshot)).toBe(true);
54
+ expect(overMcp).toEqual(overHttp);
55
+
56
+ // As sent: the tool's result holds the rest of the success as JSON, and the image as a
57
+ // content block of its own, which a model receives as an image.
58
+ const call = yield* HttpClient.execute(
59
+ Testing.mcpRequest(
60
+ "tools/call",
61
+ { name: "getAttachment", arguments: { id: attachment.id } },
62
+ { headers: { authorization: "Bearer ada", "x-workspace": "acme" } },
63
+ ),
64
+ );
65
+
66
+ const text = yield* call.text;
67
+
68
+ expect(text).toContain('"type":"image"');
69
+ expect(text).toContain('"mimeType":"image/png"');
70
+ expect(text).toContain(`"structuredContent":{"attachment":{"id":"${attachment.id}"`);
71
+ }).pipe(Effect.provide(served)),
72
+ );
73
+
74
+ it.effect("refuses a file that is no image as invalid input, naming the field", () =>
75
+ Effect.gen(function* () {
76
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
77
+ const issue = yield* ada.createIssue({ title: "Crash", body: "" });
78
+
79
+ const refused = yield* Effect.flip(
80
+ ada.attachImage({
81
+ issue: issue.id,
82
+ name: "notes.txt",
83
+ image: { data: screenshot, mimeType: "text/plain" },
84
+ }),
85
+ );
86
+
87
+ expect(refused).toBeInstanceOf(Action.InvalidInput);
88
+ expect(refused).toMatchObject({ issues: [{ path: ["image", "mimeType"] }] });
89
+ }).pipe(Effect.provide(served)),
90
+ );
91
+
92
+ it.effect("counts an attachment against the issues' write limit", () =>
93
+ Effect.gen(function* () {
94
+ const attachment = yield* attached;
95
+ const ada = yield* ActionHttp.client(Http, as("ada", "acme"));
96
+
97
+ // `attached` spent two writes, an issue and its image. Eight comments spend the rest.
98
+ yield* Effect.forEach(Array.range(1, 8), (n) =>
99
+ ada.commentOnIssue({ id: attachment.issue, text: `Comment ${n}` }),
100
+ );
101
+
102
+ const refused = yield* Effect.flip(
103
+ ada.attachImage({
104
+ issue: attachment.issue,
105
+ name: "again.png",
106
+ image: { data: screenshot, mimeType: "image/png" },
107
+ }),
108
+ );
109
+
110
+ expect(refused).toBeInstanceOf(RateLimited);
111
+ }).pipe(Effect.provide(served)),
112
+ );
113
+
114
+ it.effect("serves the bytes to the web app's cookie, from the host's own route", () =>
115
+ Effect.gen(function* () {
116
+ const attachment = yield* attached;
117
+
118
+ const download = (session: string) =>
119
+ HttpClient.execute(
120
+ HttpClientRequest.get(`/attachments/${attachment.id}`).pipe(
121
+ HttpClientRequest.setHeader("cookie", `tracker_session=${session}`),
122
+ ),
123
+ );
124
+
125
+ const mine = yield* download("ada-at-acme");
126
+ const anotherWorkspace = yield* download("grace-at-globex");
127
+
128
+ expect(mine.status).toBe(200);
129
+ expect(Buffer.from(yield* mine.arrayBuffer).equals(screenshot)).toBe(true);
130
+ // The route states its own caching; everything else authentication covers is `no-store`.
131
+ expect(mine.headers["cache-control"]).toBe("private, max-age=31536000, immutable");
132
+ // An uploaded image opened by its URL is a document on this origin: it gets no scripts.
133
+ expect([
134
+ mine.headers["content-security-policy"],
135
+ mine.headers["x-content-type-options"],
136
+ ]).toEqual(["sandbox", "nosniff"]);
137
+ expect(anotherWorkspace.status).toBe(404);
138
+ }).pipe(Effect.provide(served)),
139
+ );
140
+
141
+ it.effect("refuses, on that route, whom authentication and the route's rule refuse", () =>
142
+ Effect.gen(function* () {
143
+ const attachment = yield* attached;
144
+ const download = HttpClientRequest.get(`/attachments/${attachment.id}`);
145
+ const withCookie = HttpClientRequest.setHeader("cookie", "tracker_session=kay-at-acme");
146
+
147
+ const signedOut = yield* HttpClient.execute(download);
148
+ const withToken = yield* HttpClient.execute(signedIn("ada", "acme")(download));
149
+ const withoutScope = yield* HttpClient.execute(withCookie(download));
150
+
151
+ // A cookie scheme has no challenge. A token is no credential on this route.
152
+ expect([signedOut.status, signedOut.headers["cache-control"]]).toEqual([401, "no-store"]);
153
+ expect(signedOut.headers["www-authenticate"]).toBeUndefined();
154
+ expect(withToken.status).toBe(401);
155
+ // kay is signed in, in the right workspace, without the read scope: the route's own
156
+ // `authorize` refuses.
157
+ expect(withoutScope.status).toBe(403);
158
+ expect(yield* withoutScope.json).toMatchObject({ message: "Requires issues:read." });
159
+ }).pipe(Effect.provide(served)),
160
+ );
161
+ });