@app-vitals/agent-runtime 0.16.2 → 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/Dockerfile CHANGED
@@ -175,7 +175,7 @@ RUN if [ -f bun.lock ]; then bun install --frozen-lockfile; else bun install; fi
175
175
  COPY runtime/ ./runtime/
176
176
  COPY admin/ ./admin/
177
177
  COPY lib/ ./lib/
178
- # Agent-type definitions (setup.ts reads identity.templatesDir from them).
178
+ # Agent-type definitions (setup.ts reads each type's templates/ from them).
179
179
  COPY agent-types/ ./agent-types/
180
180
  # Composition root spawned by runtime/entrypoint.ts (../index.ts).
181
181
  COPY index.ts ./index.ts
@@ -1,8 +1,8 @@
1
1
  /**
2
- * agent/src/runtime/shipwright-config-client.ts
2
+ * agent/src/runtime/agent-config-client.ts
3
3
  *
4
- * - ShipwrightConfigClient — interface for DI (used by entrypoint and tests)
5
- * - RecordedShipwrightConfigClient — cassette-backed double for tests
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 ShipwrightConfigClient {
16
+ export interface AgentConfigClient {
17
17
  getConfig(agentId: string): Promise<AgentConfigResponse>;
18
18
  }
19
19
 
20
- // ─── RecordedShipwrightConfigClient ───────────────────────────────────────────
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 RecordedShipwrightConfigClient implements ShipwrightConfigClient {
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, undefined, extraEnv),
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 ShipwrightConfigClient,
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 RecordedShipwrightConfigClient implements ShipwrightConfigClient {
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(makeConfig(env), [
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(makeConfig(env), [
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(makeConfig({}), []);
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 RecordedShipwrightConfigClient(makeConfig(env), [
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 RecordedShipwrightConfigClient(makeConfig(env), [
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 RecordedShipwrightConfigClient(
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 RecordedShipwrightConfigClient(makeConfig(env), [
242
+ const client = new RecordedAgentConfigClient(makeConfig(env), [
243
243
  makeCron("cron-1"),
244
244
  ]);
245
245
  const results = await validateCutover(client, AGENT_ID);
@@ -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 ShipwrightConfigClient {
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: ShipwrightConfigClient,
42
+ client: CutoverConfigClient,
43
43
  agentId: string,
44
44
  ): Promise<CheckResult[]> {
45
45
  const [config, crons] = await Promise.all([
@@ -7,43 +7,29 @@
7
7
  import { describe, expect, it } from "bun:test";
8
8
  import { existsSync } from "node:fs";
9
9
  import { join } from "node:path";
10
- import { SpecialtyRegistry } from "@app-vitals/agent-admin/specialty-manifest-loader";
10
+ import { SpecialtyTypes } from "@app-vitals/agent-admin/specialty-types";
11
11
  import {
12
12
  resolveDefaultSpecialtyName,
13
- resolveSpecialtiesDir,
14
13
  SHIPPED_AGENT_TYPES_DIR,
15
14
  } from "@app-vitals/agent-lib/default-specialty";
15
+ import { SPECIALIST_TYPE } from "@app-vitals/agent-lib/specialist-type";
16
16
  import { resolveTemplatesDir } from "./setup.ts";
17
17
 
18
18
  const EMPTY_ENV: Record<string, string | undefined> = {};
19
19
 
20
20
  describe("platform-only boot with empty env", () => {
21
- it("SpecialtyRegistry resolves the default specialist type from the shipped agent-types/ without warning", () => {
22
- const warnings: string[] = [];
23
- const registry = new SpecialtyRegistry({
24
- typesDir: resolveSpecialtiesDir(EMPTY_ENV),
25
- defaultTypeName: resolveDefaultSpecialtyName(EMPTY_ENV),
26
- warn: (m) => warnings.push(m),
27
- });
28
- const manifest = registry.getManifest(
29
- resolveDefaultSpecialtyName(EMPTY_ENV),
30
- );
31
- expect(manifest.metadata.name).toBe("specialist");
32
- expect(warnings).toEqual([]);
33
- expect(registry.listTypes().map((t) => t.name)).toContain("specialist");
21
+ it("the default type resolves to the built-in specialist type", () => {
22
+ const defaultName = resolveDefaultSpecialtyName(EMPTY_ENV);
23
+ const types = new SpecialtyTypes({ defaultTypeName: defaultName });
24
+ expect(defaultName).toBe("specialist");
25
+ expect(types.tryGetType(defaultName)).toBe(SPECIALIST_TYPE);
26
+ expect(types.listTypes().map((t) => t.name)).toEqual(["specialist"]);
34
27
  });
35
28
 
36
- it("an unknown type warns and resolves to the shipped specialist manifest", () => {
37
- const warnings: string[] = [];
38
- const registry = new SpecialtyRegistry({
39
- typesDir: SHIPPED_AGENT_TYPES_DIR,
40
- defaultTypeName: "specialist",
41
- warn: (m) => warnings.push(m),
42
- });
43
- expect(registry.getManifest("no-such-type").metadata.name).toBe(
44
- "specialist",
45
- );
46
- expect(warnings).toHaveLength(1);
29
+ it("a stored legacy type still resolves to the specialist floor, but is not a valid new type", () => {
30
+ const types = new SpecialtyTypes({ defaultTypeName: "specialist" });
31
+ expect(types.getType("no-such-type")).toBe(SPECIALIST_TYPE);
32
+ expect(types.tryGetType("no-such-type")).toBeUndefined();
47
33
  });
48
34
 
49
35
  it("runtime resolveTemplatesDir agrees: the default type's shipped templates, no warning", () => {
@@ -56,6 +42,12 @@ describe("platform-only boot with empty env", () => {
56
42
  );
57
43
  expect(warnings).toEqual([]);
58
44
  expect(dir).toBe(join(SHIPPED_AGENT_TYPES_DIR, "specialist", "templates"));
45
+ expect(dir).toBe(
46
+ join(import.meta.dir, "..", SPECIALIST_TYPE.templatesDir).replace(
47
+ /\/$/,
48
+ "",
49
+ ),
50
+ );
59
51
  expect(existsSync(join(dir, "CLAUDE.md.template"))).toBe(true);
60
52
  });
61
53
  });
@@ -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 RecordedShipwrightConfigClient (cassette-backed) — no real HTTP.
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 { ShipwrightConfigClient } from "./shipwright-config-client.ts";
16
- import { RecordedShipwrightConfigClient } from "./shipwright-config-client.ts";
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: ShipwrightConfigClient,
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 RecordedShipwrightConfigClient({
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
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 RecordedShipwrightConfigClient(SAMPLE_CONFIG);
257
+ const configClient = new RecordedAgentConfigClient(SAMPLE_CONFIG);
258
258
  let fetchCalled = false;
259
- const trackingClient: ShipwrightConfigClient = {
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 RecordedShipwrightConfigClient(emptyConfig);
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: ShipwrightConfigClient = {
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 { ShipwrightConfigClient } from "./shipwright-config-client.ts";
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: ShipwrightConfigClient;
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<ShipwrightConfigClient["getConfig"]>>;
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": "0.16.2",
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": "0.16.2",
44
- "@app-vitals/agent-lib": "0.16.2",
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",
@@ -92,18 +92,9 @@ describe("fixture specialty templates", () => {
92
92
  });
93
93
 
94
94
  describe("coding is not special-cased (PCD-2.2)", () => {
95
- const writeCodingManifest = (templatesDir: string) => {
95
+ it("resolves coding through the templates/ convention under the specialties dir", () => {
96
96
  const specialties = join(root, "specialties");
97
- mkdirSync(join(specialties, "coding"), { recursive: true });
98
- writeFileSync(
99
- join(specialties, "coding", "manifest.yaml"),
100
- `identity:\n templatesDir: ${templatesDir}\n`,
101
- );
102
- return specialties;
103
- };
104
-
105
- it("resolves coding through its manifest identity.templatesDir", () => {
106
- const tpl = join(root, "coding-tpl");
97
+ const tpl = join(specialties, "coding", "templates");
107
98
  mkdirSync(tpl, { recursive: true });
108
99
  for (const f of [
109
100
  "CLAUDE.md.template",
@@ -113,7 +104,7 @@ describe("coding is not special-cased (PCD-2.2)", () => {
113
104
  writeFileSync(join(tpl, f), "x");
114
105
  const warnings: string[] = [];
115
106
  const dir = resolveTemplatesDir("coding", root, (m) => warnings.push(m), {
116
- SHIPWRIGHT_SPECIALTIES_DIR: writeCodingManifest(tpl),
107
+ SHIPWRIGHT_SPECIALTIES_DIR: specialties,
117
108
  });
118
109
  expect(dir).toBe(tpl);
119
110
  expect(warnings).toEqual([]);
package/setup.ts CHANGED
@@ -20,12 +20,13 @@ import {
20
20
  unlinkSync,
21
21
  writeFileSync,
22
22
  } from "node:fs";
23
- import { dirname, join, resolve } from "node:path";
23
+ import { join, resolve } from "node:path";
24
24
  import type { AgentPlugin } from "@app-vitals/agent-admin";
25
25
  import {
26
26
  resolveDefaultSpecialtyName,
27
27
  resolveSpecialtiesDir,
28
28
  } from "@app-vitals/agent-lib/default-specialty";
29
+ import { SPECIALIST_TYPE } from "@app-vitals/agent-lib/specialist-type";
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Template loading
@@ -69,17 +70,17 @@ const REQUIRED_TEMPLATES: readonly string[] = [
69
70
  /**
70
71
  * Resolves the workspace template directory for a specialty (ARD-6.1).
71
72
  *
72
- * Reads `identity.templatesDir` from `<specialtiesDir>/<specialty>/manifest.yaml`,
73
- * where specialtiesDir is SHIPWRIGHT_SPECIALTIES_DIR or the shipped
74
- * `agent-types/` (shared helper: @app-vitals/agent-lib/default-specialty). A
75
- * relative templatesDir resolves against the repo root, then the specialties
76
- * dir's parent (how the shipped manifests spell it: `agent-types/<t>/templates/`),
77
- * then the manifest's own directory. An absent type resolves as the default
78
- * type (`specialist`, or SHIPWRIGHT_DEFAULT_AGENT_TYPE). Returns the built-in
79
- * (generic) workspace/ dir for any type that does not resolve (no manifest,
80
- * unparseable, or the directory is missing or lacks a required template),
81
- * logging a warning. An unknown requested type first falls back to the default
82
- * type's templates (with a warning) before the built-in dir. Never throws: a template lookup must not fail boot.
73
+ * A type's templates live at `<specialtiesDir>/<specialty>/templates/`, where
74
+ * specialtiesDir is SHIPWRIGHT_SPECIALTIES_DIR or the shipped `agent-types/`
75
+ * (shared helper: @app-vitals/agent-lib/default-specialty). The built-in
76
+ * specialist type additionally resolves its record's `templatesDir`
77
+ * (lib/specialist-type.ts) against the repo root. An absent type resolves as
78
+ * the default type (`specialist`, or SHIPWRIGHT_DEFAULT_AGENT_TYPE). Returns
79
+ * the built-in (generic) workspace/ dir for any type that does not resolve
80
+ * (the directory is missing or lacks a required template), logging a warning.
81
+ * An unknown requested type first falls back to the default type's templates
82
+ * (with a warning) before the built-in dir. Never throws: a template lookup
83
+ * must not fail boot.
83
84
  */
