@app-vitals/agent-runtime 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +40 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/browser.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration test for launchBrowser() — a real I/O boundary (an actual
|
|
5
|
+
* Chromium process launch) with no existing coverage, so this exercises the
|
|
6
|
+
* real dependency rather than a double per docs/testing.md's integration
|
|
7
|
+
* layer definition.
|
|
8
|
+
*
|
|
9
|
+
* Verifies the browser launches under the same --no-sandbox flags the
|
|
10
|
+
* container's restricted securityContext requires (see agent/src/runtime/browser.ts)
|
|
11
|
+
* and that a trivial page can be navigated and read back.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import type { Browser } from "playwright";
|
|
16
|
+
import { launchBrowser } from "./browser.ts";
|
|
17
|
+
|
|
18
|
+
describe("launchBrowser", () => {
|
|
19
|
+
let browser: Browser | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
if (browser) {
|
|
23
|
+
await browser.close();
|
|
24
|
+
browser = undefined;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("launches successfully", async () => {
|
|
29
|
+
browser = await launchBrowser();
|
|
30
|
+
expect(browser.isConnected()).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("navigates a data: URL and reads back page content", async () => {
|
|
34
|
+
browser = await launchBrowser();
|
|
35
|
+
const page = await browser.newPage();
|
|
36
|
+
await page.goto(
|
|
37
|
+
"data:text/html,<title>shipwright-browser-test</title><h1>hello</h1>",
|
|
38
|
+
);
|
|
39
|
+
expect(await page.title()).toBe("shipwright-browser-test");
|
|
40
|
+
expect(await page.content()).toContain("hello");
|
|
41
|
+
await page.close();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("navigates about:blank and reports a ready document", async () => {
|
|
45
|
+
browser = await launchBrowser();
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
await page.goto("about:blank");
|
|
48
|
+
const readyState = await page.evaluate(() => document.readyState);
|
|
49
|
+
expect(readyState).toBe("complete");
|
|
50
|
+
await page.close();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/browser.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/browser.ts
|
|
3
|
+
*
|
|
4
|
+
* Launches a headless Chromium browser for the agent's browser-driven tools
|
|
5
|
+
* (e.g. the PW-1.x line of work). Runs under the container's restricted
|
|
6
|
+
* securityContext (runAsNonRoot, allowPrivilegeEscalation: false, no sudo) —
|
|
7
|
+
* there is no real Linux user-namespace sandbox available, so Chromium's own
|
|
8
|
+
* OS-level sandbox must be disabled via --no-sandbox / --disable-setuid-sandbox
|
|
9
|
+
* (and Playwright's chromiumSandbox option, which wraps the same behavior).
|
|
10
|
+
*
|
|
11
|
+
* Browser resolution: chromium.launch({ headless: true }) with no explicit
|
|
12
|
+
* `channel` resolves to the chromium-headless-shell executable whenever it's
|
|
13
|
+
* installed (confirmed against playwright-core@1.61.1's actual launch-time
|
|
14
|
+
* executable resolution: BrowserType.getExecutableName() returns
|
|
15
|
+
* "chromium-headless-shell" when options.headless is true and no channel is
|
|
16
|
+
* set — see server/chromium/chromium.ts, bundled into lib/coreBundle.js).
|
|
17
|
+
* This matches Playwright's documented default headless-mode behavior since
|
|
18
|
+
* v1.49 (https://github.com/microsoft/playwright/issues/33566) — no channel
|
|
19
|
+
* string needs to be passed. agent/Dockerfile installs exactly this browser
|
|
20
|
+
* (`playwright install chromium-headless-shell`, PW-1.1), so no other
|
|
21
|
+
* Chromium variant is present in the runtime image for headless launches to
|
|
22
|
+
* fall back to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Browser } from "playwright";
|
|
26
|
+
import { chromium } from "playwright";
|
|
27
|
+
|
|
28
|
+
export async function launchBrowser(): Promise<Browser> {
|
|
29
|
+
return chromium.launch({
|
|
30
|
+
headless: true,
|
|
31
|
+
chromiumSandbox: false,
|
|
32
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/chat-poller.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for the chat poll loop's start()/stop() timer lifecycle.
|
|
5
|
+
* Filed as integration (not unit) per docs/test-readiness/test-system.md: these
|
|
6
|
+
* tests drive `createChatPoller`'s real `setInterval`/`clearInterval` across
|
|
7
|
+
* real wall-clock waits (~100ms) to observe timer start/stop behavior, which
|
|
8
|
+
* puts them over the unit-layer <200ms hard cap and outside its "no I/O of any
|
|
9
|
+
* kind" boundary. Client and runner remain fake doubles — no real HTTP.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
13
|
+
import { createChatPoller } from "./chat-poller.ts";
|
|
14
|
+
import type { ChatServiceClient } from "./http-chat-service-client.ts";
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function makeThread(id: string) {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
agentId: "agent-1",
|
|
22
|
+
memberId: null,
|
|
23
|
+
title: null,
|
|
24
|
+
createdAt: new Date(),
|
|
25
|
+
updatedAt: new Date(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Create a fake ChatServiceClient. All methods are overridable per test. */
|
|
30
|
+
function makeFakeClient(
|
|
31
|
+
overrides: Partial<ChatServiceClient> = {},
|
|
32
|
+
): ChatServiceClient {
|
|
33
|
+
return {
|
|
34
|
+
listThreads: async () => ({ threads: [], total: 0, limit: 50, offset: 0 }),
|
|
35
|
+
claimMessage: async () => null,
|
|
36
|
+
heartbeat: async () => ({ cancelRequested: false }),
|
|
37
|
+
replyToMessage: async () => {
|
|
38
|
+
throw new Error("replyToMessage not stubbed for this test");
|
|
39
|
+
},
|
|
40
|
+
getAttachment: async () => null,
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── start/stop: timer lifecycle ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe("createChatPoller start/stop", () => {
|
|
48
|
+
it("start() invokes pollOnce on the configured interval, and stop() halts it", async () => {
|
|
49
|
+
const thread = makeThread("thread-timer");
|
|
50
|
+
const claimMessage = mock(async () => null);
|
|
51
|
+
const client = makeFakeClient({
|
|
52
|
+
listThreads: async () => ({
|
|
53
|
+
threads: [thread],
|
|
54
|
+
total: 1,
|
|
55
|
+
limit: 50,
|
|
56
|
+
offset: 0,
|
|
57
|
+
}),
|
|
58
|
+
claimMessage,
|
|
59
|
+
});
|
|
60
|
+
const runner = mock(async () => ({ result: "ok" }));
|
|
61
|
+
|
|
62
|
+
const poller = createChatPoller({ client, runner, intervalMs: 10 });
|
|
63
|
+
poller.start();
|
|
64
|
+
|
|
65
|
+
// Let a couple of intervals elapse.
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
67
|
+
poller.stop();
|
|
68
|
+
const callsAtStop = claimMessage.mock.calls.length;
|
|
69
|
+
expect(callsAtStop).toBeGreaterThan(0);
|
|
70
|
+
|
|
71
|
+
// No further polling after stop().
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
73
|
+
expect(claimMessage.mock.calls.length).toBe(callsAtStop);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("start() is a no-op when already running (does not create a second timer)", async () => {
|
|
77
|
+
const thread = makeThread("thread-timer-2");
|
|
78
|
+
const claimMessage = mock(async () => null);
|
|
79
|
+
const client = makeFakeClient({
|
|
80
|
+
listThreads: async () => ({
|
|
81
|
+
threads: [thread],
|
|
82
|
+
total: 1,
|
|
83
|
+
limit: 50,
|
|
84
|
+
offset: 0,
|
|
85
|
+
}),
|
|
86
|
+
claimMessage,
|
|
87
|
+
});
|
|
88
|
+
const runner = mock(async () => ({ result: "ok" }));
|
|
89
|
+
|
|
90
|
+
const poller = createChatPoller({ client, runner, intervalMs: 10 });
|
|
91
|
+
poller.start();
|
|
92
|
+
poller.start(); // second call should be a no-op guard
|
|
93
|
+
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
95
|
+
poller.stop();
|
|
96
|
+
|
|
97
|
+
// If a second timer had been created, calls would double up per tick.
|
|
98
|
+
// Just assert it ran and stopped cleanly without throwing.
|
|
99
|
+
expect(claimMessage.mock.calls.length).toBeGreaterThan(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("stop() is a no-op when the poller was never started", () => {
|
|
103
|
+
const client = makeFakeClient();
|
|
104
|
+
const runner = mock(async () => ({ result: "ok" }));
|
|
105
|
+
const poller = createChatPoller({ client, runner });
|
|
106
|
+
|
|
107
|
+
expect(() => poller.stop()).not.toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|
package/chat-poller.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/chat-poller.ts
|
|
3
|
+
*
|
|
4
|
+
* Chat poll loop — polls the chat service for pending user messages, runs them
|
|
5
|
+
* through Claude, and posts replies back.
|
|
6
|
+
*
|
|
7
|
+
* The poll loop:
|
|
8
|
+
* 1. Lists threads for the agent (via ChatServiceClient.listThreads)
|
|
9
|
+
* 2. For each thread, attempts to claim the next unclaimed user message
|
|
10
|
+
* 3. For a claimed message: runs it through the Claude runner with
|
|
11
|
+
* `chat:<threadId>` as the session key, then posts the reply
|
|
12
|
+
*
|
|
13
|
+
* Error isolation: failures on individual threads are caught and logged; other
|
|
14
|
+
* threads continue processing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import type { ClaudeRunResult, ProgressCallback } from "./claude.ts";
|
|
20
|
+
import type { ChatServiceClient } from "./http-chat-service-client.ts";
|
|
21
|
+
import { parseMarkers } from "./markers.ts";
|
|
22
|
+
import {
|
|
23
|
+
synthesizeSpeech,
|
|
24
|
+
transcribeAudio,
|
|
25
|
+
type VoiceConfig,
|
|
26
|
+
} from "./voice.ts";
|
|
27
|
+
|
|
28
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
type ChatRunner = (
|
|
31
|
+
message: string,
|
|
32
|
+
sessionKey?: string,
|
|
33
|
+
onProgress?: ProgressCallback,
|
|
34
|
+
signal?: AbortSignal,
|
|
35
|
+
) => Promise<ClaudeRunResult>;
|
|
36
|
+
|
|
37
|
+
/** DI seam for STT — defaults to `transcribeAudio` from ./voice.ts. */
|
|
38
|
+
export type TranscribeAudioFn = typeof transcribeAudio;
|
|
39
|
+
/** DI seam for TTS — defaults to `synthesizeSpeech` from ./voice.ts. */
|
|
40
|
+
export type SynthesizeSpeechFn = typeof synthesizeSpeech;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Audio attachment extensions recognized as voice notes. Detected by
|
|
44
|
+
* filename extension (not mimetype — the chat service doesn't surface one)
|
|
45
|
+
* against the same set of formats Slack's voice-note handling accepts.
|
|
46
|
+
*/
|
|
47
|
+
const AUDIO_EXTENSION_REGEX = /\.(webm|ogg|wav|m4a|mp3)$/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Heartbeat cadence while a reply is in flight.
|
|
51
|
+
*
|
|
52
|
+
* Interval-driven, NOT progress-driven. A single long-running tool call (a
|
|
53
|
+
* multi-minute `Bash`, say) emits one stream event and then nothing at all
|
|
54
|
+
* until it returns, so a heartbeat tied to Claude turns goes silent during
|
|
55
|
+
* exactly the long wait it exists to cover. Liveness is a property of the
|
|
56
|
+
* agent process, not of the model's output cadence — so it gets its own
|
|
57
|
+
* timer. 3s keeps proof of life well inside the admin UI's poll interval.
|
|
58
|
+
*/
|
|
59
|
+
const HEARTBEAT_INTERVAL_MS = 3_000;
|
|
60
|
+
|
|
61
|
+
interface ChatPollerOptions {
|
|
62
|
+
client: ChatServiceClient;
|
|
63
|
+
runner: ChatRunner;
|
|
64
|
+
/** Poll interval in ms. Default: 5000 */
|
|
65
|
+
intervalMs?: number;
|
|
66
|
+
/** Heartbeat cadence in ms while a reply is in flight. Default: 3000 */
|
|
67
|
+
heartbeatIntervalMs?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Agent workspace directory. When set, attachments on claimed messages are
|
|
70
|
+
* pulled into `<workspaceDir>/uploads/` so Claude can Read them.
|
|
71
|
+
*/
|
|
72
|
+
workspaceDir?: string;
|
|
73
|
+
/** Injected for tests so timer behavior is deterministic. */
|
|
74
|
+
setIntervalFn?: typeof setInterval;
|
|
75
|
+
/** Injected for tests so timer behavior is deterministic. */
|
|
76
|
+
clearIntervalFn?: typeof clearInterval;
|
|
77
|
+
/** STT: transcribes an audio attachment. Default: transcribeAudio from ./voice.ts. */
|
|
78
|
+
transcribeAudioFn?: TranscribeAudioFn;
|
|
79
|
+
/** TTS: synthesizes a [speak:] marker's text to audio. Default: synthesizeSpeech from ./voice.ts. */
|
|
80
|
+
synthesizeSpeechFn?: SynthesizeSpeechFn;
|
|
81
|
+
/** Voice provider config (Groq/whisper-svc for STT, ElevenLabs/Piper for TTS). */
|
|
82
|
+
voiceConfig?: VoiceConfig;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ChatPoller {
|
|
86
|
+
/** Start the poll interval. */
|
|
87
|
+
start(): void;
|
|
88
|
+
/** Stop the poll interval. */
|
|
89
|
+
stop(): void;
|
|
90
|
+
/** Run a single poll iteration. Exposed for testing. */
|
|
91
|
+
pollOnce(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── createChatPoller ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export function createChatPoller(opts: ChatPollerOptions): ChatPoller {
|
|
97
|
+
const {
|
|
98
|
+
client,
|
|
99
|
+
runner,
|
|
100
|
+
intervalMs = 5_000,
|
|
101
|
+
heartbeatIntervalMs = HEARTBEAT_INTERVAL_MS,
|
|
102
|
+
workspaceDir,
|
|
103
|
+
setIntervalFn = setInterval,
|
|
104
|
+
clearIntervalFn = clearInterval,
|
|
105
|
+
transcribeAudioFn = transcribeAudio,
|
|
106
|
+
synthesizeSpeechFn = synthesizeSpeech,
|
|
107
|
+
voiceConfig = {},
|
|
108
|
+
} = opts;
|
|
109
|
+
|
|
110
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
111
|
+
/** Guards against a slow poll overlapping the next interval tick. */
|
|
112
|
+
let pollInFlight = false;
|
|
113
|
+
|
|
114
|
+
async function processThread(threadId: string): Promise<void> {
|
|
115
|
+
const message = await client.claimMessage(threadId);
|
|
116
|
+
if (!message) return; // no unclaimed messages — no-op
|
|
117
|
+
|
|
118
|
+
const sessionKey = `chat:${threadId}`;
|
|
119
|
+
|
|
120
|
+
// Pull an attachment into the workspace so Claude can Read it.
|
|
121
|
+
let runnerMessage = message.body;
|
|
122
|
+
if (message.attachmentFilename && workspaceDir) {
|
|
123
|
+
try {
|
|
124
|
+
const bytes = await client.getAttachment(threadId, message.id);
|
|
125
|
+
if (bytes) {
|
|
126
|
+
const safeFilename = message.attachmentFilename.replace(
|
|
127
|
+
/[^\w.-]+/g,
|
|
128
|
+
"_",
|
|
129
|
+
);
|
|
130
|
+
const uploadsDir = join(workspaceDir, "uploads");
|
|
131
|
+
await mkdir(uploadsDir, { recursive: true });
|
|
132
|
+
const filePath = join(uploadsDir, `${message.id}-${safeFilename}`);
|
|
133
|
+
await writeFile(filePath, bytes);
|
|
134
|
+
|
|
135
|
+
// Voice note: transcribe and replace the runner message entirely
|
|
136
|
+
// (same convention Slack's buildPromptWithFiles uses — the
|
|
137
|
+
// transcript stands in for the message body, it isn't appended to
|
|
138
|
+
// it). Any failure (no fn injected, non-audio extension, a thrown
|
|
139
|
+
// transcribeAudioFn, or a null/blank transcript) falls through to
|
|
140
|
+
// the generic attached-file note below.
|
|
141
|
+
let transcript: string | null = null;
|
|
142
|
+
if (transcribeAudioFn && AUDIO_EXTENSION_REGEX.test(safeFilename)) {
|
|
143
|
+
try {
|
|
144
|
+
transcript = await transcribeAudioFn(filePath, voiceConfig);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(
|
|
147
|
+
`[chat-poller] transcription failed for thread ${threadId} message ${message.id}:`,
|
|
148
|
+
err instanceof Error ? err.message : String(err),
|
|
149
|
+
);
|
|
150
|
+
transcript = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
runnerMessage =
|
|
155
|
+
transcript && transcript.trim().length > 0
|
|
156
|
+
? `[voice transcript: ${transcript.trim()}]`
|
|
157
|
+
: `${message.body}\n\n[Attached file: ${safeFilename} saved at ${filePath}]`;
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(
|
|
161
|
+
`[chat-poller] failed to pull attachment for thread ${threadId} message ${message.id}:`,
|
|
162
|
+
err instanceof Error ? err.message : String(err),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Bidirectional heartbeat: the same 3s tick that proves liveness also
|
|
168
|
+
// learns whether a cancel was requested (via cancelRequestedAt on the
|
|
169
|
+
// returned message) and, if so, aborts the in-flight run. This keeps the
|
|
170
|
+
// architecture pull-only — no second polling loop, no inbound HTTP surface
|
|
171
|
+
// on the agent. Failures are swallowed — a missed heartbeat must never
|
|
172
|
+
// abort the reply itself.
|
|
173
|
+
const abortController = new AbortController();
|
|
174
|
+
let cancelRequested = false;
|
|
175
|
+
const sendHeartbeat = () => {
|
|
176
|
+
client
|
|
177
|
+
.heartbeat(threadId, message.id)
|
|
178
|
+
.then((result) => {
|
|
179
|
+
if (result?.cancelRequested && !abortController.signal.aborted) {
|
|
180
|
+
cancelRequested = true;
|
|
181
|
+
abortController.abort();
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
.catch((err) => {
|
|
185
|
+
console.error(
|
|
186
|
+
`[chat-poller] heartbeat failed for thread ${threadId} message ${message.id}:`,
|
|
187
|
+
err instanceof Error ? err.message : String(err),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
let runResult: Awaited<ReturnType<ChatRunner>> | undefined;
|
|
193
|
+
let errorKind: string | undefined;
|
|
194
|
+
const heartbeatTimer = setIntervalFn(sendHeartbeat, heartbeatIntervalMs);
|
|
195
|
+
try {
|
|
196
|
+
runResult = await runner(
|
|
197
|
+
runnerMessage,
|
|
198
|
+
sessionKey,
|
|
199
|
+
undefined,
|
|
200
|
+
abortController.signal,
|
|
201
|
+
);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
// The runner failed. The message must NOT be left claimed forever with
|
|
204
|
+
// no reaper — always fall through to replyToMessage below with an
|
|
205
|
+
// errorKind. A cancel is a deliberate user action, not a stall.
|
|
206
|
+
const name = err instanceof Error ? err.name : "";
|
|
207
|
+
errorKind =
|
|
208
|
+
cancelRequested || name === "ClaudeAbortedError"
|
|
209
|
+
? "cancelled"
|
|
210
|
+
: "stalled";
|
|
211
|
+
console.error(
|
|
212
|
+
`[chat-poller] runner failed for thread ${threadId} (${errorKind}):`,
|
|
213
|
+
err instanceof Error ? err.message : String(err),
|
|
214
|
+
);
|
|
215
|
+
} finally {
|
|
216
|
+
clearIntervalFn(heartbeatTimer);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Derive the reply body + errorKind. The poller ALWAYS replies — a
|
|
220
|
+
// streamIncomplete run has an empty result string, so posting it verbatim
|
|
221
|
+
// would be an EMPTY reply; a thrown runner would otherwise leave the
|
|
222
|
+
// message claimed forever. Every branch produces a non-empty body.
|
|
223
|
+
const {
|
|
224
|
+
body,
|
|
225
|
+
tokens,
|
|
226
|
+
costUsd,
|
|
227
|
+
errorKind: finalErrorKind,
|
|
228
|
+
} = deriveReply(runResult, errorKind);
|
|
229
|
+
|
|
230
|
+
// TTS: extract a [speak:] marker (if any) and synthesize it to audio.
|
|
231
|
+
// The marker is stripped from the posted body whenever one is found,
|
|
232
|
+
// regardless of whether synthesis itself succeeds — only the attachment
|
|
233
|
+
// is conditional on success (AC2 stripping + AC4 graceful degradation).
|
|
234
|
+
// A marker-only reply that produced no audio falls back to posting the
|
|
235
|
+
// spoken text, so the user never gets an empty bubble (see below).
|
|
236
|
+
let replyBody = body;
|
|
237
|
+
let attachmentFilename: string | undefined;
|
|
238
|
+
let attachmentSize: number | undefined;
|
|
239
|
+
let attachmentBytes: Uint8Array | undefined;
|
|
240
|
+
|
|
241
|
+
const { cleaned, markers } = parseMarkers(body);
|
|
242
|
+
let speakText: string | undefined;
|
|
243
|
+
for (const marker of markers) {
|
|
244
|
+
if (marker.type === "speak") {
|
|
245
|
+
speakText = marker.text;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (speakText !== undefined) {
|
|
251
|
+
replyBody = cleaned;
|
|
252
|
+
if (synthesizeSpeechFn) {
|
|
253
|
+
try {
|
|
254
|
+
const audioPath = await synthesizeSpeechFn(speakText, voiceConfig);
|
|
255
|
+
if (audioPath) {
|
|
256
|
+
const audioBytes = await readFile(audioPath);
|
|
257
|
+
attachmentBytes = new Uint8Array(audioBytes);
|
|
258
|
+
attachmentFilename = audioPath.split("/").pop() ?? "response.mp3";
|
|
259
|
+
attachmentSize = attachmentBytes.length;
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(
|
|
263
|
+
`[chat-poller] speech synthesis failed for thread ${threadId} message ${message.id}:`,
|
|
264
|
+
err instanceof Error ? err.message : String(err),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Text fallback: a marker-only reply (`[speak:...]` and nothing else)
|
|
270
|
+
// strips down to an empty body, so if no audio was produced the user
|
|
271
|
+
// would get a blank bubble. Post the spoken text instead — the same
|
|
272
|
+
// contract as slack.ts's postSpeakTextFallback. When `cleaned` still has
|
|
273
|
+
// text, that text IS the graceful degradation; leave it alone.
|
|
274
|
+
if (attachmentBytes === undefined && replyBody.trim().length === 0) {
|
|
275
|
+
console.warn(
|
|
276
|
+
`[chat-poller] no audio for [speak:] marker on thread ${threadId} message ${message.id} — falling back to speak text`,
|
|
277
|
+
);
|
|
278
|
+
replyBody = speakText;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await client.replyToMessage(threadId, message.id, {
|
|
284
|
+
body: replyBody,
|
|
285
|
+
tokens,
|
|
286
|
+
costUsd,
|
|
287
|
+
errorKind: finalErrorKind,
|
|
288
|
+
attachmentFilename,
|
|
289
|
+
attachmentSize,
|
|
290
|
+
attachmentBytes,
|
|
291
|
+
});
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(
|
|
294
|
+
`[chat-poller] replyToMessage failed for thread ${threadId} message ${message.id}:`,
|
|
295
|
+
err instanceof Error ? err.message : String(err),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function pollOnce(): Promise<void> {
|
|
301
|
+
let listResult: Awaited<ReturnType<typeof client.listThreads>>;
|
|
302
|
+
try {
|
|
303
|
+
listResult = await client.listThreads({});
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error(
|
|
306
|
+
"[chat-poller] listThreads failed:",
|
|
307
|
+
err instanceof Error ? err.message : String(err),
|
|
308
|
+
);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (listResult.threads.length === 0) return;
|
|
313
|
+
|
|
314
|
+
// Process each thread independently — errors on one must not block others
|
|
315
|
+
await Promise.all(
|
|
316
|
+
listResult.threads.map((thread) =>
|
|
317
|
+
processThread(thread.id).catch((err) => {
|
|
318
|
+
console.error(
|
|
319
|
+
`[chat-poller] error processing thread ${thread.id}:`,
|
|
320
|
+
err instanceof Error ? err.message : String(err),
|
|
321
|
+
);
|
|
322
|
+
}),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
start() {
|
|
329
|
+
if (timer) return; // already running
|
|
330
|
+
// Skip a tick when the previous iteration is still running. A poll that
|
|
331
|
+
// outlives its interval (a long claim + reply cycle always does) would
|
|
332
|
+
// otherwise stack concurrent iterations for as long as it runs.
|
|
333
|
+
timer = setIntervalFn(() => {
|
|
334
|
+
if (pollInFlight) return;
|
|
335
|
+
pollInFlight = true;
|
|
336
|
+
void pollOnce().finally(() => {
|
|
337
|
+
pollInFlight = false;
|
|
338
|
+
});
|
|
339
|
+
}, intervalMs);
|
|
340
|
+
},
|
|
341
|
+
stop() {
|
|
342
|
+
if (timer) {
|
|
343
|
+
clearIntervalFn(timer);
|
|
344
|
+
timer = undefined;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
pollOnce,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface DerivedReply {
|
|
352
|
+
body: string;
|
|
353
|
+
tokens?: unknown;
|
|
354
|
+
costUsd?: number;
|
|
355
|
+
errorKind?: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Turn a run outcome into a reply that is never empty. `errorKind` is the
|
|
360
|
+
* failure-mode already inferred by the caller (`cancelled`/`stalled` for a
|
|
361
|
+
* thrown runner, or undefined for a completed run). Rules:
|
|
362
|
+
* - cancelled: fixed body, keep errorKind.
|
|
363
|
+
* - runner threw (no runResult): keep errorKind, synthesize a body.
|
|
364
|
+
* - streamIncomplete or empty result on a clean finish: errorKind=incomplete
|
|
365
|
+
* with a non-empty fallback body (never post an empty bubble).
|
|
366
|
+
* - otherwise: the real reply body + usage.
|
|
367
|
+
*/
|
|
368
|
+
export function deriveReply(
|
|
369
|
+
runResult: ClaudeRunResult | undefined,
|
|
370
|
+
errorKind: string | undefined,
|
|
371
|
+
): DerivedReply {
|
|
372
|
+
if (errorKind === "cancelled") return { body: "Cancelled.", errorKind };
|
|
373
|
+
|
|
374
|
+
if (!runResult) {
|
|
375
|
+
// Runner threw — errorKind is "stalled" (cancelled handled above).
|
|
376
|
+
return { body: "The run failed unexpectedly.", errorKind };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const { usage: tokens, totalCostUsd: costUsd } = runResult;
|
|
380
|
+
|
|
381
|
+
if (runResult.streamIncomplete) {
|
|
382
|
+
return {
|
|
383
|
+
body: runResult.result || "The reply was cut off before it finished.",
|
|
384
|
+
tokens,
|
|
385
|
+
costUsd,
|
|
386
|
+
errorKind: "incomplete",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (runResult.result.trim().length > 0) {
|
|
391
|
+
return { body: runResult.result, tokens, costUsd, errorKind };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Clean finish but empty body — treat as incomplete rather than an empty bubble.
|
|
395
|
+
return {
|
|
396
|
+
body: "The reply was empty.",
|
|
397
|
+
tokens,
|
|
398
|
+
costUsd,
|
|
399
|
+
errorKind: "incomplete",
|
|
400
|
+
};
|
|
401
|
+
}
|