@app-vitals/agent-runtime 0.0.1 → 0.1.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 (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +38 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,500 @@
1
+ /**
2
+ * agent/src/runtime/http-chat-service-client.unit.test.ts
3
+ *
4
+ * Unit tests for HttpChatServiceClient.
5
+ * Uses injected fake fetch doubles — no global.fetch override, no mock.module().
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ ChatServiceClientError,
11
+ HttpChatServiceClient,
12
+ } from "./http-chat-service-client.ts";
13
+
14
+ // ─── Constants ─────────────────────────────────────────────────────────────────
15
+
16
+ const BASE_URL = "https://chat.test.shipwright.dev";
17
+ const TOKEN = "chat-service-token-123";
18
+ const THREAD_ID = "thread-abc";
19
+ const MESSAGE_ID = "msg-xyz";
20
+
21
+ // ─── Fake fetch helpers ────────────────────────────────────────────────────────
22
+
23
+ type FetchFn = (
24
+ url: RequestInfo | URL,
25
+ init?: RequestInit,
26
+ ) => Promise<Response>;
27
+
28
+ type CapturedCall = {
29
+ url: string;
30
+ method: string;
31
+ headers: Record<string, string>;
32
+ body?: unknown;
33
+ };
34
+
35
+ function fakeFetch(statusCode: number, body: unknown): FetchFn {
36
+ return async () => {
37
+ return new Response(JSON.stringify(body), {
38
+ status: statusCode,
39
+ headers: { "Content-Type": "application/json" },
40
+ });
41
+ };
42
+ }
43
+
44
+ function capturingFetch(
45
+ statusCode: number,
46
+ body: unknown,
47
+ ): { fn: FetchFn; calls: CapturedCall[] } {
48
+ const calls: CapturedCall[] = [];
49
+ return {
50
+ fn: async (urlInput, init) => {
51
+ const url = urlInput instanceof Request ? urlInput.url : String(urlInput);
52
+ const method =
53
+ urlInput instanceof Request ? urlInput.method : (init?.method ?? "GET");
54
+ const headers: Record<string, string> = {};
55
+ const rawHeaders =
56
+ urlInput instanceof Request
57
+ ? urlInput.headers
58
+ : new Headers(init?.headers);
59
+ rawHeaders.forEach((value, key) => {
60
+ headers[key] = value;
61
+ });
62
+ let parsedBody: unknown;
63
+ if (init?.body) {
64
+ try {
65
+ parsedBody = JSON.parse(init.body as string);
66
+ } catch {
67
+ parsedBody = init.body;
68
+ }
69
+ }
70
+ calls.push({ url, method, headers, body: parsedBody });
71
+ return new Response(JSON.stringify(body), {
72
+ status: statusCode,
73
+ headers: { "Content-Type": "application/json" },
74
+ });
75
+ },
76
+ calls,
77
+ };
78
+ }
79
+
80
+ // ─── Sample data ───────────────────────────────────────────────────────────────
81
+
82
+ const SAMPLE_THREADS = {
83
+ threads: [
84
+ {
85
+ id: THREAD_ID,
86
+ agentId: "agent-1",
87
+ memberId: "member-1",
88
+ title: "Test Thread",
89
+ createdAt: new Date().toISOString(),
90
+ updatedAt: new Date().toISOString(),
91
+ },
92
+ ],
93
+ total: 1,
94
+ limit: 50,
95
+ offset: 0,
96
+ };
97
+
98
+ const SAMPLE_MESSAGE = {
99
+ id: MESSAGE_ID,
100
+ threadId: THREAD_ID,
101
+ role: "user",
102
+ body: "Hello!",
103
+ claimedBy: "agent-1",
104
+ claimedAt: new Date().toISOString(),
105
+ heartbeatAt: null,
106
+ cancelRequestedAt: null,
107
+ repliedAt: null,
108
+ tokens: null,
109
+ costUsd: null,
110
+ attachmentFilename: null,
111
+ createdAt: new Date().toISOString(),
112
+ updatedAt: new Date().toISOString(),
113
+ };
114
+
115
+ const SAMPLE_REPLY_RESULT = {
116
+ userMessage: SAMPLE_MESSAGE,
117
+ assistantMessage: {
118
+ ...SAMPLE_MESSAGE,
119
+ id: "msg-assist-1",
120
+ role: "assistant",
121
+ body: "Hi there!",
122
+ claimedBy: null,
123
+ claimedAt: null,
124
+ },
125
+ };
126
+
127
+ // ─── listThreads ───────────────────────────────────────────────────────────────
128
+
129
+ describe("HttpChatServiceClient.listThreads", () => {
130
+ it("returns threads list on 200", async () => {
131
+ const client = new HttpChatServiceClient({
132
+ baseUrl: BASE_URL,
133
+ token: TOKEN,
134
+ fetchFn: fakeFetch(200, SAMPLE_THREADS),
135
+ });
136
+
137
+ const result = await client.listThreads({});
138
+ expect(result.threads).toHaveLength(1);
139
+ expect(result.threads[0].id).toBe(THREAD_ID);
140
+ expect(result.total).toBe(1);
141
+ });
142
+
143
+ it("sends Authorization: Bearer header", async () => {
144
+ const { fn, calls } = capturingFetch(200, SAMPLE_THREADS);
145
+ const client = new HttpChatServiceClient({
146
+ baseUrl: BASE_URL,
147
+ token: TOKEN,
148
+ fetchFn: fn,
149
+ });
150
+
151
+ await client.listThreads({});
152
+
153
+ expect(calls).toHaveLength(1);
154
+ expect(calls[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
155
+ });
156
+
157
+ it("calls GET /threads with agentId query param", async () => {
158
+ const { fn, calls } = capturingFetch(200, SAMPLE_THREADS);
159
+ const client = new HttpChatServiceClient({
160
+ baseUrl: BASE_URL,
161
+ token: TOKEN,
162
+ fetchFn: fn,
163
+ });
164
+
165
+ await client.listThreads({ agentId: "agent-1", limit: 10, offset: 5 });
166
+
167
+ expect(calls[0].method).toBe("GET");
168
+ const url = new URL(calls[0].url);
169
+ expect(url.pathname).toBe("/threads");
170
+ expect(url.searchParams.get("agentId")).toBe("agent-1");
171
+ expect(url.searchParams.get("limit")).toBe("10");
172
+ expect(url.searchParams.get("offset")).toBe("5");
173
+ });
174
+
175
+ it("throws ChatServiceClientError on non-2xx", async () => {
176
+ const client = new HttpChatServiceClient({
177
+ baseUrl: BASE_URL,
178
+ token: TOKEN,
179
+ fetchFn: fakeFetch(500, { error: "Internal server error" }),
180
+ });
181
+
182
+ const err = await client.listThreads({}).catch((e) => e);
183
+ expect(err).toBeInstanceOf(ChatServiceClientError);
184
+ expect(err.statusCode).toBe(500);
185
+ });
186
+
187
+ it("throws ChatServiceClientError on 401", async () => {
188
+ const client = new HttpChatServiceClient({
189
+ baseUrl: BASE_URL,
190
+ token: TOKEN,
191
+ fetchFn: fakeFetch(401, { error: "Unauthorized" }),
192
+ });
193
+
194
+ const err = await client.listThreads({}).catch((e) => e);
195
+ expect(err).toBeInstanceOf(ChatServiceClientError);
196
+ expect(err.statusCode).toBe(401);
197
+ });
198
+
199
+ it("omits query params when not provided", async () => {
200
+ const { fn, calls } = capturingFetch(200, SAMPLE_THREADS);
201
+ const client = new HttpChatServiceClient({
202
+ baseUrl: BASE_URL,
203
+ token: TOKEN,
204
+ fetchFn: fn,
205
+ });
206
+
207
+ await client.listThreads({});
208
+
209
+ const url = new URL(calls[0].url);
210
+ expect(url.searchParams.has("agentId")).toBe(false);
211
+ expect(url.searchParams.has("limit")).toBe(false);
212
+ expect(url.searchParams.has("offset")).toBe(false);
213
+ });
214
+ });
215
+
216
+ // ─── claimMessage ──────────────────────────────────────────────────────────────
217
+
218
+ describe("HttpChatServiceClient.claimMessage", () => {
219
+ it("returns claimed message on 200", async () => {
220
+ const client = new HttpChatServiceClient({
221
+ baseUrl: BASE_URL,
222
+ token: TOKEN,
223
+ fetchFn: fakeFetch(200, SAMPLE_MESSAGE),
224
+ });
225
+
226
+ const result = await client.claimMessage(THREAD_ID);
227
+ expect(result).not.toBeNull();
228
+ expect(result?.id).toBe(MESSAGE_ID);
229
+ expect(result?.role).toBe("user");
230
+ });
231
+
232
+ it("returns null on 404 (no unclaimed messages)", async () => {
233
+ const client = new HttpChatServiceClient({
234
+ baseUrl: BASE_URL,
235
+ token: TOKEN,
236
+ fetchFn: fakeFetch(404, { error: "no unclaimed messages in thread" }),
237
+ });
238
+
239
+ const result = await client.claimMessage(THREAD_ID);
240
+ expect(result).toBeNull();
241
+ });
242
+
243
+ it("calls POST /threads/:threadId/messages/claim", async () => {
244
+ const { fn, calls } = capturingFetch(200, SAMPLE_MESSAGE);
245
+ const client = new HttpChatServiceClient({
246
+ baseUrl: BASE_URL,
247
+ token: TOKEN,
248
+ fetchFn: fn,
249
+ });
250
+
251
+ await client.claimMessage(THREAD_ID);
252
+
253
+ expect(calls[0].method).toBe("POST");
254
+ const url = new URL(calls[0].url);
255
+ expect(url.pathname).toBe(`/threads/${THREAD_ID}/messages/claim`);
256
+ });
257
+
258
+ it("sends Authorization: Bearer header", async () => {
259
+ const { fn, calls } = capturingFetch(200, SAMPLE_MESSAGE);
260
+ const client = new HttpChatServiceClient({
261
+ baseUrl: BASE_URL,
262
+ token: TOKEN,
263
+ fetchFn: fn,
264
+ });
265
+
266
+ await client.claimMessage(THREAD_ID);
267
+
268
+ expect(calls[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
269
+ });
270
+
271
+ it("throws ChatServiceClientError on 500", async () => {
272
+ const client = new HttpChatServiceClient({
273
+ baseUrl: BASE_URL,
274
+ token: TOKEN,
275
+ fetchFn: fakeFetch(500, { error: "Internal server error" }),
276
+ });
277
+
278
+ const err = await client.claimMessage(THREAD_ID).catch((e) => e);
279
+ expect(err).toBeInstanceOf(ChatServiceClientError);
280
+ expect(err.statusCode).toBe(500);
281
+ });
282
+ });
283
+
284
+ // ─── heartbeat ──────────────────────────────────────────────────────────────────
285
+
286
+ describe("HttpChatServiceClient.heartbeat", () => {
287
+ it("resolves to { cancelRequested: false } when the message has no cancel request", async () => {
288
+ const client = new HttpChatServiceClient({
289
+ baseUrl: BASE_URL,
290
+ token: TOKEN,
291
+ fetchFn: fakeFetch(200, {
292
+ ...SAMPLE_MESSAGE,
293
+ cancelRequestedAt: null,
294
+ }),
295
+ });
296
+
297
+ const result = await client.heartbeat(THREAD_ID, MESSAGE_ID);
298
+ expect(result.cancelRequested).toBe(false);
299
+ });
300
+
301
+ it("derives cancelRequested=true from a non-null cancelRequestedAt", async () => {
302
+ const client = new HttpChatServiceClient({
303
+ baseUrl: BASE_URL,
304
+ token: TOKEN,
305
+ fetchFn: fakeFetch(200, {
306
+ ...SAMPLE_MESSAGE,
307
+ cancelRequestedAt: new Date().toISOString(),
308
+ }),
309
+ });
310
+
311
+ const result = await client.heartbeat(THREAD_ID, MESSAGE_ID);
312
+ expect(result.cancelRequested).toBe(true);
313
+ });
314
+
315
+ it("calls POST /threads/:threadId/messages/:messageId/heartbeat", async () => {
316
+ const { fn, calls } = capturingFetch(200, SAMPLE_MESSAGE);
317
+ const client = new HttpChatServiceClient({
318
+ baseUrl: BASE_URL,
319
+ token: TOKEN,
320
+ fetchFn: fn,
321
+ });
322
+
323
+ await client.heartbeat(THREAD_ID, MESSAGE_ID);
324
+
325
+ expect(calls[0].method).toBe("POST");
326
+ const url = new URL(calls[0].url);
327
+ expect(url.pathname).toBe(
328
+ `/threads/${THREAD_ID}/messages/${MESSAGE_ID}/heartbeat`,
329
+ );
330
+ });
331
+
332
+ it("throws ChatServiceClientError on non-2xx", async () => {
333
+ const client = new HttpChatServiceClient({
334
+ baseUrl: BASE_URL,
335
+ token: TOKEN,
336
+ fetchFn: fakeFetch(404, { error: "message not found" }),
337
+ });
338
+
339
+ const err = await client.heartbeat(THREAD_ID, MESSAGE_ID).catch((e) => e);
340
+ expect(err).toBeInstanceOf(ChatServiceClientError);
341
+ expect(err.statusCode).toBe(404);
342
+ });
343
+ });
344
+
345
+ // ─── replyToMessage ────────────────────────────────────────────────────────────
346
+
347
+ describe("HttpChatServiceClient.replyToMessage", () => {
348
+ it("returns reply result on 201", async () => {
349
+ const client = new HttpChatServiceClient({
350
+ baseUrl: BASE_URL,
351
+ token: TOKEN,
352
+ fetchFn: fakeFetch(201, SAMPLE_REPLY_RESULT),
353
+ });
354
+
355
+ const result = await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
356
+ body: "Hi there!",
357
+ });
358
+ expect(result.assistantMessage.body).toBe("Hi there!");
359
+ expect(result.userMessage.id).toBe(MESSAGE_ID);
360
+ });
361
+
362
+ it("calls POST /threads/:threadId/messages/:messageId/reply", async () => {
363
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
364
+ const client = new HttpChatServiceClient({
365
+ baseUrl: BASE_URL,
366
+ token: TOKEN,
367
+ fetchFn: fn,
368
+ });
369
+
370
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, { body: "Hi!" });
371
+
372
+ expect(calls[0].method).toBe("POST");
373
+ const url = new URL(calls[0].url);
374
+ expect(url.pathname).toBe(
375
+ `/threads/${THREAD_ID}/messages/${MESSAGE_ID}/reply`,
376
+ );
377
+ });
378
+
379
+ it("sends body, tokens, and costUsd in request body", async () => {
380
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
381
+ const client = new HttpChatServiceClient({
382
+ baseUrl: BASE_URL,
383
+ token: TOKEN,
384
+ fetchFn: fn,
385
+ });
386
+
387
+ const tokens = { input_tokens: 100, output_tokens: 50 };
388
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
389
+ body: "reply text",
390
+ tokens,
391
+ costUsd: 0.001,
392
+ });
393
+
394
+ expect(calls[0].body).toEqual({
395
+ body: "reply text",
396
+ tokens,
397
+ costUsd: 0.001,
398
+ });
399
+ });
400
+
401
+ it("sends errorKind in the request body when provided", async () => {
402
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
403
+ const client = new HttpChatServiceClient({
404
+ baseUrl: BASE_URL,
405
+ token: TOKEN,
406
+ fetchFn: fn,
407
+ });
408
+
409
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
410
+ body: "Cancelled.",
411
+ errorKind: "cancelled",
412
+ });
413
+
414
+ expect(calls[0].body).toEqual({
415
+ body: "Cancelled.",
416
+ errorKind: "cancelled",
417
+ });
418
+ });
419
+
420
+ it("sends Authorization: Bearer header", async () => {
421
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
422
+ const client = new HttpChatServiceClient({
423
+ baseUrl: BASE_URL,
424
+ token: TOKEN,
425
+ fetchFn: fn,
426
+ });
427
+
428
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, { body: "reply" });
429
+
430
+ expect(calls[0].headers.authorization).toBe(`Bearer ${TOKEN}`);
431
+ });
432
+
433
+ it("throws ChatServiceClientError on non-2xx", async () => {
434
+ const client = new HttpChatServiceClient({
435
+ baseUrl: BASE_URL,
436
+ token: TOKEN,
437
+ fetchFn: fakeFetch(400, { error: "bad request" }),
438
+ });
439
+
440
+ const err = await client
441
+ .replyToMessage(THREAD_ID, MESSAGE_ID, { body: "reply" })
442
+ .catch((e) => e);
443
+ expect(err).toBeInstanceOf(ChatServiceClientError);
444
+ expect(err.statusCode).toBe(400);
445
+ });
446
+
447
+ it("base64-encodes attachmentBytes and sends attachmentFilename/attachmentSize", async () => {
448
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
449
+ const client = new HttpChatServiceClient({
450
+ baseUrl: BASE_URL,
451
+ token: TOKEN,
452
+ fetchFn: fn,
453
+ });
454
+
455
+ const attachmentBytes = new Uint8Array([1, 2, 3, 4]);
456
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
457
+ body: "Here you go",
458
+ attachmentFilename: "response.wav",
459
+ attachmentSize: attachmentBytes.length,
460
+ attachmentBytes,
461
+ });
462
+
463
+ expect(calls[0].body).toEqual({
464
+ body: "Here you go",
465
+ attachmentFilename: "response.wav",
466
+ attachmentSize: 4,
467
+ attachmentBytes: Buffer.from(attachmentBytes).toString("base64"),
468
+ });
469
+ });
470
+
471
+ it("omits attachment fields entirely when not provided", async () => {
472
+ const { fn, calls } = capturingFetch(201, SAMPLE_REPLY_RESULT);
473
+ const client = new HttpChatServiceClient({
474
+ baseUrl: BASE_URL,
475
+ token: TOKEN,
476
+ fetchFn: fn,
477
+ });
478
+
479
+ await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
480
+ body: "no attachment",
481
+ });
482
+
483
+ const body = calls[0].body as Record<string, unknown>;
484
+ expect(body.attachmentFilename).toBeUndefined();
485
+ expect(body.attachmentSize).toBeUndefined();
486
+ expect(body.attachmentBytes).toBeUndefined();
487
+ });
488
+ });
489
+
490
+ // ─── ChatServiceClientError ────────────────────────────────────────────────────
491
+
492
+ describe("ChatServiceClientError", () => {
493
+ it("has correct name and statusCode", () => {
494
+ const err = new ChatServiceClientError(503, "Service unavailable");
495
+ expect(err.name).toBe("ChatServiceClientError");
496
+ expect(err.statusCode).toBe(503);
497
+ expect(err.message).toBe("Service unavailable");
498
+ expect(err).toBeInstanceOf(Error);
499
+ });
500
+ });
package/log-prefix.ts ADDED
@@ -0,0 +1,12 @@
1
+ // Builds a console log prefix, tagging lines with the agent ID (when set) so
2
+ // they're attributable in an aggregated multi-agent log view.
3
+ export function buildLogPrefix(
4
+ agentId: string | undefined,
5
+ timestamp: string,
6
+ ): string {
7
+ const timestampPart = `[${timestamp}]`;
8
+ if (!agentId) {
9
+ return timestampPart;
10
+ }
11
+ return `${timestampPart} [agent:${agentId}]`;
12
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildLogPrefix } from "./log-prefix.ts";
3
+
4
+ describe("buildLogPrefix", () => {
5
+ test("returns timestamp only when agentId is undefined, with no 'undefined' literal", () => {
6
+ const timestamp = "2026-07-09T09:00:00.000Z";
7
+ const prefix = buildLogPrefix(undefined, timestamp);
8
+ expect(prefix).toBe(`[${timestamp}]`);
9
+ expect(prefix).not.toContain("undefined");
10
+ });
11
+
12
+ test("returns timestamp only when agentId is empty string", () => {
13
+ const timestamp = "2026-07-09T09:00:00.000Z";
14
+ const prefix = buildLogPrefix("", timestamp);
15
+ expect(prefix).toBe(`[${timestamp}]`);
16
+ });
17
+
18
+ test("appends [agent:ID] after the timestamp when agentId is set", () => {
19
+ const timestamp = "2026-07-09T09:00:00.123Z";
20
+ const agentId = "shipwright-prod-us-east-1";
21
+ const prefix = buildLogPrefix(agentId, timestamp);
22
+ expect(prefix).toBe(`[${timestamp}] [agent:${agentId}]`);
23
+ });
24
+ });