@app-vitals/agent-runtime 1.0.0 → 1.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/{shipwright-config-client.ts → agent-config-client.ts} +6 -6
- package/boot.ts +2 -1
- package/cutover-validate.integration.test.ts +14 -14
- package/cutover-validate.ts +3 -3
- package/entrypoint.integration.test.ts +20 -20
- package/entrypoint.ts +3 -3
- package/package.json +3 -3
- package/slack-chat-conversation.integration.test.ts +4 -0
- package/slack.integration.test.ts +21 -0
- package/slack.ts +67 -21
- package/slack.unit.test.ts +59 -8
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent/src/runtime/
|
|
2
|
+
* agent/src/runtime/agent-config-client.ts
|
|
3
3
|
*
|
|
4
|
-
* -
|
|
5
|
-
* -
|
|
4
|
+
* - AgentConfigClient — interface for DI (used by entrypoint and tests)
|
|
5
|
+
* - RecordedAgentConfigClient — cassette-backed double for tests
|
|
6
6
|
*
|
|
7
7
|
* The HTTP implementation was consolidated into HttpShipwrightRuntimeClient
|
|
8
8
|
* (shipwright-runtime-client.ts). Wire that via getAgentConfigBundle() as the
|
|
@@ -13,17 +13,17 @@ import type { AgentConfigResponse } from "@app-vitals/agent-admin";
|
|
|
13
13
|
|
|
14
14
|
// ─── Interface ────────────────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
|
-
export interface
|
|
16
|
+
export interface AgentConfigClient {
|
|
17
17
|
getConfig(agentId: string): Promise<AgentConfigResponse>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// ───
|
|
20
|
+
// ─── RecordedAgentConfigClient ───────────────────────────────────────────
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Cassette-backed client for tests.
|
|
24
24
|
* Returns a fixed AgentConfigResponse on every call — no network required.
|
|
25
25
|
*/
|
|
26
|
-
export class
|
|
26
|
+
export class RecordedAgentConfigClient implements AgentConfigClient {
|
|
27
27
|
private readonly cassette: AgentConfigResponse;
|
|
28
28
|
|
|
29
29
|
constructor(cassette: AgentConfigResponse) {
|
package/boot.ts
CHANGED
|
@@ -364,8 +364,9 @@ export async function startAgent(
|
|
|
364
364
|
message: string,
|
|
365
365
|
sessionKey?: string,
|
|
366
366
|
onProgress?: ProgressCallback,
|
|
367
|
+
signal?: AbortSignal,
|
|
367
368
|
extraEnv?: Record<string, string>,
|
|
368
|
-
) => runner(message, sessionKey, onProgress,
|
|
369
|
+
) => runner(message, sessionKey, onProgress, signal, extraEnv),
|
|
369
370
|
markdownToSlack,
|
|
370
371
|
threadKey,
|
|
371
372
|
undefined, // appFactory — default Bolt App
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
AgentCronJob,
|
|
11
11
|
} from "@app-vitals/agent-admin";
|
|
12
12
|
import {
|
|
13
|
-
type
|
|
13
|
+
type CutoverConfigClient,
|
|
14
14
|
validateCutover,
|
|
15
15
|
} from "./cutover-validate.ts";
|
|
16
16
|
|
|
@@ -62,7 +62,7 @@ function makeConfig(env: Record<string, string>): AgentConfigResponse {
|
|
|
62
62
|
|
|
63
63
|
// ─── Recorded double ──────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
|
-
class
|
|
65
|
+
class RecordedAgentConfigClient implements CutoverConfigClient {
|
|
66
66
|
constructor(
|
|
67
67
|
private readonly config: AgentConfigResponse,
|
|
68
68
|
private readonly crons: AgentCronJob[],
|
|
@@ -100,7 +100,7 @@ function makePatEnv(): Record<string, string> {
|
|
|
100
100
|
|
|
101
101
|
describe("validateCutover", () => {
|
|
102
102
|
it("returns all passing when SLACK_BOT_TOKEN + GitHub App creds + crons present", async () => {
|
|
103
|
-
const client = new
|
|
103
|
+
const client = new RecordedAgentConfigClient(
|
|
104
104
|
makeConfig(makeAppAuthEnv()),
|
|
105
105
|
[makeCron("cron-1")],
|
|
106
106
|
);
|
|
@@ -109,7 +109,7 @@ describe("validateCutover", () => {
|
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
it("returns all passing when SLACK_BOT_TOKEN + GH_TOKEN (PAT) + crons present", async () => {
|
|
112
|
-
const client = new
|
|
112
|
+
const client = new RecordedAgentConfigClient(
|
|
113
113
|
makeConfig(makePatEnv()),
|
|
114
114
|
[makeCron("cron-1")],
|
|
115
115
|
);
|
|
@@ -119,7 +119,7 @@ describe("validateCutover", () => {
|
|
|
119
119
|
|
|
120
120
|
it("does NOT fail when SLACK_BOT_TOKEN is missing — a chat-only agent is a supported configuration", async () => {
|
|
121
121
|
const { SLACK_BOT_TOKEN: _s, ...env } = makeAppAuthEnv();
|
|
122
|
-
const client = new
|
|
122
|
+
const client = new RecordedAgentConfigClient(makeConfig(env), [
|
|
123
123
|
makeCron("cron-1"),
|
|
124
124
|
]);
|
|
125
125
|
const results = await validateCutover(client, AGENT_ID);
|
|
@@ -132,7 +132,7 @@ describe("validateCutover", () => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
it("reports SLACK_BOT_TOKEN as present when it is set", async () => {
|
|
135
|
-
const client = new
|
|
135
|
+
const client = new RecordedAgentConfigClient(
|
|
136
136
|
makeConfig(makeAppAuthEnv()),
|
|
137
137
|
[makeCron("cron-1")],
|
|
138
138
|
);
|
|
@@ -144,7 +144,7 @@ describe("validateCutover", () => {
|
|
|
144
144
|
|
|
145
145
|
it("names no failing check when only SLACK_BOT_TOKEN is missing", async () => {
|
|
146
146
|
const { SLACK_BOT_TOKEN: _s, ...env } = makeAppAuthEnv();
|
|
147
|
-
const client = new
|
|
147
|
+
const client = new RecordedAgentConfigClient(makeConfig(env), [
|
|
148
148
|
makeCron("cron-1"),
|
|
149
149
|
]);
|
|
150
150
|
const results = await validateCutover(client, AGENT_ID);
|
|
@@ -152,7 +152,7 @@ describe("validateCutover", () => {
|
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
it("fails when GitHub auth credentials are missing", async () => {
|
|
155
|
-
const client = new
|
|
155
|
+
const client = new RecordedAgentConfigClient(
|
|
156
156
|
makeConfig({ SLACK_BOT_TOKEN: "xoxb-token" }),
|
|
157
157
|
[makeCron("cron-1")],
|
|
158
158
|
);
|
|
@@ -163,7 +163,7 @@ describe("validateCutover", () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
it("fails when no crons are present", async () => {
|
|
166
|
-
const client = new
|
|
166
|
+
const client = new RecordedAgentConfigClient(
|
|
167
167
|
makeConfig(makeAppAuthEnv()),
|
|
168
168
|
[],
|
|
169
169
|
);
|
|
@@ -174,7 +174,7 @@ describe("validateCutover", () => {
|
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
it("reports all failing checks when multiple checks fail", async () => {
|
|
177
|
-
const client = new
|
|
177
|
+
const client = new RecordedAgentConfigClient(makeConfig({}), []);
|
|
178
178
|
const results = await validateCutover(client, AGENT_ID);
|
|
179
179
|
const failed = results.filter((r) => !r.passed);
|
|
180
180
|
expect(failed.length).toBeGreaterThanOrEqual(2);
|
|
@@ -192,7 +192,7 @@ describe("validateCutover", () => {
|
|
|
192
192
|
GH_APP_PRIVATE_KEY: "some-key",
|
|
193
193
|
// GH_APP_INSTALLATION_ID intentionally omitted — discovered at runtime
|
|
194
194
|
};
|
|
195
|
-
const client = new
|
|
195
|
+
const client = new RecordedAgentConfigClient(makeConfig(env), [
|
|
196
196
|
makeCron("cron-1"),
|
|
197
197
|
]);
|
|
198
198
|
const results = await validateCutover(client, AGENT_ID);
|
|
@@ -207,7 +207,7 @@ describe("validateCutover", () => {
|
|
|
207
207
|
GH_APP_ID: "12345",
|
|
208
208
|
GH_APP_INSTALLATION_ID: "67890",
|
|
209
209
|
};
|
|
210
|
-
const client = new
|
|
210
|
+
const client = new RecordedAgentConfigClient(makeConfig(env), [
|
|
211
211
|
makeCron("cron-1"),
|
|
212
212
|
]);
|
|
213
213
|
const results = await validateCutover(client, AGENT_ID);
|
|
@@ -222,7 +222,7 @@ describe("validateCutover", () => {
|
|
|
222
222
|
...makeCron("cron-disabled"),
|
|
223
223
|
enabled: false,
|
|
224
224
|
};
|
|
225
|
-
const client = new
|
|
225
|
+
const client = new RecordedAgentConfigClient(
|
|
226
226
|
makeConfig(makeAppAuthEnv()),
|
|
227
227
|
[disabledCron],
|
|
228
228
|
);
|
|
@@ -239,7 +239,7 @@ describe("validateCutover", () => {
|
|
|
239
239
|
GH_APP_PRIVATE_KEY: "some-key",
|
|
240
240
|
GH_APP_INSTALLATION_ID: "11111",
|
|
241
241
|
};
|
|
242
|
-
const client = new
|
|
242
|
+
const client = new RecordedAgentConfigClient(makeConfig(env), [
|
|
243
243
|
makeCron("cron-1"),
|
|
244
244
|
]);
|
|
245
245
|
const results = await validateCutover(client, AGENT_ID);
|
package/cutover-validate.ts
CHANGED
|
@@ -15,11 +15,11 @@ import type {
|
|
|
15
15
|
AgentConfigResponse,
|
|
16
16
|
AgentCronJob,
|
|
17
17
|
} from "@app-vitals/agent-admin";
|
|
18
|
+
import type { AgentConfigClient } from "./agent-config-client.ts";
|
|
18
19
|
|
|
19
20
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
|
-
export interface
|
|
22
|
-
getConfig(agentId: string): Promise<AgentConfigResponse>;
|
|
22
|
+
export interface CutoverConfigClient extends AgentConfigClient {
|
|
23
23
|
getCrons(agentId: string): Promise<AgentCronJob[]>;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -39,7 +39,7 @@ function hasGitHubAuth(env: Record<string, string>): boolean {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export async function validateCutover(
|
|
42
|
-
client:
|
|
42
|
+
client: CutoverConfigClient,
|
|
43
43
|
agentId: string,
|
|
44
44
|
): Promise<CheckResult[]> {
|
|
45
45
|
const [config, crons] = await Promise.all([
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* agent/src/runtime/entrypoint.integration.test.ts
|
|
3
3
|
*
|
|
4
4
|
* Integration tests for the startup sequence in entrypoint.ts.
|
|
5
|
-
* Uses
|
|
5
|
+
* Uses RecordedAgentConfigClient (cassette-backed) — no real HTTP.
|
|
6
6
|
* Isolation contract: no mock.module(), no global overrides.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -12,8 +12,8 @@ import { join } from "node:path";
|
|
|
12
12
|
import type { AgentConfigResponse } from "@app-vitals/agent-admin";
|
|
13
13
|
import type { EntrypointDeps } from "./entrypoint.ts";
|
|
14
14
|
import { runEntrypoint } from "./entrypoint.ts";
|
|
15
|
-
import type {
|
|
16
|
-
import {
|
|
15
|
+
import type { AgentConfigClient } from "./agent-config-client.ts";
|
|
16
|
+
import { RecordedAgentConfigClient } from "./agent-config-client.ts";
|
|
17
17
|
|
|
18
18
|
// ─── Test fixtures ─────────────────────────────────────────────────────────────
|
|
19
19
|
|
|
@@ -70,7 +70,7 @@ interface SetupGitHubAuthCall {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function makeDeps(
|
|
73
|
-
configClient:
|
|
73
|
+
configClient: AgentConfigClient,
|
|
74
74
|
overrides: Partial<EntrypointDeps> = {},
|
|
75
75
|
): {
|
|
76
76
|
deps: EntrypointDeps;
|
|
@@ -138,7 +138,7 @@ function makeDeps(
|
|
|
138
138
|
|
|
139
139
|
describe("runEntrypoint — happy path", () => {
|
|
140
140
|
it("exports the specialty to the server process when the config carries one (ARD-6.1)", async () => {
|
|
141
|
-
const configClient = new
|
|
141
|
+
const configClient = new RecordedAgentConfigClient({
|
|
142
142
|
...SAMPLE_CONFIG,
|
|
143
143
|
specialty: "specialist",
|
|
144
144
|
});
|
|
@@ -148,14 +148,14 @@ describe("runEntrypoint — happy path", () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
it("exports the default specialty from the config bundle (AGR-6.3: specialty is required)", async () => {
|
|
151
|
-
const configClient = new
|
|
151
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
152
152
|
const { deps, appliedEnv } = makeDeps(configClient);
|
|
153
153
|
await runEntrypoint(deps);
|
|
154
154
|
expect(appliedEnv.SHIPWRIGHT_AGENT_TYPE).toBe("coding");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
157
|
it("fetches config and applies env vars from the bundle", async () => {
|
|
158
|
-
const configClient = new
|
|
158
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
159
159
|
const { deps, appliedEnv } = makeDeps(configClient);
|
|
160
160
|
|
|
161
161
|
await runEntrypoint(deps);
|
|
@@ -165,7 +165,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it("symlinks ~/.claude to $AGENT_HOME/dot-claude", async () => {
|
|
168
|
-
const configClient = new
|
|
168
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
169
169
|
const { deps, symlinkCalls } = makeDeps(configClient);
|
|
170
170
|
|
|
171
171
|
await runEntrypoint(deps);
|
|
@@ -175,7 +175,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
it("calls setupGitHubAuth", async () => {
|
|
178
|
-
const configClient = new
|
|
178
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
179
179
|
const { deps, githubAuthCalled } = makeDeps(configClient);
|
|
180
180
|
|
|
181
181
|
await runEntrypoint(deps);
|
|
@@ -184,7 +184,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
it("calls runMiseStartup with agentHome", async () => {
|
|
187
|
-
const configClient = new
|
|
187
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
188
188
|
const { deps, miseCalls } = makeDeps(configClient);
|
|
189
189
|
|
|
190
190
|
await runEntrypoint(deps);
|
|
@@ -194,7 +194,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
it("calls installPlugins with plugins from config", async () => {
|
|
197
|
-
const configClient = new
|
|
197
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
198
198
|
const { deps, pluginCalls } = makeDeps(configClient);
|
|
199
199
|
|
|
200
200
|
await runEntrypoint(deps);
|
|
@@ -203,7 +203,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it("spawns the agent server after setup completes", async () => {
|
|
206
|
-
const configClient = new
|
|
206
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
207
207
|
const { deps, spawnCalls } = makeDeps(configClient);
|
|
208
208
|
|
|
209
209
|
await runEntrypoint(deps);
|
|
@@ -213,7 +213,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
it("does not call exit on success", async () => {
|
|
216
|
-
const configClient = new
|
|
216
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
217
217
|
const { deps, exitCodes } = makeDeps(configClient);
|
|
218
218
|
|
|
219
219
|
await runEntrypoint(deps);
|
|
@@ -224,7 +224,7 @@ describe("runEntrypoint — happy path", () => {
|
|
|
224
224
|
|
|
225
225
|
describe("runEntrypoint — missing required vars", () => {
|
|
226
226
|
it("exits non-zero when agentId is missing", async () => {
|
|
227
|
-
const configClient = new
|
|
227
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
228
228
|
const { deps, exitCodes } = makeDeps(configClient, { agentId: undefined });
|
|
229
229
|
|
|
230
230
|
await runEntrypoint(deps);
|
|
@@ -234,7 +234,7 @@ describe("runEntrypoint — missing required vars", () => {
|
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
it("exits non-zero when apiUrl is missing", async () => {
|
|
237
|
-
const configClient = new
|
|
237
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
238
238
|
const { deps, exitCodes } = makeDeps(configClient, { apiUrl: undefined });
|
|
239
239
|
|
|
240
240
|
await runEntrypoint(deps);
|
|
@@ -244,7 +244,7 @@ describe("runEntrypoint — missing required vars", () => {
|
|
|
244
244
|
});
|
|
245
245
|
|
|
246
246
|
it("exits non-zero when apiKey is missing", async () => {
|
|
247
|
-
const configClient = new
|
|
247
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
248
248
|
const { deps, exitCodes } = makeDeps(configClient, { apiKey: undefined });
|
|
249
249
|
|
|
250
250
|
await runEntrypoint(deps);
|
|
@@ -254,9 +254,9 @@ describe("runEntrypoint — missing required vars", () => {
|
|
|
254
254
|
});
|
|
255
255
|
|
|
256
256
|
it("does not fetch config when required vars are missing", async () => {
|
|
257
|
-
const configClient = new
|
|
257
|
+
const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
|
|
258
258
|
let fetchCalled = false;
|
|
259
|
-
const trackingClient:
|
|
259
|
+
const trackingClient: AgentConfigClient = {
|
|
260
260
|
getConfig: async (agentId: string) => {
|
|
261
261
|
fetchCalled = true;
|
|
262
262
|
return configClient.getConfig(agentId);
|
|
@@ -289,7 +289,7 @@ describe("runEntrypoint — config with empty env", () => {
|
|
|
289
289
|
cleanupMergedWorktrees: true,
|
|
290
290
|
cleanupAfterDays: 14,
|
|
291
291
|
};
|
|
292
|
-
const configClient = new
|
|
292
|
+
const configClient = new RecordedAgentConfigClient(emptyConfig);
|
|
293
293
|
const { deps, exitCodes, spawnCalls } = makeDeps(configClient);
|
|
294
294
|
|
|
295
295
|
await runEntrypoint(deps);
|
|
@@ -302,7 +302,7 @@ describe("runEntrypoint — config with empty env", () => {
|
|
|
302
302
|
|
|
303
303
|
describe("runEntrypoint — startup timeout", () => {
|
|
304
304
|
it("exits non-zero when startup exceeds startupTimeoutMs", async () => {
|
|
305
|
-
const hangingClient:
|
|
305
|
+
const hangingClient: AgentConfigClient = {
|
|
306
306
|
getConfig: () => new Promise(() => {}), // never resolves
|
|
307
307
|
};
|
|
308
308
|
const { deps, exitCodes } = makeDeps(hangingClient, {
|
package/entrypoint.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import type { AgentPlugin } from "@app-vitals/agent-admin";
|
|
22
|
-
import type {
|
|
22
|
+
import type { AgentConfigClient } from "./agent-config-client.ts";
|
|
23
23
|
|
|
24
24
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
25
|
|
|
@@ -34,7 +34,7 @@ export interface EntrypointDeps {
|
|
|
34
34
|
apiUrl: string | undefined;
|
|
35
35
|
apiKey: string | undefined;
|
|
36
36
|
agentHome: string;
|
|
37
|
-
configClient:
|
|
37
|
+
configClient: AgentConfigClient;
|
|
38
38
|
applyEnv: (env: Record<string, string>) => void;
|
|
39
39
|
symlinkDotClaude: (target: string, linkPath: string) => void;
|
|
40
40
|
setupGitHubAuth: () => Promise<void>;
|
|
@@ -98,7 +98,7 @@ export async function runEntrypoint(deps: EntrypointDeps): Promise<void> {
|
|
|
98
98
|
|
|
99
99
|
const startup = async () => {
|
|
100
100
|
// Step 2: Fetch config from Shipwright API
|
|
101
|
-
let config: Awaited<ReturnType<
|
|
101
|
+
let config: Awaited<ReturnType<AgentConfigClient["getConfig"]>>;
|
|
102
102
|
try {
|
|
103
103
|
config = await configClient.getConfig(agentId as string);
|
|
104
104
|
console.log(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"./workspace-paths": "./workspace-paths.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@app-vitals/agent-admin": "1.
|
|
44
|
-
"@app-vitals/agent-lib": "1.
|
|
43
|
+
"@app-vitals/agent-admin": "1.1.0",
|
|
44
|
+
"@app-vitals/agent-lib": "1.1.0",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|
|
@@ -156,6 +156,7 @@ describe("Slack chat conversation journey — DM multi-turn continuity", () => {
|
|
|
156
156
|
"[U1]: Hey, can you look into the failing build?",
|
|
157
157
|
"D100:1000.001",
|
|
158
158
|
expect.any(Function),
|
|
159
|
+
expect.any(AbortSignal),
|
|
159
160
|
{ SLACK_CHANNEL_ID: "D100", SLACK_THREAD_TS: "1000.001" },
|
|
160
161
|
);
|
|
161
162
|
expect(say1).toHaveBeenCalledWith({
|
|
@@ -200,6 +201,7 @@ describe("Slack chat conversation journey — DM multi-turn continuity", () => {
|
|
|
200
201
|
"[U1]: Any update?",
|
|
201
202
|
establishedKey,
|
|
202
203
|
expect.any(Function),
|
|
204
|
+
expect.any(AbortSignal),
|
|
203
205
|
{ SLACK_CHANNEL_ID: "D100", SLACK_THREAD_TS: "1000.001" },
|
|
204
206
|
);
|
|
205
207
|
expect(say2).toHaveBeenCalledWith({
|
|
@@ -250,6 +252,7 @@ describe("Slack chat conversation journey — channel app_mention thread continu
|
|
|
250
252
|
"[U2]: <@UBOT123> can you check the deploy?",
|
|
251
253
|
mentionKey,
|
|
252
254
|
expect.any(Function),
|
|
255
|
+
expect.any(AbortSignal),
|
|
253
256
|
{ SLACK_CHANNEL_ID: "C500", SLACK_THREAD_TS: "2000.001" },
|
|
254
257
|
);
|
|
255
258
|
expect(say1).toHaveBeenCalledWith({
|
|
@@ -291,6 +294,7 @@ describe("Slack chat conversation journey — channel app_mention thread continu
|
|
|
291
294
|
"[Thread message — respond normally, or use [silent] if no response is needed]\n[U2]: thanks, any update?",
|
|
292
295
|
mentionKey,
|
|
293
296
|
expect.any(Function),
|
|
297
|
+
expect.any(AbortSignal),
|
|
294
298
|
{ SLACK_CHANNEL_ID: "C500", SLACK_THREAD_TS: "2000.001" },
|
|
295
299
|
);
|
|
296
300
|
expect(say2).toHaveBeenCalledWith({
|
|
@@ -404,6 +404,7 @@ describe("message handler — DM routing", () => {
|
|
|
404
404
|
"Hello from rich text",
|
|
405
405
|
"D123:111.222",
|
|
406
406
|
expect.any(Function),
|
|
407
|
+
expect.any(AbortSignal),
|
|
407
408
|
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
408
409
|
);
|
|
409
410
|
});
|
|
@@ -429,6 +430,7 @@ describe("message handler — DM routing", () => {
|
|
|
429
430
|
"Plain text wins",
|
|
430
431
|
"D123:111.222",
|
|
431
432
|
expect.any(Function),
|
|
433
|
+
expect.any(AbortSignal),
|
|
432
434
|
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
433
435
|
);
|
|
434
436
|
});
|
|
@@ -443,6 +445,7 @@ describe("message handler — DM routing", () => {
|
|
|
443
445
|
"Do the thing",
|
|
444
446
|
"D123:111.222",
|
|
445
447
|
expect.any(Function),
|
|
448
|
+
expect.any(AbortSignal),
|
|
446
449
|
{ SLACK_CHANNEL_ID: "D123", SLACK_THREAD_TS: "111.222" },
|
|
447
450
|
);
|
|
448
451
|
});
|
|
@@ -453,6 +456,7 @@ describe("message handler — DM routing", () => {
|
|
|
453
456
|
"Hello bot",
|
|
454
457
|
"D456:0.9",
|
|
455
458
|
expect.any(Function),
|
|
459
|
+
expect.any(AbortSignal),
|
|
456
460
|
{ SLACK_CHANNEL_ID: "D456", SLACK_THREAD_TS: "0.9" },
|
|
457
461
|
);
|
|
458
462
|
});
|
|
@@ -1009,6 +1013,7 @@ describe("message handler — channel thread routing", () => {
|
|
|
1009
1013
|
"[Thread message — respond normally, or use [silent] if no response is needed]\nFollowup message",
|
|
1010
1014
|
"C123:1.0",
|
|
1011
1015
|
expect.any(Function),
|
|
1016
|
+
expect.any(AbortSignal),
|
|
1012
1017
|
{ SLACK_CHANNEL_ID: "C123", SLACK_THREAD_TS: "1.0" },
|
|
1013
1018
|
);
|
|
1014
1019
|
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
@@ -1062,6 +1067,7 @@ describe("message handler — channel thread routing", () => {
|
|
|
1062
1067
|
"[Thread message — respond normally, or use [silent] if no response is needed]\nFollowup message",
|
|
1063
1068
|
"C123:1.0",
|
|
1064
1069
|
expect.any(Function),
|
|
1070
|
+
expect.any(AbortSignal),
|
|
1065
1071
|
{ SLACK_CHANNEL_ID: "C123", SLACK_THREAD_TS: "1.0" },
|
|
1066
1072
|
);
|
|
1067
1073
|
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
@@ -1085,6 +1091,7 @@ describe("message handler — channel thread routing", () => {
|
|
|
1085
1091
|
"[Thread message — respond normally, or use [silent] if no response is needed]\nGood work bot",
|
|
1086
1092
|
"C-CRON:200.0",
|
|
1087
1093
|
expect.any(Function),
|
|
1094
|
+
expect.any(AbortSignal),
|
|
1088
1095
|
{ SLACK_CHANNEL_ID: "C-CRON", SLACK_THREAD_TS: "200.0" },
|
|
1089
1096
|
);
|
|
1090
1097
|
expect(client.chat.appendStream).toHaveBeenCalled();
|
|
@@ -1254,6 +1261,7 @@ describe("message handler — file handling", () => {
|
|
|
1254
1261
|
"[file: /tmp/test-image.jpg]\nlook at this",
|
|
1255
1262
|
"D1:1.1",
|
|
1256
1263
|
expect.any(Function),
|
|
1264
|
+
expect.any(AbortSignal),
|
|
1257
1265
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1258
1266
|
);
|
|
1259
1267
|
});
|
|
@@ -1279,6 +1287,7 @@ describe("message handler — file handling", () => {
|
|
|
1279
1287
|
"[file: /tmp/test.pdf]",
|
|
1280
1288
|
"D1:1.1",
|
|
1281
1289
|
expect.any(Function),
|
|
1290
|
+
expect.any(AbortSignal),
|
|
1282
1291
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1283
1292
|
);
|
|
1284
1293
|
});
|
|
@@ -1305,6 +1314,7 @@ describe("message handler — file handling", () => {
|
|
|
1305
1314
|
"here is a file",
|
|
1306
1315
|
"D1:1.1",
|
|
1307
1316
|
expect.any(Function),
|
|
1317
|
+
expect.any(AbortSignal),
|
|
1308
1318
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1309
1319
|
);
|
|
1310
1320
|
});
|
|
@@ -1333,6 +1343,7 @@ describe("message handler — file handling", () => {
|
|
|
1333
1343
|
"with erroring file",
|
|
1334
1344
|
"D1:1.1",
|
|
1335
1345
|
expect.any(Function),
|
|
1346
|
+
expect.any(AbortSignal),
|
|
1336
1347
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
1337
1348
|
);
|
|
1338
1349
|
});
|
|
@@ -1390,6 +1401,7 @@ describe("app_mention handler", () => {
|
|
|
1390
1401
|
"<@UBOT> do something",
|
|
1391
1402
|
"C999:222.333",
|
|
1392
1403
|
expect.any(Function),
|
|
1404
|
+
expect.any(AbortSignal),
|
|
1393
1405
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1394
1406
|
);
|
|
1395
1407
|
});
|
|
@@ -1400,6 +1412,7 @@ describe("app_mention handler", () => {
|
|
|
1400
1412
|
"<@UBOT> do something",
|
|
1401
1413
|
"C999:1.1",
|
|
1402
1414
|
expect.any(Function),
|
|
1415
|
+
expect.any(AbortSignal),
|
|
1403
1416
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "1.1" },
|
|
1404
1417
|
);
|
|
1405
1418
|
});
|
|
@@ -1713,6 +1726,7 @@ describe("app_mention handler — file handling", () => {
|
|
|
1713
1726
|
"[file: /tmp/test-mention-image.jpg]\n<@UBOT> look at this",
|
|
1714
1727
|
"C999:222.333",
|
|
1715
1728
|
expect.any(Function),
|
|
1729
|
+
expect.any(AbortSignal),
|
|
1716
1730
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1717
1731
|
);
|
|
1718
1732
|
});
|
|
@@ -1745,6 +1759,7 @@ describe("app_mention handler — file handling", () => {
|
|
|
1745
1759
|
"<@UBOT> here is a file",
|
|
1746
1760
|
"C999:222.333",
|
|
1747
1761
|
expect.any(Function),
|
|
1762
|
+
expect.any(AbortSignal),
|
|
1748
1763
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1749
1764
|
);
|
|
1750
1765
|
});
|
|
@@ -1763,6 +1778,7 @@ describe("app_mention handler — file handling", () => {
|
|
|
1763
1778
|
"<@UBOT> with erroring file",
|
|
1764
1779
|
"C999:222.333",
|
|
1765
1780
|
expect.any(Function),
|
|
1781
|
+
expect.any(AbortSignal),
|
|
1766
1782
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1767
1783
|
);
|
|
1768
1784
|
});
|
|
@@ -1795,6 +1811,7 @@ describe("app_mention handler — file handling", () => {
|
|
|
1795
1811
|
"[file: /tmp/mention-image.jpg]\n<@UBOT> look at this",
|
|
1796
1812
|
"C999:222.333",
|
|
1797
1813
|
expect.any(Function),
|
|
1814
|
+
expect.any(AbortSignal),
|
|
1798
1815
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
1799
1816
|
);
|
|
1800
1817
|
});
|
|
@@ -2088,6 +2105,7 @@ describe("user name context — message handler", () => {
|
|
|
2088
2105
|
"[Dan]: hello",
|
|
2089
2106
|
"D1:1.1",
|
|
2090
2107
|
expect.any(Function),
|
|
2108
|
+
expect.any(AbortSignal),
|
|
2091
2109
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
2092
2110
|
);
|
|
2093
2111
|
});
|
|
@@ -2109,6 +2127,7 @@ describe("user name context — message handler", () => {
|
|
|
2109
2127
|
"hello",
|
|
2110
2128
|
"D1:1.1",
|
|
2111
2129
|
expect.any(Function),
|
|
2130
|
+
expect.any(AbortSignal),
|
|
2112
2131
|
{ SLACK_CHANNEL_ID: "D1", SLACK_THREAD_TS: "1.1" },
|
|
2113
2132
|
);
|
|
2114
2133
|
});
|
|
@@ -2138,6 +2157,7 @@ describe("user name context — app_mention handler", () => {
|
|
|
2138
2157
|
"[Dan]: <@UBOT> do something",
|
|
2139
2158
|
"C999:222.333",
|
|
2140
2159
|
expect.any(Function),
|
|
2160
|
+
expect.any(AbortSignal),
|
|
2141
2161
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
2142
2162
|
);
|
|
2143
2163
|
});
|
|
@@ -2158,6 +2178,7 @@ describe("user name context — app_mention handler", () => {
|
|
|
2158
2178
|
"<@UBOT> do something",
|
|
2159
2179
|
"C999:222.333",
|
|
2160
2180
|
expect.any(Function),
|
|
2181
|
+
expect.any(AbortSignal),
|
|
2161
2182
|
{ SLACK_CHANNEL_ID: "C999", SLACK_THREAD_TS: "222.333" },
|
|
2162
2183
|
);
|
|
2163
2184
|
});
|
package/slack.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
} from "./chat-token-reporter.ts";
|
|
32
32
|
import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
|
|
33
33
|
import {
|
|
34
|
+
ClaudeAbortedError,
|
|
34
35
|
ClaudeRunError,
|
|
35
36
|
ClaudeTimeoutError,
|
|
36
37
|
reportClaudeError,
|
|
@@ -157,6 +158,7 @@ export type ClaudeRunner = (
|
|
|
157
158
|
message: string,
|
|
158
159
|
sessionKey?: string,
|
|
159
160
|
onProgress?: ProgressCallback,
|
|
161
|
+
signal?: AbortSignal,
|
|
160
162
|
extraEnv?: Record<string, string>,
|
|
161
163
|
) => Promise<ClaudeRunResult>;
|
|
162
164
|
|
|
@@ -596,11 +598,17 @@ export function createSlackApp(
|
|
|
596
598
|
// (right after finish()) — so it never leaks entries for completed runs.
|
|
597
599
|
// Closure-scoped (one instance per createSlackApp() call), same lifecycle
|
|
598
600
|
// as threadStatusTracker above — never module-level.
|
|
599
|
-
const liveStreams = new Map<
|
|
600
|
-
|
|
601
|
-
|
|
601
|
+
const liveStreams = new Map<
|
|
602
|
+
string,
|
|
603
|
+
{ progress: SlackProgress; controller: AbortController }
|
|
604
|
+
>();
|
|
605
|
+
|
|
606
|
+
function registerStream(
|
|
607
|
+
progress: SlackProgress,
|
|
608
|
+
controller: AbortController,
|
|
609
|
+
): void {
|
|
602
610
|
const key = progress.streamKey();
|
|
603
|
-
if (key) liveStreams.set(key, progress);
|
|
611
|
+
if (key) liveStreams.set(key, { progress, controller });
|
|
604
612
|
}
|
|
605
613
|
|
|
606
614
|
function unregisterStream(progress: SlackProgress): void {
|
|
@@ -706,7 +714,8 @@ export function createSlackApp(
|
|
|
706
714
|
// Registers the stream (no-op when startStream() failed to open one —
|
|
707
715
|
// streamKey() is undefined either way) so agent_session_stopped can look
|
|
708
716
|
// this instance up (AGS-1.1).
|
|
709
|
-
|
|
717
|
+
const runController = new AbortController();
|
|
718
|
+
registerStream(progress, runController);
|
|
710
719
|
|
|
711
720
|
const rawText =
|
|
712
721
|
msg.text || (hasRichText ? richTextToMarkdown(msg.blocks ?? []) : "");
|
|
@@ -734,10 +743,16 @@ export function createSlackApp(
|
|
|
734
743
|
// rather than the generic "Done" when the reply was suppressed.
|
|
735
744
|
let wasSuppressed = false;
|
|
736
745
|
try {
|
|
737
|
-
const runResult = await runner(
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
746
|
+
const runResult = await runner(
|
|
747
|
+
prompt,
|
|
748
|
+
sessionKey,
|
|
749
|
+
progress.onProgress,
|
|
750
|
+
runController.signal,
|
|
751
|
+
{
|
|
752
|
+
SLACK_CHANNEL_ID: msg.channel,
|
|
753
|
+
SLACK_THREAD_TS: replyTs,
|
|
754
|
+
},
|
|
755
|
+
);
|
|
741
756
|
if (runResult.streamIncomplete) {
|
|
742
757
|
// Clean process exit, but the stream never emitted a terminal
|
|
743
758
|
// `result` event — treat this the same as a genuine failure rather
|
|
@@ -812,6 +827,13 @@ export function createSlackApp(
|
|
|
812
827
|
postedText: shouldSuppress ? undefined : cleaned,
|
|
813
828
|
});
|
|
814
829
|
} catch (err) {
|
|
830
|
+
// A user-initiated Stop (agent_session_stopped aborts runController) makes
|
|
831
|
+
// the runner reject with ClaudeAbortedError — a deliberate cancel, not a
|
|
832
|
+
// failure: no Sentry report and no error message in the thread.
|
|
833
|
+
if (err instanceof ClaudeAbortedError) {
|
|
834
|
+
console.info("[slack] run aborted by user stop");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
815
837
|
console.error("[slack] error:", err);
|
|
816
838
|
reportClaudeError(sentryClient, err);
|
|
817
839
|
// Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
|
|
@@ -900,7 +922,8 @@ export function createSlackApp(
|
|
|
900
922
|
await startProgress(progress, threadStatusTracker.enter(sessionKey));
|
|
901
923
|
// Registers the stream so agent_session_stopped can look this instance
|
|
902
924
|
// up (AGS-1.1) — see the app.message handler's identical comment above.
|
|
903
|
-
|
|
925
|
+
const runController = new AbortController();
|
|
926
|
+
registerStream(progress, runController);
|
|
904
927
|
|
|
905
928
|
let prompt = await buildPromptWithFiles(
|
|
906
929
|
ev.text,
|
|
@@ -952,10 +975,16 @@ export function createSlackApp(
|
|
|
952
975
|
// rather than the generic "Done" when the reply was suppressed.
|
|
953
976
|
let wasSuppressed = false;
|
|
954
977
|
try {
|
|
955
|
-
const runResult = await runner(
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
978
|
+
const runResult = await runner(
|
|
979
|
+
prompt,
|
|
980
|
+
sessionKey,
|
|
981
|
+
progress.onProgress,
|
|
982
|
+
runController.signal,
|
|
983
|
+
{
|
|
984
|
+
SLACK_CHANNEL_ID: ev.channel,
|
|
985
|
+
SLACK_THREAD_TS: replyTs,
|
|
986
|
+
},
|
|
987
|
+
);
|
|
959
988
|
if (runResult.streamIncomplete) {
|
|
960
989
|
// Clean process exit, but the stream never emitted a terminal
|
|
961
990
|
// `result` event — treat this the same as a genuine failure rather
|
|
@@ -1024,6 +1053,13 @@ export function createSlackApp(
|
|
|
1024
1053
|
postedText: isSilent ? undefined : cleaned,
|
|
1025
1054
|
});
|
|
1026
1055
|
} catch (err) {
|
|
1056
|
+
// A user-initiated Stop (agent_session_stopped aborts runController) makes
|
|
1057
|
+
// the runner reject with ClaudeAbortedError — a deliberate cancel, not a
|
|
1058
|
+
// failure: no Sentry report and no error message in the thread.
|
|
1059
|
+
if (err instanceof ClaudeAbortedError) {
|
|
1060
|
+
console.info("[slack] run aborted by user stop");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1027
1063
|
console.error("[slack] error:", err);
|
|
1028
1064
|
reportClaudeError(sentryClient, err);
|
|
1029
1065
|
// Mark the Thinking Steps card status:"error" (STS2-4.1 AC #1) BEFORE the
|
|
@@ -1103,17 +1139,24 @@ export function createSlackApp(
|
|
|
1103
1139
|
await startProgress(progress, threadStatusTracker.enter(sessionKey));
|
|
1104
1140
|
// Registers the stream so agent_session_stopped can look this instance
|
|
1105
1141
|
// up (AGS-1.1) — see the app.message handler's identical comment above.
|
|
1106
|
-
|
|
1142
|
+
const runController = new AbortController();
|
|
1143
|
+
registerStream(progress, runController);
|
|
1107
1144
|
|
|
1108
1145
|
// Set inside the try block below (STS2-3.1) — hoisted so finishProgress()
|
|
1109
1146
|
// in the finally block can title the stream's completion card "Ack"
|
|
1110
1147
|
// rather than the generic "Done" when the reply was suppressed.
|
|
1111
1148
|
let wasSuppressed = false;
|
|
1112
1149
|
try {
|
|
1113
|
-
const runResult = await runner(
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1150
|
+
const runResult = await runner(
|
|
1151
|
+
prompt,
|
|
1152
|
+
sessionKey,
|
|
1153
|
+
progress.onProgress,
|
|
1154
|
+
runController.signal,
|
|
1155
|
+
{
|
|
1156
|
+
SLACK_CHANNEL_ID: ev.item.channel,
|
|
1157
|
+
SLACK_THREAD_TS: ev.item.ts,
|
|
1158
|
+
},
|
|
1159
|
+
);
|
|
1117
1160
|
if (runResult.streamIncomplete) {
|
|
1118
1161
|
// Clean process exit, but the stream never emitted a terminal
|
|
1119
1162
|
// `result` event — treat this the same as a genuine failure rather
|
|
@@ -1246,10 +1289,13 @@ export function createSlackApp(
|
|
|
1246
1289
|
let threadTs: string | undefined;
|
|
1247
1290
|
if (ev.channel && ev.streaming_message_ts) {
|
|
1248
1291
|
const key = `${ev.channel}:${ev.streaming_message_ts}`;
|
|
1249
|
-
const
|
|
1250
|
-
if (
|
|
1292
|
+
const live = liveStreams.get(key);
|
|
1293
|
+
if (live) {
|
|
1294
|
+
const { progress, controller } = live;
|
|
1251
1295
|
threadTs = progress.getThreadTs();
|
|
1252
1296
|
progress.markExternallyStopped();
|
|
1297
|
+
// SSC-1.1: cancel the in-flight Claude run, not just the stream.
|
|
1298
|
+
controller.abort();
|
|
1253
1299
|
liveStreams.delete(key);
|
|
1254
1300
|
}
|
|
1255
1301
|
}
|
package/slack.unit.test.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { describe, expect, mock, test } from "bun:test";
|
|
19
19
|
import type { AgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
|
|
20
20
|
import { createAgentSlackMembershipRef } from "./agent-slack-membership-ref.ts";
|
|
21
|
+
import { ClaudeAbortedError } from "./claude.ts";
|
|
21
22
|
import {
|
|
22
23
|
createSlackApp as _createSlackApp,
|
|
23
24
|
dispatchMarkers,
|
|
@@ -675,7 +676,7 @@ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-
|
|
|
675
676
|
expect(runner).toHaveBeenCalledTimes(1);
|
|
676
677
|
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
677
678
|
const call = (runner as any).mock.calls[0];
|
|
678
|
-
expect(call[
|
|
679
|
+
expect(call[4]).toEqual({
|
|
679
680
|
SLACK_CHANNEL_ID: "D0EXAMPLE",
|
|
680
681
|
SLACK_THREAD_TS: "1700000000.000100",
|
|
681
682
|
});
|
|
@@ -711,7 +712,7 @@ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-
|
|
|
711
712
|
expect(runner).toHaveBeenCalledTimes(1);
|
|
712
713
|
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
713
714
|
const call = (runner as any).mock.calls[0];
|
|
714
|
-
expect(call[
|
|
715
|
+
expect(call[4]).toEqual({
|
|
715
716
|
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
716
717
|
SLACK_THREAD_TS: "1700000000.000150",
|
|
717
718
|
});
|
|
@@ -742,7 +743,7 @@ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-
|
|
|
742
743
|
expect(runner).toHaveBeenCalledTimes(1);
|
|
743
744
|
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
744
745
|
const call = (runner as any).mock.calls[0];
|
|
745
|
-
expect(call[
|
|
746
|
+
expect(call[4]).toEqual({
|
|
746
747
|
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
747
748
|
SLACK_THREAD_TS: "1700000000.000300",
|
|
748
749
|
});
|
|
@@ -778,7 +779,7 @@ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-
|
|
|
778
779
|
expect(runner).toHaveBeenCalledTimes(1);
|
|
779
780
|
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
780
781
|
const call = (runner as any).mock.calls[0];
|
|
781
|
-
expect(call[
|
|
782
|
+
expect(call[4]).toEqual({
|
|
782
783
|
SLACK_CHANNEL_ID: "C0EXAMPLE",
|
|
783
784
|
SLACK_THREAD_TS: "1700000000.000400",
|
|
784
785
|
});
|
|
@@ -808,7 +809,7 @@ describe("extraEnv passed to runner — SLACK_CHANNEL_ID / SLACK_THREAD_TS (STC-
|
|
|
808
809
|
expect(runner).toHaveBeenCalledTimes(1);
|
|
809
810
|
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
810
811
|
const call = (runner as any).mock.calls[0];
|
|
811
|
-
expect(call[
|
|
812
|
+
expect(call[4]).toEqual({
|
|
812
813
|
SLACK_CHANNEL_ID: "D0EXAMPLE",
|
|
813
814
|
SLACK_THREAD_TS: "1700000000.000500",
|
|
814
815
|
});
|
|
@@ -861,11 +862,22 @@ describe("agent_session_stopped handler", () => {
|
|
|
861
862
|
userId: string,
|
|
862
863
|
client: unknown,
|
|
863
864
|
) => Promise<string | undefined>;
|
|
865
|
+
rejectOnAbort?: boolean;
|
|
864
866
|
}) {
|
|
865
867
|
const runner = mock(
|
|
866
|
-
(
|
|
867
|
-
|
|
868
|
-
|
|
868
|
+
(
|
|
869
|
+
_msg: string,
|
|
870
|
+
_key?: string,
|
|
871
|
+
_onProgress?: unknown,
|
|
872
|
+
signal?: AbortSignal,
|
|
873
|
+
) =>
|
|
874
|
+
new Promise((_resolve, reject) => {
|
|
875
|
+
if (overrides.rejectOnAbort)
|
|
876
|
+
signal?.addEventListener("abort", () =>
|
|
877
|
+
reject(new ClaudeAbortedError()),
|
|
878
|
+
);
|
|
879
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberately never resolves (or rejects on abort) — type matches ClaudeRunner's return via cast
|
|
880
|
+
}) as any,
|
|
869
881
|
);
|
|
870
882
|
_createSlackApp(
|
|
871
883
|
runner,
|
|
@@ -927,6 +939,45 @@ describe("agent_session_stopped handler", () => {
|
|
|
927
939
|
expect(client.chat.appendStream).not.toHaveBeenCalled();
|
|
928
940
|
});
|
|
929
941
|
|
|
942
|
+
test("aborts the AbortSignal the runner observed for the in-flight run (SSC-1.1)", async () => {
|
|
943
|
+
const ref = createAgentSlackMembershipRef();
|
|
944
|
+
const { runner, client } = startAStream({
|
|
945
|
+
membershipRef: ref,
|
|
946
|
+
resolveUserEmailFn: async () => undefined,
|
|
947
|
+
});
|
|
948
|
+
await flushMicrotasks();
|
|
949
|
+
|
|
950
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double captures calls
|
|
951
|
+
const signal = (runner as any).mock.calls[0][3] as AbortSignal;
|
|
952
|
+
expect(signal).toBeInstanceOf(AbortSignal);
|
|
953
|
+
expect(signal.aborted).toBe(false);
|
|
954
|
+
|
|
955
|
+
await capturedAgentSessionStoppedHandler?.({
|
|
956
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
957
|
+
client,
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
expect(signal.aborted).toBe(true);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("a runner rejecting with ClaudeAbortedError after stop posts no error message (SSC-1.1)", async () => {
|
|
964
|
+
const ref = createAgentSlackMembershipRef();
|
|
965
|
+
const { client, say } = startAStream({
|
|
966
|
+
membershipRef: ref,
|
|
967
|
+
resolveUserEmailFn: async () => undefined,
|
|
968
|
+
rejectOnAbort: true,
|
|
969
|
+
});
|
|
970
|
+
await flushMicrotasks();
|
|
971
|
+
|
|
972
|
+
await capturedAgentSessionStoppedHandler?.({
|
|
973
|
+
event: { channel: "D123", streaming_message_ts: "stream.ts.1" },
|
|
974
|
+
client,
|
|
975
|
+
});
|
|
976
|
+
await flushMicrotasks();
|
|
977
|
+
|
|
978
|
+
expect(say).not.toHaveBeenCalled();
|
|
979
|
+
});
|
|
980
|
+
|
|
930
981
|
test("resets the thread status via a direct agents.sessions.setStatus('active') call", async () => {
|
|
931
982
|
const ref = createAgentSlackMembershipRef();
|
|
932
983
|
const { client } = startAStream({
|