@app-vitals/agent-runtime 0.0.0-stage → 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.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/http-chat-service-client.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for HttpChatServiceClient against recorded chat-service
|
|
5
|
+
* REST API fixtures.
|
|
6
|
+
*
|
|
7
|
+
* Drives the client through an INJECTED fetchFn that replays canned Responses
|
|
8
|
+
* from cassette JSON files committed under ./fixtures/chat-service/ — no live
|
|
9
|
+
* API server, no global.fetch override, no mock.module(). Mirrors the pattern
|
|
10
|
+
* in admin/src/chat-service-provisioning-client.integration.test.ts.
|
|
11
|
+
*
|
|
12
|
+
* Companion to http-chat-service-client.unit.test.ts, which covers pure
|
|
13
|
+
* request-shaping logic via an inline injected double — this file instead
|
|
14
|
+
* reads response bodies from committed fixture JSON to exercise the
|
|
15
|
+
* "recorded dependency behavior" boundary per docs/testing.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from "bun:test";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
ChatServiceClientError,
|
|
23
|
+
HttpChatServiceClient,
|
|
24
|
+
} from "./http-chat-service-client.ts";
|
|
25
|
+
|
|
26
|
+
// ─── Cassettes ─────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
interface CassetteEntry {
|
|
29
|
+
status: number;
|
|
30
|
+
/** When set to "base64", `body` is a base64 string decoded to raw bytes. */
|
|
31
|
+
bodyEncoding?: "base64";
|
|
32
|
+
body: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function loadCassette(filename: string): Record<string, CassetteEntry> {
|
|
36
|
+
// Beside the runtime source when extracted, one level up in the monorepo.
|
|
37
|
+
const path = [
|
|
38
|
+
join(import.meta.dir, "fixtures"),
|
|
39
|
+
join(import.meta.dir, "..", "fixtures"),
|
|
40
|
+
]
|
|
41
|
+
.map((dir) => join(dir, "chat-service", filename))
|
|
42
|
+
.find((candidate) => existsSync(candidate)) as string;
|
|
43
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const threadsCassette = loadCassette("threads.json");
|
|
47
|
+
const messagesCassette = loadCassette("messages.json");
|
|
48
|
+
const attachmentCassette = loadCassette("attachment.json");
|
|
49
|
+
|
|
50
|
+
interface RecordedRequest {
|
|
51
|
+
url: string;
|
|
52
|
+
method: string;
|
|
53
|
+
headers: Headers;
|
|
54
|
+
body?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a Response for a cassette entry. JSON entries get a JSON content
|
|
59
|
+
* type; base64-encoded entries (binary attachments) are decoded to raw bytes
|
|
60
|
+
* and returned without a JSON content type, matching what the real
|
|
61
|
+
* chat-service attachment endpoint sends.
|
|
62
|
+
*/
|
|
63
|
+
function responseForEntry(entry: CassetteEntry): Response {
|
|
64
|
+
if (entry.bodyEncoding === "base64") {
|
|
65
|
+
const bytes = Uint8Array.from(atob(entry.body as string), (c) =>
|
|
66
|
+
c.charCodeAt(0),
|
|
67
|
+
);
|
|
68
|
+
return new Response(bytes, {
|
|
69
|
+
status: entry.status,
|
|
70
|
+
statusText: `status-${entry.status}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return new Response(JSON.stringify(entry.body), {
|
|
74
|
+
status: entry.status,
|
|
75
|
+
statusText: `status-${entry.status}`,
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build an injected fetchFn that replays the cassette entry for `key` from
|
|
82
|
+
* `cassette` on every call. Records the last request so tests can assert
|
|
83
|
+
* URL/method/headers.
|
|
84
|
+
*/
|
|
85
|
+
function cassetteFetch(
|
|
86
|
+
cassette: Record<string, CassetteEntry>,
|
|
87
|
+
key: string,
|
|
88
|
+
): {
|
|
89
|
+
fetchFn: typeof fetch;
|
|
90
|
+
lastRequest: () => RecordedRequest;
|
|
91
|
+
} {
|
|
92
|
+
let last: RecordedRequest | undefined;
|
|
93
|
+
const entry = cassette[key];
|
|
94
|
+
if (!entry) throw new Error(`cassette key not found: ${key}`);
|
|
95
|
+
|
|
96
|
+
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
97
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
98
|
+
last = {
|
|
99
|
+
url,
|
|
100
|
+
method: init?.method ?? "GET",
|
|
101
|
+
headers: new Headers(init?.headers),
|
|
102
|
+
body: typeof init?.body === "string" ? init.body : undefined,
|
|
103
|
+
};
|
|
104
|
+
return responseForEntry(entry);
|
|
105
|
+
}) as typeof fetch;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
fetchFn,
|
|
109
|
+
lastRequest: () => {
|
|
110
|
+
if (!last) throw new Error("fetchFn was not called");
|
|
111
|
+
return last;
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const BASE_URL = "https://chat.example.com";
|
|
117
|
+
const TOKEN = "chat-service-token-xyz";
|
|
118
|
+
const THREAD_ID = "thread-abc";
|
|
119
|
+
const MESSAGE_ID = "msg-xyz";
|
|
120
|
+
|
|
121
|
+
function makeClient(
|
|
122
|
+
cassette: Record<string, CassetteEntry>,
|
|
123
|
+
key: string,
|
|
124
|
+
): {
|
|
125
|
+
client: HttpChatServiceClient;
|
|
126
|
+
lastRequest: () => RecordedRequest;
|
|
127
|
+
} {
|
|
128
|
+
const { fetchFn, lastRequest } = cassetteFetch(cassette, key);
|
|
129
|
+
const client = new HttpChatServiceClient({
|
|
130
|
+
baseUrl: BASE_URL,
|
|
131
|
+
token: TOKEN,
|
|
132
|
+
fetchFn,
|
|
133
|
+
});
|
|
134
|
+
return { client, lastRequest };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── listThreads ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("HttpChatServiceClient.listThreads (recorded fixtures)", () => {
|
|
140
|
+
it("returns the threads array from the recorded fixture on 200", async () => {
|
|
141
|
+
const { client, lastRequest } = makeClient(
|
|
142
|
+
threadsCassette,
|
|
143
|
+
"listThreads_success",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const result = await client.listThreads({});
|
|
147
|
+
|
|
148
|
+
const req = lastRequest();
|
|
149
|
+
expect(req.method).toBe("GET");
|
|
150
|
+
expect(req.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
151
|
+
expect(result.threads).toHaveLength(2);
|
|
152
|
+
expect(result.threads[0].id).toBe("thread-abc");
|
|
153
|
+
expect(result.threads[1].memberId).toBeNull();
|
|
154
|
+
expect(result.total).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("sends agentId/limit/offset as query params and returns the paginated fixture", async () => {
|
|
158
|
+
const { client, lastRequest } = makeClient(
|
|
159
|
+
threadsCassette,
|
|
160
|
+
"listThreads_with_query_params",
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await client.listThreads({
|
|
164
|
+
agentId: "agent-1",
|
|
165
|
+
limit: 10,
|
|
166
|
+
offset: 5,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const req = lastRequest();
|
|
170
|
+
const url = new URL(req.url);
|
|
171
|
+
expect(url.pathname).toBe("/threads");
|
|
172
|
+
expect(url.searchParams.get("agentId")).toBe("agent-1");
|
|
173
|
+
expect(url.searchParams.get("limit")).toBe("10");
|
|
174
|
+
expect(url.searchParams.get("offset")).toBe("5");
|
|
175
|
+
expect(result.total).toBe(6);
|
|
176
|
+
expect(result.limit).toBe(10);
|
|
177
|
+
expect(result.offset).toBe(5);
|
|
178
|
+
expect(result.threads).toHaveLength(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("throws ChatServiceClientError with statusCode 500 on the recorded server error", async () => {
|
|
182
|
+
const { client } = makeClient(threadsCassette, "listThreads_500");
|
|
183
|
+
|
|
184
|
+
const err = await client.listThreads({}).catch((e) => e);
|
|
185
|
+
expect(err).toBeInstanceOf(ChatServiceClientError);
|
|
186
|
+
expect(err.statusCode).toBe(500);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ─── claimMessage ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("HttpChatServiceClient.claimMessage (recorded fixtures)", () => {
|
|
193
|
+
it("returns the claimed message from the recorded fixture on 200", async () => {
|
|
194
|
+
const { client, lastRequest } = makeClient(
|
|
195
|
+
messagesCassette,
|
|
196
|
+
"claimMessage_success",
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const result = await client.claimMessage(THREAD_ID);
|
|
200
|
+
|
|
201
|
+
const req = lastRequest();
|
|
202
|
+
expect(req.method).toBe("POST");
|
|
203
|
+
const url = new URL(req.url);
|
|
204
|
+
expect(url.pathname).toBe(`/threads/${THREAD_ID}/messages/claim`);
|
|
205
|
+
expect(req.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
206
|
+
expect(result).not.toBeNull();
|
|
207
|
+
expect(result?.id).toBe(MESSAGE_ID);
|
|
208
|
+
expect(result?.claimedBy).toBe("agent-1");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns null on the recorded 404 (no unclaimed messages)", async () => {
|
|
212
|
+
const { client } = makeClient(messagesCassette, "claimMessage_404");
|
|
213
|
+
|
|
214
|
+
const result = await client.claimMessage(THREAD_ID);
|
|
215
|
+
expect(result).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("throws ChatServiceClientError with statusCode 500 on the recorded server error", async () => {
|
|
219
|
+
const { client } = makeClient(messagesCassette, "claimMessage_500");
|
|
220
|
+
|
|
221
|
+
const err = await client.claimMessage(THREAD_ID).catch((e) => e);
|
|
222
|
+
expect(err).toBeInstanceOf(ChatServiceClientError);
|
|
223
|
+
expect(err.statusCode).toBe(500);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ─── heartbeat ──────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("HttpChatServiceClient.heartbeat (recorded fixtures)", () => {
|
|
230
|
+
it("resolves and hits the recorded fixture on 200", async () => {
|
|
231
|
+
const { client, lastRequest } = makeClient(
|
|
232
|
+
messagesCassette,
|
|
233
|
+
"heartbeat_success",
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const result = await client.heartbeat(THREAD_ID, MESSAGE_ID);
|
|
237
|
+
expect(result.cancelRequested).toBe(false);
|
|
238
|
+
|
|
239
|
+
const req = lastRequest();
|
|
240
|
+
expect(req.method).toBe("POST");
|
|
241
|
+
const url = new URL(req.url);
|
|
242
|
+
expect(url.pathname).toBe(
|
|
243
|
+
`/threads/${THREAD_ID}/messages/${MESSAGE_ID}/heartbeat`,
|
|
244
|
+
);
|
|
245
|
+
expect(req.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("throws ChatServiceClientError with statusCode 404 on the recorded not-found error", async () => {
|
|
249
|
+
const { client } = makeClient(messagesCassette, "heartbeat_404");
|
|
250
|
+
|
|
251
|
+
const err = await client.heartbeat(THREAD_ID, MESSAGE_ID).catch((e) => e);
|
|
252
|
+
expect(err).toBeInstanceOf(ChatServiceClientError);
|
|
253
|
+
expect(err.statusCode).toBe(404);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ─── replyToMessage ────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
describe("HttpChatServiceClient.replyToMessage (recorded fixtures)", () => {
|
|
260
|
+
it("returns the reply result from the recorded fixture on 201", async () => {
|
|
261
|
+
const { client, lastRequest } = makeClient(
|
|
262
|
+
messagesCassette,
|
|
263
|
+
"replyToMessage_success",
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const result = await client.replyToMessage(THREAD_ID, MESSAGE_ID, {
|
|
267
|
+
body: "Sure, here's how...",
|
|
268
|
+
tokens: { input_tokens: 120, output_tokens: 42 },
|
|
269
|
+
costUsd: 0.0021,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const req = lastRequest();
|
|
273
|
+
expect(req.method).toBe("POST");
|
|
274
|
+
const url = new URL(req.url);
|
|
275
|
+
expect(url.pathname).toBe(
|
|
276
|
+
`/threads/${THREAD_ID}/messages/${MESSAGE_ID}/reply`,
|
|
277
|
+
);
|
|
278
|
+
expect(req.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
279
|
+
expect(JSON.parse(req.body ?? "{}")).toEqual({
|
|
280
|
+
body: "Sure, here's how...",
|
|
281
|
+
tokens: { input_tokens: 120, output_tokens: 42 },
|
|
282
|
+
costUsd: 0.0021,
|
|
283
|
+
});
|
|
284
|
+
expect(result.userMessage.id).toBe(MESSAGE_ID);
|
|
285
|
+
expect(result.assistantMessage.role).toBe("assistant");
|
|
286
|
+
expect(result.assistantMessage.body).toBe("Sure, here's how...");
|
|
287
|
+
expect(result.assistantMessage.costUsd).toBe(0.0021);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("throws ChatServiceClientError with statusCode 400 on the recorded bad-request error", async () => {
|
|
291
|
+
const { client } = makeClient(messagesCassette, "replyToMessage_400");
|
|
292
|
+
|
|
293
|
+
const err = await client
|
|
294
|
+
.replyToMessage(THREAD_ID, MESSAGE_ID, { body: "" })
|
|
295
|
+
.catch((e) => e);
|
|
296
|
+
expect(err).toBeInstanceOf(ChatServiceClientError);
|
|
297
|
+
expect(err.statusCode).toBe(400);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ─── getAttachment ─────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
describe("HttpChatServiceClient.getAttachment (recorded fixtures)", () => {
|
|
304
|
+
it("returns the recorded binary bytes as a Uint8Array on 200", async () => {
|
|
305
|
+
const entry = attachmentCassette.getAttachment_success;
|
|
306
|
+
const expectedBytes = Uint8Array.from(atob(entry.body as string), (c) =>
|
|
307
|
+
c.charCodeAt(0),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const { client, lastRequest } = makeClient(
|
|
311
|
+
attachmentCassette,
|
|
312
|
+
"getAttachment_success",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const result = await client.getAttachment(THREAD_ID, MESSAGE_ID);
|
|
316
|
+
|
|
317
|
+
const req = lastRequest();
|
|
318
|
+
expect(req.method).toBe("GET");
|
|
319
|
+
const url = new URL(req.url);
|
|
320
|
+
expect(url.pathname).toBe(
|
|
321
|
+
`/threads/${THREAD_ID}/messages/${MESSAGE_ID}/attachment`,
|
|
322
|
+
);
|
|
323
|
+
expect(req.headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
324
|
+
expect(result).not.toBeNull();
|
|
325
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
326
|
+
expect(result).toEqual(expectedBytes);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("returns null on the recorded 404 (no attachment)", async () => {
|
|
330
|
+
const { client } = makeClient(attachmentCassette, "getAttachment_404");
|
|
331
|
+
|
|
332
|
+
const result = await client.getAttachment(THREAD_ID, MESSAGE_ID);
|
|
333
|
+
expect(result).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("throws ChatServiceClientError with statusCode 500 on the recorded server error", async () => {
|
|
337
|
+
const { client } = makeClient(attachmentCassette, "getAttachment_500");
|
|
338
|
+
|
|
339
|
+
const err = await client
|
|
340
|
+
.getAttachment(THREAD_ID, MESSAGE_ID)
|
|
341
|
+
.catch((e) => e);
|
|
342
|
+
expect(err).toBeInstanceOf(ChatServiceClientError);
|
|
343
|
+
expect(err.statusCode).toBe(500);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/http-chat-service-client.ts
|
|
3
|
+
*
|
|
4
|
+
* Typed HTTP client for the Shipwright chat service REST API.
|
|
5
|
+
*
|
|
6
|
+
* - ChatServiceClientError — typed error with statusCode
|
|
7
|
+
* - ChatServiceClient — interface for DI / testability
|
|
8
|
+
* - HttpChatServiceClient — production implementation with injectable fetchFn
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface Thread {
|
|
14
|
+
id: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
memberId: string | null;
|
|
17
|
+
title: string | null;
|
|
18
|
+
createdAt: Date | string;
|
|
19
|
+
updatedAt: Date | string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Message {
|
|
23
|
+
id: string;
|
|
24
|
+
threadId: string;
|
|
25
|
+
role: string;
|
|
26
|
+
body: string;
|
|
27
|
+
claimedBy: string | null;
|
|
28
|
+
claimedAt: Date | string | null;
|
|
29
|
+
heartbeatAt: Date | string | null;
|
|
30
|
+
cancelRequestedAt: Date | string | null;
|
|
31
|
+
repliedAt: Date | string | null;
|
|
32
|
+
tokens: unknown;
|
|
33
|
+
costUsd: number | null;
|
|
34
|
+
attachmentFilename: string | null;
|
|
35
|
+
createdAt: Date | string;
|
|
36
|
+
updatedAt: Date | string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result of a heartbeat tick. The tick is bidirectional: the same request that
|
|
41
|
+
* proves liveness returns whether a cancel was requested, so there is no second
|
|
42
|
+
* polling loop and no inbound HTTP surface on the agent.
|
|
43
|
+
*/
|
|
44
|
+
interface HeartbeatResult {
|
|
45
|
+
/** True when cancelRequestedAt is set on the message — the run should abort. */
|
|
46
|
+
cancelRequested: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ReplyResult {
|
|
50
|
+
userMessage: Message;
|
|
51
|
+
assistantMessage: Message;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ListThreadsOptions {
|
|
55
|
+
agentId?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
offset?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ListThreadsResult {
|
|
61
|
+
threads: Thread[];
|
|
62
|
+
total: number;
|
|
63
|
+
limit: number;
|
|
64
|
+
offset: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ReplyOptions {
|
|
68
|
+
body: string;
|
|
69
|
+
tokens?: unknown;
|
|
70
|
+
costUsd?: number;
|
|
71
|
+
/** Stamped on the assistant message so the UI can offer a Retry action. */
|
|
72
|
+
errorKind?: string;
|
|
73
|
+
/** Filename for an optional reply attachment (e.g. synthesized speech audio). */
|
|
74
|
+
attachmentFilename?: string;
|
|
75
|
+
/** Byte size of `attachmentBytes`. Required alongside attachmentBytes. */
|
|
76
|
+
attachmentSize?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Raw attachment bytes. Base64-encoded by replyToMessage() before being
|
|
79
|
+
* sent in the JSON body — callers pass raw bytes, not a pre-encoded string.
|
|
80
|
+
*/
|
|
81
|
+
attachmentBytes?: Uint8Array;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Error ────────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export class ChatServiceClientError extends Error {
|
|
87
|
+
constructor(
|
|
88
|
+
public readonly statusCode: number,
|
|
89
|
+
message: string,
|
|
90
|
+
) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = "ChatServiceClientError";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Interface ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface ChatServiceClient {
|
|
99
|
+
listThreads(opts: ListThreadsOptions): Promise<ListThreadsResult>;
|
|
100
|
+
/** Returns null when there are no unclaimed messages in the thread. */
|
|
101
|
+
claimMessage(threadId: string): Promise<Message | null>;
|
|
102
|
+
/**
|
|
103
|
+
* Bump a claimed message's heartbeatAt — proof of life while a long-running
|
|
104
|
+
* reply is in progress — and learn whether a cancel was requested. The tick
|
|
105
|
+
* is bidirectional (see HeartbeatResult): the returned message carries
|
|
106
|
+
* cancelRequestedAt, from which cancelRequested is derived. Best-effort:
|
|
107
|
+
* callers should swallow failures rather than let a heartbeat blip abort the
|
|
108
|
+
* reply itself.
|
|
109
|
+
*/
|
|
110
|
+
heartbeat(threadId: string, messageId: string): Promise<HeartbeatResult>;
|
|
111
|
+
replyToMessage(
|
|
112
|
+
threadId: string,
|
|
113
|
+
messageId: string,
|
|
114
|
+
opts: ReplyOptions,
|
|
115
|
+
): Promise<ReplyResult>;
|
|
116
|
+
/**
|
|
117
|
+
* Fetch a message's attachment bytes. Returns null when there is no
|
|
118
|
+
* attachment (404). The chat service drops the bytes after serving them.
|
|
119
|
+
*/
|
|
120
|
+
getAttachment(
|
|
121
|
+
threadId: string,
|
|
122
|
+
messageId: string,
|
|
123
|
+
): Promise<Uint8Array | null>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── HttpChatServiceClient ────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
type FetchFn = (
|
|
129
|
+
url: RequestInfo | URL,
|
|
130
|
+
init?: RequestInit,
|
|
131
|
+
) => Promise<Response>;
|
|
132
|
+
|
|
133
|
+
export class HttpChatServiceClient implements ChatServiceClient {
|
|
134
|
+
private readonly baseUrl: string;
|
|
135
|
+
private readonly token: string;
|
|
136
|
+
private readonly fetchFn: FetchFn;
|
|
137
|
+
|
|
138
|
+
constructor(opts: {
|
|
139
|
+
baseUrl: string;
|
|
140
|
+
token: string;
|
|
141
|
+
/** Injectable fetch for testing. Defaults to global fetch. */
|
|
142
|
+
fetchFn?: FetchFn;
|
|
143
|
+
}) {
|
|
144
|
+
// Strip trailing slash for clean URL construction
|
|
145
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
146
|
+
this.token = opts.token;
|
|
147
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private authHeaders(): Record<string, string> {
|
|
151
|
+
return {
|
|
152
|
+
Authorization: `Bearer ${this.token}`,
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async listThreads(opts: ListThreadsOptions): Promise<ListThreadsResult> {
|
|
158
|
+
const url = new URL(`${this.baseUrl}/threads`);
|
|
159
|
+
if (opts.agentId !== undefined)
|
|
160
|
+
url.searchParams.set("agentId", opts.agentId);
|
|
161
|
+
if (opts.limit !== undefined)
|
|
162
|
+
url.searchParams.set("limit", String(opts.limit));
|
|
163
|
+
if (opts.offset !== undefined)
|
|
164
|
+
url.searchParams.set("offset", String(opts.offset));
|
|
165
|
+
|
|
166
|
+
const res = await this.fetchFn(url.toString(), {
|
|
167
|
+
method: "GET",
|
|
168
|
+
headers: this.authHeaders(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
throw new ChatServiceClientError(
|
|
173
|
+
res.status,
|
|
174
|
+
`GET /threads failed: ${res.status}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return res.json() as Promise<ListThreadsResult>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async claimMessage(threadId: string): Promise<Message | null> {
|
|
182
|
+
const url = `${this.baseUrl}/threads/${threadId}/messages/claim`;
|
|
183
|
+
|
|
184
|
+
const res = await this.fetchFn(url, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: this.authHeaders(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// 404 = no unclaimed messages — not an error, just means nothing to do
|
|
190
|
+
if (res.status === 404) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
throw new ChatServiceClientError(
|
|
196
|
+
res.status,
|
|
197
|
+
`POST /threads/${threadId}/messages/claim failed: ${res.status}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return res.json() as Promise<Message>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async heartbeat(
|
|
205
|
+
threadId: string,
|
|
206
|
+
messageId: string,
|
|
207
|
+
): Promise<HeartbeatResult> {
|
|
208
|
+
const url = `${this.baseUrl}/threads/${threadId}/messages/${messageId}/heartbeat`;
|
|
209
|
+
|
|
210
|
+
const res = await this.fetchFn(url, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: this.authHeaders(),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
throw new ChatServiceClientError(
|
|
217
|
+
res.status,
|
|
218
|
+
`POST /threads/${threadId}/messages/${messageId}/heartbeat failed: ${res.status}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const message = (await res.json()) as Message;
|
|
223
|
+
return { cancelRequested: message.cancelRequestedAt != null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async replyToMessage(
|
|
227
|
+
threadId: string,
|
|
228
|
+
messageId: string,
|
|
229
|
+
opts: ReplyOptions,
|
|
230
|
+
): Promise<ReplyResult> {
|
|
231
|
+
const url = `${this.baseUrl}/threads/${threadId}/messages/${messageId}/reply`;
|
|
232
|
+
|
|
233
|
+
const body: Record<string, unknown> = { body: opts.body };
|
|
234
|
+
if (opts.tokens !== undefined) body.tokens = opts.tokens;
|
|
235
|
+
if (opts.costUsd !== undefined) body.costUsd = opts.costUsd;
|
|
236
|
+
if (opts.errorKind !== undefined) body.errorKind = opts.errorKind;
|
|
237
|
+
if (opts.attachmentFilename !== undefined)
|
|
238
|
+
body.attachmentFilename = opts.attachmentFilename;
|
|
239
|
+
if (opts.attachmentSize !== undefined)
|
|
240
|
+
body.attachmentSize = opts.attachmentSize;
|
|
241
|
+
if (opts.attachmentBytes !== undefined)
|
|
242
|
+
body.attachmentBytes = Buffer.from(opts.attachmentBytes).toString(
|
|
243
|
+
"base64",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const res = await this.fetchFn(url, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: this.authHeaders(),
|
|
249
|
+
body: JSON.stringify(body),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!res.ok) {
|
|
253
|
+
throw new ChatServiceClientError(
|
|
254
|
+
res.status,
|
|
255
|
+
`POST /threads/${threadId}/messages/${messageId}/reply failed: ${res.status}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return res.json() as Promise<ReplyResult>;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getAttachment(
|
|
263
|
+
threadId: string,
|
|
264
|
+
messageId: string,
|
|
265
|
+
): Promise<Uint8Array | null> {
|
|
266
|
+
const url = `${this.baseUrl}/threads/${threadId}/messages/${messageId}/attachment`;
|
|
267
|
+
|
|
268
|
+
const res = await this.fetchFn(url, {
|
|
269
|
+
method: "GET",
|
|
270
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 404 = no attachment — not an error, nothing to pull.
|
|
274
|
+
if (res.status === 404) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!res.ok) {
|
|
279
|
+
throw new ChatServiceClientError(
|
|
280
|
+
res.status,
|
|
281
|
+
`GET /threads/${threadId}/messages/${messageId}/attachment failed: ${res.status}`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
286
|
+
}
|
|
287
|
+
}
|