84
85
  export function resolveTemplatesDir(
85
86
  requestedType: string | undefined,
@@ -93,24 +94,13 @@ export function resolveTemplatesDir(
93
94
  const specialtiesDir = resolveSpecialtiesDir(env);
94
95
 
95
96
  const lookup = (name: string): string => {
96
- const manifestPath = join(specialtiesDir, name, "manifest.yaml");
97
- const manifest = Bun.YAML.parse(readFileSync(manifestPath, "utf8")) as {
98
- identity?: { templatesDir?: unknown };
99
- };
100
- const templatesDir = manifest?.identity?.templatesDir;
101
- if (typeof templatesDir !== "string" || templatesDir === "") {
102
- throw new Error("manifest has no identity.templatesDir");
97
+ const candidates = [join(specialtiesDir, name, "templates")];
98
+ if (name === SPECIALIST_TYPE.name) {
99
+ candidates.push(resolve(repoRoot, SPECIALIST_TYPE.templatesDir));
103
100
  }
104
- // Manifests spell templatesDir relative to the repo root, or (shipped
105
- // agent-types/, where the repo root may not be the image root) relative to
106
- // the specialties dir's parent; a self-contained specialty (e.g. the
107
- // platform test fixture) spells it relative to its own directory.
108
101
  const dir =
109
- [
110
- resolve(repoRoot, templatesDir),
111
- resolve(dirname(specialtiesDir), templatesDir),
112
- ].find((candidate) => existsSync(candidate)) ??
113
- resolve(dirname(manifestPath), templatesDir);
102
+ candidates.find((candidate) => existsSync(candidate)) ??
103
+ (candidates[0] as string);
114
104
  if (!statSync(dir).isDirectory()) {
115
105
  throw new Error(`${dir} is not a directory`);
116
106
  }
@@ -165,7 +155,7 @@ function readTemplate(
165
155
  `Missing workspace template: ${path}. The runtime ships no product ` +
166
156
  "templates of its own: set SHIPWRIGHT_SPECIALTIES_DIR to a directory " +
167
157
  "of specialties and SHIPWRIGHT_DEFAULT_AGENT_TYPE to the specialty " +
168
- "whose identity.templatesDir provides it.",
158
+ "whose templates/ directory provides it.",
169
159
  );
170
160
  }
171
161
  }
@@ -162,7 +162,7 @@ describe("discoverBakedMarketplaces", () => {
162
162
  });
163
163
 
164
164
  describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
165
- it("resolves the shipped specialist manifest to agent-types/specialist/templates/ with no warning (SAI-1.2)", async () => {
165
+ it("resolves the shipped specialist type to agent-types/specialist/templates/ with no warning (SAI-1.2)", async () => {
166
166
  const { resolveTemplatesDir } = await import("./setup.ts");
167
167
  const repoRoot = join(import.meta.dir, "..");
168
168
  const warnings: string[] = [];
@@ -234,10 +234,10 @@ describe("resolveTemplatesDir option resolution (AEP-1.2)", () => {
234
234
  SHIPWRIGHT_SPECIALTIES_DIR: "/fixture/types",
235
235
  },
236
236
  );
237
- // Missing fixture manifest → warns naming the type, falls back built-in.
237
+ // Missing fixture templates dir → warns naming the type, falls back built-in.
238
238
  expect(warnings).toHaveLength(1);
239
239
  expect(warnings[0]).toContain('"base"');
240
- expect(warnings[0]).toContain("/fixture/types/base/manifest.yaml");
240
+ expect(warnings[0]).toContain("/fixture/types/base/templates");
241
241
  expect(dir).toMatch(/workspace$/);
242
242
  });
243
243
  });
@@ -288,22 +288,18 @@ describe("SHIPWRIGHT_REPO_ROOT override (PCB-1.1)", () => {
288
288
  const makeRoot = () => {
289
289
  const root = mkdtempSync(join(tmpdir(), "repo-root-"));
290
290
  const dir = join(root, "types", "foo");
291
- mkdirSync(join(dir, "tpl"), { recursive: true });
292
- writeFileSync(
293
- join(dir, "manifest.yaml"),
294
- "identity:\n templatesDir: tpl\n",
295
- );
291
+ mkdirSync(join(dir, "templates"), { recursive: true });
296
292
  for (const f of [
297
293
  "CLAUDE.md.template",
298
294
  "SOUL.md.template",
299
295
  "IDENTITY.md.template",
300
296
  ]) {
301
- writeFileSync(join(dir, "tpl", f), "x");
297
+ writeFileSync(join(dir, "templates", f), "x");
302
298
  }
303
- return { root, tpl: join(dir, "tpl") };
299
+ return { root, tpl: join(dir, "templates") };
304
300
  };
305
301
 
306
- it("resolves templatesDir against SHIPWRIGHT_REPO_ROOT when set", () => {
302
+ it("resolves a type's templates/ under SHIPWRIGHT_SPECIALTIES_DIR when SHIPWRIGHT_REPO_ROOT is set", () => {
307
303
  const { root, tpl } = makeRoot();
308
304
  try {
309
305
  const warnings: string[] = [];
@@ -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<string, SlackProgress>();
600
-
601
- function registerStream(progress: SlackProgress): void {
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
- registerStream(progress);
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(prompt, sessionKey, progress.onProgress, {
738
- SLACK_CHANNEL_ID: msg.channel,
739
- SLACK_THREAD_TS: replyTs,
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
- registerStream(progress);
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(prompt, sessionKey, progress.onProgress, {
956
- SLACK_CHANNEL_ID: ev.channel,
957
- SLACK_THREAD_TS: replyTs,
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
- registerStream(progress);
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(prompt, sessionKey, progress.onProgress, {
1114
- SLACK_CHANNEL_ID: ev.item.channel,
1115
- SLACK_THREAD_TS: ev.item.ts,
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 progress = liveStreams.get(key);
1250
- if (progress) {
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
  }
@@ -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[3]).toEqual({
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[3]).toEqual({
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[3]).toEqual({
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[3]).toEqual({
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[3]).toEqual({
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
- (_msg: string, _key?: string) =>
867
- // biome-ignore lint/suspicious/noExplicitAny: deliberately never resolves — type matches ClaudeRunner's return via cast
868
- new Promise(() => {}) as any,
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({
@@ -4,7 +4,7 @@
4
4
  * The platform's SPECIALTY.md.template is the generic fallback for every agent
5
5
  * type, so it must stay a neutral stub. Specialty-specific pipeline content
6
6
  * (e.g. the coding specialty's Shipwright workflow) is shipped by the specialty
7
- * itself through its manifest's identity.templatesDir.
7
+ * itself in its templates/ directory.
8
8
  *
9
9
  * Content-assertion only: readFileSync, no I/O beyond local file reads.
10
10
  */
@@ -1,35 +0,0 @@
1
- # Minimal fixture specialty (AGR-1.9). Platform tests point
2
- # SHIPWRIGHT_SPECIALTIES_DIR at the parent of this directory and
3
- # SHIPWRIGHT_DEFAULT_AGENT_TYPE at "fixture", so the platform test suite needs
4
- # no coding specialty. It is test data, not a product specialty: no crons, no
5
- # plugins, no GitHub scope.
6
- apiVersion: shipwright.dev/v1alpha1
7
- kind: Specialty
8
- metadata:
9
- name: fixture
10
- displayName: Fixture Agent
11
- description: Minimal specialty used by the platform test suite.
12
- version: 1.0.0
13
- skills: []
14
- identity:
15
- # Resolved against the repo root first, then against this manifest's own directory.
16
- templatesDir: templates/
17
- crons: []
18
- plugins:
19
- - fixture-plugin
20
- tools:
21
- - Bash
22
- - WebSearch
23
- env:
24
- required:
25
- - key: CLAUDE_CODE_OAUTH_TOKEN
26
- description: Claude Code OAuth token used to authenticate the agent's Claude sessions.
27
- secret: true
28
- optional:
29
- - key: GH_TOKEN
30
- description: GitHub Personal Access Token.
31
- secret: true
32
- members: []
33
- repos: []
34
- chat: true
35
- voice: true