alvin-bot 4.12.4 → 4.13.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/CHANGELOG.md +77 -0
- package/dist/handlers/message.js +9 -0
- package/dist/paths.js +8 -0
- package/dist/providers/claude-sdk-provider.js +25 -5
- package/dist/services/alvin-dispatch.js +125 -0
- package/dist/services/alvin-mcp-tools.js +103 -0
- package/dist/services/async-agent-parser.js +50 -0
- package/dist/services/personality.js +36 -10
- package/package.json +1 -1
- package/test/alvin-dispatch.test.ts +220 -0
- package/test/async-agent-parser-streamjson.test.ts +273 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,83 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.13.0] — 2026-04-16
|
|
6
|
+
|
|
7
|
+
### ✨ Major: truly detached sub-agent dispatch via `alvin_dispatch_agent` MCP tool
|
|
8
|
+
|
|
9
|
+
**Background.** v4.12.1 → v4.12.3 tried three progressively more complex fixes for the "bot freezes while sub-agent runs" problem, all of which depended on Claude Agent SDK's built-in `Task(run_in_background: true)` tool. All three iterations missed the same architectural reality: the SDK's background task stays tied to the parent SDK subprocess lifecycle. When v4.12.3's bypass path aborted the parent to unblock the user, the abort cascaded into killing the in-flight sub-agent mid-work. v4.12.4 worked around this at the delivery layer (recovering partial output after a 5-min staleness window), but the fundamental architecture was still wrong.
|
|
10
|
+
|
|
11
|
+
v4.13 fixes the architecture. Instead of using the SDK's built-in Task tool for background work, we register our own MCP tool — `mcp__alvin__dispatch_agent` — which spawns a **completely independent** `claude -p` subprocess (its own PID, its own process group, unreferenced from the parent's event loop). Aborting the parent has zero effect on the dispatched subprocess. It continues to write its stream-json output to its own file and runs to completion. The async-agent-watcher polls the output file and delivers the result as a separate message when ready.
|
|
12
|
+
|
|
13
|
+
Empirically verified with a standalone survival test (`scripts/smoke-test-abort-survival.mjs`): dispatch an agent that needs 20+ seconds of work, kill the parent Node process 100ms later, watch the subprocess keep writing to its output file and complete cleanly with the expected result.
|
|
14
|
+
|
|
15
|
+
### What changed for the user
|
|
16
|
+
|
|
17
|
+
- **Before v4.13** (with Task tool): the bot shows "typing…" for the entire duration of the sub-agent's work (5, 20, 60 minutes). New messages sit in a queue and don't get processed. If the user interrupts via v4.12.3's bypass, the sub-agent dies mid-work and hours later the user gets a `720m timeout · (empty output)` message.
|
|
18
|
+
- **After v4.13** (with `alvin_dispatch_agent`): the bot's turn completes within seconds of dispatch. The user sees "🤖 Dispatched 2 background agents — I'll send the results when ready." and can immediately chat about anything else. The background subprocesses finish cleanly and deliver their full results as separate messages.
|
|
19
|
+
|
|
20
|
+
This matches the OpenClaw experience the user was asking about — except it's built natively into Claude Agent SDK's MCP-tool mechanism, not a wholesale replacement.
|
|
21
|
+
|
|
22
|
+
### Technical details
|
|
23
|
+
|
|
24
|
+
**New module** `src/services/alvin-dispatch.ts`
|
|
25
|
+
- `dispatchDetachedAgent(input)` — spawns `claude -p <prompt> --output-format stream-json` via `child_process.spawn({ detached: true, stdio: ["ignore", outFd, errFd] })` + `.unref()`
|
|
26
|
+
- Synchronous return: `{ agentId, outputFile, spawned: true }`
|
|
27
|
+
- Side effects: registers with `async-agent-watcher`, increments `session.pendingBackgroundCount`
|
|
28
|
+
- Unique agent IDs via `crypto.randomBytes(12).toString("hex")` (collision-safe for parallel dispatch)
|
|
29
|
+
- Cleans `CLAUDECODE`/`CLAUDE_CODE_ENTRYPOINT` from env to prevent nested-session errors
|
|
30
|
+
|
|
31
|
+
**New module** `src/services/alvin-mcp-tools.ts`
|
|
32
|
+
- `buildAlvinMcpServer(ctx)` — creates an SDK MCP server bound to this turn's `{ chatId, userId, sessionKey }` context via closure
|
|
33
|
+
- Exposes `dispatch_agent` tool (zod-validated input: `{ prompt: string, description: string }`)
|
|
34
|
+
- Tool handler calls `dispatchDetachedAgent` and returns `agentId + outputFile` to Claude
|
|
35
|
+
- Uses SDK's `createSdkMcpServer` + `tool` builders (the SDK's native inline-tool API — no separate MCP server process needed)
|
|
36
|
+
|
|
37
|
+
**Provider integration** (`src/providers/claude-sdk-provider.ts`)
|
|
38
|
+
- New `QueryOptions.alvinDispatchContext` field — when set, provider registers `mcpServers: { alvin: buildAlvinMcpServer(ctx) }` + appends `mcp__alvin__dispatch_agent` to the default `allowedTools` list
|
|
39
|
+
- When unset, the MCP server is not registered and Claude falls back to the built-in Task tool only
|
|
40
|
+
- Non-SDK providers ignore the new field entirely
|
|
41
|
+
|
|
42
|
+
**Handler integration** (`src/handlers/message.ts`)
|
|
43
|
+
- Passes `alvinDispatchContext: { chatId, userId, sessionKey }` on every SDK turn
|
|
44
|
+
- No other handler changes — the bypass path, the staleness parser, and the pending-count decrement are all reused from v4.12.3/v4.12.4
|
|
45
|
+
|
|
46
|
+
**Parser extension** (`src/services/async-agent-parser.ts`)
|
|
47
|
+
- New first-pass scan for `{"type":"result"}` events — the completion marker used by `claude -p --output-format stream-json` (different from the SDK-internal sub-agent format that uses `message.stop_reason: "end_turn"`)
|
|
48
|
+
- When found, uses the `result.result` field as authoritative output when present, falls back to aggregating all assistant text blocks
|
|
49
|
+
- Preserves backward compat with the existing `end_turn`-based path (tested by the old test suite)
|
|
50
|
+
|
|
51
|
+
**System prompt update** (`src/services/personality.ts`)
|
|
52
|
+
- `BACKGROUND_SUBAGENT_HINT` rewritten to strongly prefer `mcp__alvin__dispatch_agent` over `Task(run_in_background: true)` on Telegram/WhatsApp/Slack/Discord
|
|
53
|
+
- Explicit decision tree, concrete example prompts, parallel-dispatch guidance
|
|
54
|
+
- Built-in Task tool remains available but deprecated for long-running work; reserved for the rare case where Claude needs a result in the same turn
|
|
55
|
+
|
|
56
|
+
### Known limitations
|
|
57
|
+
|
|
58
|
+
- **First-turn only for now**: the MCP server is bound to `{ chatId, userId, sessionKey }` at query construction time. If the session's underlying SDK session ID changes mid-conversation (rare), the tool context goes stale. Defensive: a new MCP server is built on each handler invocation, so any next turn picks up the correct context.
|
|
59
|
+
- **Non-Telegram platforms**: `src/handlers/platform-message.ts` (Slack/Discord/WhatsApp) doesn't pass `alvinDispatchContext` yet. Deferred to follow-up — the Telegram path is the primary use case and the one the user explicitly requested.
|
|
60
|
+
- **Parallel dispatch not smoke-tested**: the system prompt guides Claude to call `dispatch_agent` multiple times in one turn for parallel work, but I only end-to-end tested single dispatch. Should work (no shared state in the handler), but YMMV until battle-tested.
|
|
61
|
+
|
|
62
|
+
### Testing
|
|
63
|
+
|
|
64
|
+
- **Baseline**: 447 tests (v4.12.4)
|
|
65
|
+
- **New**:
|
|
66
|
+
- `test/alvin-dispatch.test.ts` — 6 tests (spawn flags, unique IDs, watcher registration, session counter, stdio redirect, env cleanup)
|
|
67
|
+
- `test/async-agent-parser-streamjson.test.ts` — 7 tests (result-event detection, token extraction, error state, running state, multi-text aggregation, `result.result` precedence, minimal fields)
|
|
68
|
+
- **Total**: 460 tests, all green, TSC clean
|
|
69
|
+
- **Real-world smoke tests** (NOT in CI — run via `node scripts/smoke-test-dispatch.mjs` and `node scripts/smoke-test-abort-survival.mjs`):
|
|
70
|
+
- `smoke-test-dispatch`: dispatches a real `claude -p` subprocess, polls to completion (~10s), verifies exact output `"SMOKE_TEST_OK_v4.13"`. **PASS**.
|
|
71
|
+
- `smoke-test-abort-survival`: dispatches a subprocess that needs ~25s of work, kills the parent Node process ~100ms later, polls the output file. Subprocess survives and completes cleanly. **PASS**.
|
|
72
|
+
|
|
73
|
+
### Files changed
|
|
74
|
+
|
|
75
|
+
- **NEW**: `src/services/alvin-dispatch.ts`, `src/services/alvin-mcp-tools.ts`, `scripts/smoke-test-dispatch.mjs`, `scripts/smoke-test-abort-survival.mjs`
|
|
76
|
+
- **NEW tests**: `test/alvin-dispatch.test.ts`, `test/async-agent-parser-streamjson.test.ts`
|
|
77
|
+
- **Modified**: `src/paths.ts` (SUBAGENTS_DIR), `src/services/async-agent-parser.ts` (stream-json detection), `src/providers/claude-sdk-provider.ts` (MCP server registration + allowedTools), `src/providers/types.ts` (QueryOptions.alvinDispatchContext), `src/handlers/message.ts` (pass dispatch context), `src/services/personality.ts` (BACKGROUND_SUBAGENT_HINT rewrite)
|
|
78
|
+
- **Version**: `package.json` 4.12.4 → 4.13.0 (minor bump — new public surface: MCP tool)
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
5
82
|
## [4.12.4] — 2026-04-16
|
|
6
83
|
|
|
7
84
|
### 🐛 Patch: recover partial output from interrupted background sub-agents
|
package/dist/handlers/message.js
CHANGED
|
@@ -400,6 +400,15 @@ export async function handleMessage(ctx) {
|
|
|
400
400
|
messageCount: session.messageCount,
|
|
401
401
|
toolUseCount: session.toolUseCount,
|
|
402
402
|
} : undefined,
|
|
403
|
+
// v4.13 — Expose alvin_dispatch_agent MCP tool so Claude can spawn
|
|
404
|
+
// truly detached background sub-agents (independent of this SDK
|
|
405
|
+
// subprocess's lifecycle). Only for SDK provider + Telegram here —
|
|
406
|
+
// non-SDK providers ignore this field.
|
|
407
|
+
alvinDispatchContext: isSDK ? {
|
|
408
|
+
chatId: ctx.chat.id,
|
|
409
|
+
userId,
|
|
410
|
+
sessionKey,
|
|
411
|
+
} : undefined,
|
|
403
412
|
};
|
|
404
413
|
// Stream response from provider (with fallback)
|
|
405
414
|
let lastBroadcastLen = 0;
|
package/dist/paths.js
CHANGED
|
@@ -118,3 +118,11 @@ export const ASSETS_DIR = resolve(DATA_DIR, "assets");
|
|
|
118
118
|
export const ASSETS_INDEX_JSON = resolve(DATA_DIR, "assets", "INDEX.json");
|
|
119
119
|
/** assets/INDEX.md — Human-readable asset summary (injected into prompts) */
|
|
120
120
|
export const ASSETS_INDEX_MD = resolve(DATA_DIR, "assets", "INDEX.md");
|
|
121
|
+
/** subagents/ — Detached `claude -p` subprocess output files (v4.13).
|
|
122
|
+
* Each dispatched agent writes its full stream-json output to
|
|
123
|
+
* subagents/<agentId>.jsonl. The async-agent-watcher polls these files
|
|
124
|
+
* and delivers the final result as a separate message when ready.
|
|
125
|
+
* These live outside BOT_ROOT/DATA_DIR's state/ so that the watcher's
|
|
126
|
+
* giveUpAt-survive-restart logic doesn't leak into the subprocess
|
|
127
|
+
* lifecycle. */
|
|
128
|
+
export const SUBAGENTS_DIR = resolve(DATA_DIR, "subagents");
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "url";
|
|
|
13
13
|
import { execFile } from "child_process";
|
|
14
14
|
import { promisify } from "util";
|
|
15
15
|
import { findClaudeBinary } from "../find-claude-binary.js";
|
|
16
|
+
import { buildAlvinMcpServer } from "../services/alvin-mcp-tools.js";
|
|
16
17
|
const execFileAsync = promisify(execFile);
|
|
17
18
|
/**
|
|
18
19
|
* Detects the Claude CLI "Not logged in" error message. The CLI emits this
|
|
@@ -103,6 +104,25 @@ export class ClaudeSDKProvider {
|
|
|
103
104
|
}
|
|
104
105
|
try {
|
|
105
106
|
const claudePath = findClaudeBinary();
|
|
107
|
+
// v4.13 — Register Alvin's custom MCP server if the caller provided
|
|
108
|
+
// dispatch context. The server exposes `alvin_dispatch_agent` which
|
|
109
|
+
// spawns truly detached `claude -p` subprocesses (independent of the
|
|
110
|
+
// main SDK subprocess's lifecycle). When Claude calls it, the bot
|
|
111
|
+
// can abort this query without killing the dispatched sub-agent.
|
|
112
|
+
const mcpServers = {};
|
|
113
|
+
if (options.alvinDispatchContext) {
|
|
114
|
+
mcpServers.alvin = buildAlvinMcpServer(options.alvinDispatchContext);
|
|
115
|
+
}
|
|
116
|
+
// v4.13 — MCP tool names must be explicitly whitelisted via allowedTools
|
|
117
|
+
// in the form `mcp__<server>__<tool>`. Without this, Claude can see the
|
|
118
|
+
// tool in the catalog but cannot actually invoke it.
|
|
119
|
+
const defaultAllowed = [
|
|
120
|
+
"Read", "Write", "Edit", "Bash", "Glob", "Grep",
|
|
121
|
+
"WebSearch", "WebFetch", "Task",
|
|
122
|
+
];
|
|
123
|
+
if (options.alvinDispatchContext) {
|
|
124
|
+
defaultAllowed.push("mcp__alvin__dispatch_agent");
|
|
125
|
+
}
|
|
106
126
|
const q = query({
|
|
107
127
|
prompt,
|
|
108
128
|
options: {
|
|
@@ -116,11 +136,11 @@ export class ClaudeSDKProvider {
|
|
|
116
136
|
settingSources: ["user", "project"],
|
|
117
137
|
// v4.12.2 — options.allowedTools can override the default full set.
|
|
118
138
|
// Used by sub-agents with toolset="readonly"/"research" to restrict
|
|
119
|
-
// what Claude can do. Default = full access.
|
|
120
|
-
allowedTools: options.allowedTools ??
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
// what Claude can do. Default = full access + alvin MCP tools.
|
|
140
|
+
allowedTools: options.allowedTools ?? defaultAllowed,
|
|
141
|
+
// v4.13 — Conditionally pass the MCP server config so the inline
|
|
142
|
+
// dispatch tool is visible. Empty object = no custom tools.
|
|
143
|
+
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
|
124
144
|
systemPrompt,
|
|
125
145
|
effort: (options.effort || "medium"),
|
|
126
146
|
maxTurns: 50,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — alvin_dispatch custom-tool service.
|
|
3
|
+
*
|
|
4
|
+
* Architectural replacement for Claude Agent SDK's built-in
|
|
5
|
+
* `Task(run_in_background: true)` tool. The SDK's built-in version
|
|
6
|
+
* ties the background sub-agent's execution to the parent SDK
|
|
7
|
+
* subprocess lifecycle — killing the parent (e.g. via v4.12.3's
|
|
8
|
+
* bypass-abort) cascades into killing any in-flight background tasks.
|
|
9
|
+
*
|
|
10
|
+
* This module instead spawns a truly independent `claude -p` subprocess
|
|
11
|
+
* via Node's `child_process.spawn({ detached: true, stdio: [...] })`.
|
|
12
|
+
* The subprocess:
|
|
13
|
+
* - Has its own PID, own process group (by detached: true)
|
|
14
|
+
* - Is unreffed so the parent Node process doesn't wait for it
|
|
15
|
+
* - Writes its stream-json output to its own file
|
|
16
|
+
* - Survives any abort/crash/restart of the parent Alvin bot
|
|
17
|
+
*
|
|
18
|
+
* The async-agent-watcher polls the output file and delivers the
|
|
19
|
+
* final result via subagent-delivery.ts when the sub-agent completes.
|
|
20
|
+
*
|
|
21
|
+
* See Phase A of docs/superpowers/plans/2026-04-16-v4.13-truly-async-subagents.md
|
|
22
|
+
* for the empirical verification that detached `claude -p` subprocesses
|
|
23
|
+
* behave as expected (they do).
|
|
24
|
+
*/
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import crypto from "node:crypto";
|
|
28
|
+
import { resolve } from "node:path";
|
|
29
|
+
import { findClaudeBinary } from "../find-claude-binary.js";
|
|
30
|
+
import { registerPendingAgent } from "./async-agent-watcher.js";
|
|
31
|
+
import { getAllSessions } from "./session.js";
|
|
32
|
+
import { SUBAGENTS_DIR } from "../paths.js";
|
|
33
|
+
/** Generate a 32-char hex agent id. Avoids collisions across parallel
|
|
34
|
+
* dispatches even at sub-millisecond intervals. */
|
|
35
|
+
function generateAgentId() {
|
|
36
|
+
return "alvin-" + crypto.randomBytes(12).toString("hex");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Dispatch a detached sub-agent. Returns synchronously — the subprocess
|
|
40
|
+
* runs in the background. Throws if spawn fails. On success:
|
|
41
|
+
*
|
|
42
|
+
* 1. Subprocess is running, writing stream-json to outputFile
|
|
43
|
+
* 2. The agent is registered with async-agent-watcher (pending list)
|
|
44
|
+
* 3. session.pendingBackgroundCount is incremented
|
|
45
|
+
* 4. When the subprocess completes, watcher delivers the result
|
|
46
|
+
*/
|
|
47
|
+
export function dispatchDetachedAgent(input) {
|
|
48
|
+
// Ensure subagents dir exists. Idempotent.
|
|
49
|
+
try {
|
|
50
|
+
fs.mkdirSync(SUBAGENTS_DIR, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* race-safe — next open() will surface the real error */
|
|
54
|
+
}
|
|
55
|
+
const agentId = generateAgentId();
|
|
56
|
+
const outputFile = resolve(SUBAGENTS_DIR, `${agentId}.jsonl`);
|
|
57
|
+
// Open the output file for write. We pass the FD to child's stdout
|
|
58
|
+
// so the subprocess writes directly without going through us.
|
|
59
|
+
// stderr → separate .err file for diagnostics.
|
|
60
|
+
const errFile = resolve(SUBAGENTS_DIR, `${agentId}.err`);
|
|
61
|
+
const outFd = fs.openSync(outputFile, "w");
|
|
62
|
+
const errFd = fs.openSync(errFile, "w");
|
|
63
|
+
const cleanEnv = { ...process.env };
|
|
64
|
+
// v4.13 — Prevent nested-session errors. The SDK refuses to run if
|
|
65
|
+
// these are already set in env (they leak from parent Alvin/SDK).
|
|
66
|
+
delete cleanEnv.CLAUDECODE;
|
|
67
|
+
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
68
|
+
const claudePath = findClaudeBinary();
|
|
69
|
+
if (!claudePath) {
|
|
70
|
+
fs.closeSync(outFd);
|
|
71
|
+
fs.closeSync(errFd);
|
|
72
|
+
throw new Error("alvin_dispatch: claude CLI not found. Install claude-code to enable background dispatch.");
|
|
73
|
+
}
|
|
74
|
+
const child = spawn(claudePath, [
|
|
75
|
+
"-p",
|
|
76
|
+
input.prompt,
|
|
77
|
+
"--output-format",
|
|
78
|
+
"stream-json",
|
|
79
|
+
"--verbose",
|
|
80
|
+
], {
|
|
81
|
+
cwd: input.cwd,
|
|
82
|
+
detached: true,
|
|
83
|
+
stdio: ["ignore", outFd, errFd],
|
|
84
|
+
env: cleanEnv,
|
|
85
|
+
});
|
|
86
|
+
// Close our copies of the FDs — the child has its own descriptors now.
|
|
87
|
+
try {
|
|
88
|
+
fs.closeSync(outFd);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
fs.closeSync(errFd);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* ignore */
|
|
98
|
+
}
|
|
99
|
+
// Detach from parent Node's event loop so parent exit doesn't wait.
|
|
100
|
+
child.unref();
|
|
101
|
+
// Register with watcher so it polls the output file and delivers.
|
|
102
|
+
registerPendingAgent({
|
|
103
|
+
agentId,
|
|
104
|
+
outputFile,
|
|
105
|
+
description: input.description,
|
|
106
|
+
prompt: input.prompt,
|
|
107
|
+
chatId: input.chatId,
|
|
108
|
+
userId: input.userId,
|
|
109
|
+
toolUseId: null,
|
|
110
|
+
sessionKey: input.sessionKey,
|
|
111
|
+
});
|
|
112
|
+
// Increment the session's pendingBackgroundCount so the main handler
|
|
113
|
+
// knows a background task is in flight (same signal path as SDK's
|
|
114
|
+
// built-in Task tool).
|
|
115
|
+
try {
|
|
116
|
+
const s = getAllSessions().get(input.sessionKey);
|
|
117
|
+
if (s) {
|
|
118
|
+
s.pendingBackgroundCount = (s.pendingBackgroundCount ?? 0) + 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* never let counter updates break dispatch */
|
|
123
|
+
}
|
|
124
|
+
return { agentId, outputFile, spawned: true };
|
|
125
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — Alvin's custom MCP tools, registered with the Claude Agent SDK
|
|
3
|
+
* via `createSdkMcpServer()`.
|
|
4
|
+
*
|
|
5
|
+
* Currently exposes a single tool:
|
|
6
|
+
* `alvin_dispatch_agent(prompt, description)` — spawns a truly
|
|
7
|
+
* detached `claude -p` subprocess that's independent of the parent
|
|
8
|
+
* SDK lifecycle. Claude should prefer this over built-in
|
|
9
|
+
* `Task(run_in_background: true)` for any long-running work on
|
|
10
|
+
* Telegram so the main Telegram session isn't blocked by the SDK's
|
|
11
|
+
* task-notification injection mechanism.
|
|
12
|
+
*
|
|
13
|
+
* The MCP server is created lazily per-query so each query gets fresh
|
|
14
|
+
* handler context (chatId/userId/sessionKey) via a closure.
|
|
15
|
+
*/
|
|
16
|
+
import { createSdkMcpServer, tool, } from "@anthropic-ai/claude-agent-sdk";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { dispatchDetachedAgent } from "./alvin-dispatch.js";
|
|
19
|
+
/**
|
|
20
|
+
* Build an MCP server bound to a specific turn's context. Pass the
|
|
21
|
+
* returned instance under `mcpServers: { alvin: <instance> }` in the
|
|
22
|
+
* query options.
|
|
23
|
+
*/
|
|
24
|
+
export function buildAlvinMcpServer(ctx) {
|
|
25
|
+
return createSdkMcpServer({
|
|
26
|
+
name: "alvin",
|
|
27
|
+
version: "4.13.0",
|
|
28
|
+
tools: [
|
|
29
|
+
tool("dispatch_agent", [
|
|
30
|
+
"Dispatch a TRULY DETACHED background sub-agent that runs",
|
|
31
|
+
"independently of this session. Use this for ANY long-running",
|
|
32
|
+
"work on Telegram/Slack/Discord/WhatsApp — research tasks,",
|
|
33
|
+
"audits, multi-page scraping, deep analysis — so the main",
|
|
34
|
+
"user session stays responsive and the user can keep chatting",
|
|
35
|
+
"with you while the sub-agent works.",
|
|
36
|
+
"",
|
|
37
|
+
"HOW IT DIFFERS FROM Task(run_in_background: true):",
|
|
38
|
+
"- The built-in Task tool's subprocess is tied to this session,",
|
|
39
|
+
" so aborting the session also kills the sub-agent mid-work.",
|
|
40
|
+
"- `alvin_dispatch.dispatch_agent` spawns a completely",
|
|
41
|
+
" independent `claude -p` subprocess that survives any abort,",
|
|
42
|
+
" crash, or restart of the main bot.",
|
|
43
|
+
"",
|
|
44
|
+
"WHEN TO USE:",
|
|
45
|
+
"- Any audit/research visiting >2 URLs or reading >5 files",
|
|
46
|
+
"- Full-repo scans, code reviews, SEO/security/perf audits",
|
|
47
|
+
"- Anything you'd describe as 'thorough' or 'takes a few min'",
|
|
48
|
+
"",
|
|
49
|
+
"HOW THE RESULT GETS BACK TO THE USER:",
|
|
50
|
+
"- The tool returns { agentId, outputFile } immediately.",
|
|
51
|
+
"- The bot's async-agent watcher polls the outputFile and",
|
|
52
|
+
" delivers the final result as a separate chat message when",
|
|
53
|
+
" the sub-agent completes (success, failure, or 5-min",
|
|
54
|
+
" staleness).",
|
|
55
|
+
"- Your job after calling this tool: tell the user ONE short",
|
|
56
|
+
" sentence about what you dispatched, then END your turn.",
|
|
57
|
+
" Do NOT wait. Do NOT poll the outputFile yourself.",
|
|
58
|
+
].join("\n"), {
|
|
59
|
+
prompt: z
|
|
60
|
+
.string()
|
|
61
|
+
.describe("The full prompt for the sub-agent. Be specific and self-contained — the sub-agent has no access to this conversation's context and will see only this prompt."),
|
|
62
|
+
description: z
|
|
63
|
+
.string()
|
|
64
|
+
.describe("Short human-readable title (e.g. 'SEO audit alev-b.com', 'Research Higgsfield Seedance 2.0'). Shown to the user when the result arrives."),
|
|
65
|
+
}, async (args) => {
|
|
66
|
+
try {
|
|
67
|
+
const result = dispatchDetachedAgent({
|
|
68
|
+
prompt: args.prompt,
|
|
69
|
+
description: args.description,
|
|
70
|
+
chatId: ctx.chatId,
|
|
71
|
+
userId: ctx.userId,
|
|
72
|
+
sessionKey: ctx.sessionKey,
|
|
73
|
+
cwd: ctx.cwd,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: "text",
|
|
79
|
+
text: `✅ Background sub-agent dispatched.\n` +
|
|
80
|
+
`agentId: ${result.agentId}\n` +
|
|
81
|
+
`output_file: ${result.outputFile}\n` +
|
|
82
|
+
`The user will receive the result as a separate message when the sub-agent completes.\n` +
|
|
83
|
+
`End your turn now. Do not wait for the result — it arrives asynchronously.`,
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: `⚠️ Failed to dispatch background agent: ${msg}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
isError: true,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -148,6 +148,56 @@ export async function parseOutputFileStatus(path, opts = {}) {
|
|
|
148
148
|
const usable = lines
|
|
149
149
|
.slice(headIncomplete, lines.length - (trailIncomplete > 0 ? trailIncomplete : 0))
|
|
150
150
|
.filter((l) => l.length > 0);
|
|
151
|
+
// v4.13 — FIRST PASS: look for a `{"type":"result"}` event anywhere in
|
|
152
|
+
// the tail. This is the completion signal for `claude -p
|
|
153
|
+
// --output-format stream-json` output (used by the v4.13 dispatch
|
|
154
|
+
// mechanism). When present, the `result` field holds the authoritative
|
|
155
|
+
// final text. If `result.result` is missing, aggregate from all
|
|
156
|
+
// assistant text blocks in the tail.
|
|
157
|
+
for (let i = usable.length - 1; i >= 0; i--) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(usable[i]);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (parsed.type === "result") {
|
|
166
|
+
// Prefer the authoritative `result` field when present.
|
|
167
|
+
let output = typeof parsed.result === "string" ? parsed.result : "";
|
|
168
|
+
// Fallback: aggregate text from all assistant messages in the tail.
|
|
169
|
+
if (!output) {
|
|
170
|
+
const fragments = [];
|
|
171
|
+
for (const line of usable) {
|
|
172
|
+
let p;
|
|
173
|
+
try {
|
|
174
|
+
p = JSON.parse(line);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (p.type === "assistant" &&
|
|
180
|
+
Array.isArray(p.message?.content)) {
|
|
181
|
+
for (const c of p.message.content) {
|
|
182
|
+
if (c?.type === "text" && typeof c.text === "string") {
|
|
183
|
+
fragments.push(c.text);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
output = fragments.join("\n\n").trim();
|
|
189
|
+
}
|
|
190
|
+
// Token usage from the result event itself.
|
|
191
|
+
const usage = parsed.usage;
|
|
192
|
+
const tokensUsed = usage
|
|
193
|
+
? {
|
|
194
|
+
input: usage.input_tokens ?? 0,
|
|
195
|
+
output: usage.output_tokens ?? 0,
|
|
196
|
+
}
|
|
197
|
+
: undefined;
|
|
198
|
+
return { state: "completed", output, tokensUsed };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
151
201
|
// Walk backwards to find the most-recent assistant message with end_turn
|
|
152
202
|
for (let i = usable.length - 1; i >= 0; i--) {
|
|
153
203
|
let parsed;
|
|
@@ -61,13 +61,27 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
|
|
|
61
61
|
* See test/system-prompt-background-hint.test.ts and
|
|
62
62
|
* docs/superpowers/plans/2026-04-13-async-subagents.md
|
|
63
63
|
*/
|
|
64
|
-
const BACKGROUND_SUBAGENT_HINT = `## ⚠️ CRITICAL: Background Sub-Agents
|
|
64
|
+
const BACKGROUND_SUBAGENT_HINT = `## ⚠️ CRITICAL: Background Sub-Agents on Telegram/WhatsApp/Slack
|
|
65
65
|
|
|
66
|
-
**
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
**THE RULE (v4.13 update)**: For ANY long-running work (research,
|
|
67
|
+
audits, multi-step analysis, >30 seconds), you MUST prefer the
|
|
68
|
+
\`mcp__alvin__dispatch_agent\` tool over the built-in \`Task\` tool.
|
|
69
|
+
|
|
70
|
+
**Why alvin_dispatch over Task(run_in_background: true)?**
|
|
71
|
+
|
|
72
|
+
The built-in Task tool (even with \`run_in_background: true\`) keeps
|
|
73
|
+
the sub-agent tied to THIS session's CLI subprocess. If the user sends
|
|
74
|
+
me a new message mid-work, aborting this session would kill the
|
|
75
|
+
sub-agent half-way through and leave garbage partial output. Alvin
|
|
76
|
+
learned this the hard way (v4.12.1–v4.12.3 all got this wrong).
|
|
77
|
+
|
|
78
|
+
\`mcp__alvin__dispatch_agent\` spawns a COMPLETELY INDEPENDENT
|
|
79
|
+
\`claude -p\` subprocess with its own PID, own process group. It
|
|
80
|
+
survives ANY abort of this session. The user can interrupt me freely
|
|
81
|
+
and keep chatting while the sub-agent finishes cleanly. The bot
|
|
82
|
+
delivers the result as a separate message when ready.
|
|
83
|
+
|
|
84
|
+
**THIS IS THE SINGLE MOST IMPORTANT RULE FOR USER RESPONSIVENESS.**
|
|
71
85
|
|
|
72
86
|
**Why it matters**: During a synchronous Agent tool call the parent
|
|
73
87
|
session has no way to know the sub-agent is still working. It appears
|
|
@@ -77,7 +91,7 @@ an \`agentId\` + \`outputFile\` path IMMEDIATELY, your turn ends in
|
|
|
77
91
|
seconds, the user can keep chatting with me, and the bot automatically
|
|
78
92
|
delivers the sub-agent's final result as a separate message when ready.
|
|
79
93
|
|
|
80
|
-
**Decision tree** (apply every time you consider
|
|
94
|
+
**Decision tree** (apply every time you consider any sub-agent tool):
|
|
81
95
|
|
|
82
96
|
Does the task involve ANY of the following?
|
|
83
97
|
• Visiting more than 2 URLs
|
|
@@ -89,8 +103,14 @@ delivers the sub-agent's final result as a separate message when ready.
|
|
|
89
103
|
• Crawling, scraping, or fetching multiple resources
|
|
90
104
|
• Research across multiple sources or domains
|
|
91
105
|
|
|
92
|
-
YES →
|
|
93
|
-
NO → foreground is fine (single quick sub-query under 30s
|
|
106
|
+
YES → use \`mcp__alvin__dispatch_agent\` (truly detached, preferred)
|
|
107
|
+
NO → foreground is fine (single quick sub-query under 30s, answer
|
|
108
|
+
yourself if possible)
|
|
109
|
+
|
|
110
|
+
NOTE: The built-in Task tool with run_in_background: true still works
|
|
111
|
+
but is now deprecated on Telegram/Slack/Discord/WhatsApp because it
|
|
112
|
+
ties sub-agent lifetime to this session. Only use Task directly when
|
|
113
|
+
you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
|
|
94
114
|
|
|
95
115
|
**Examples where you MUST use \`run_in_background: true\`:**
|
|
96
116
|
- ANY audit (SEO, security, code quality, performance, accessibility, GEO)
|
|
@@ -107,7 +127,7 @@ delivers the sub-agent's final result as a separate message when ready.
|
|
|
107
127
|
- "What's 2+2?" (no sub-agent needed — answer yourself)
|
|
108
128
|
- "Check if package.json has foo" (one quick tool call)
|
|
109
129
|
|
|
110
|
-
**After launching a background agent, you MUST:**
|
|
130
|
+
**After launching a background agent (either tool), you MUST:**
|
|
111
131
|
1. Tell the user in ONE short sentence what you kicked off.
|
|
112
132
|
Example: "Starting SEO audit for gethomes.io in the background —
|
|
113
133
|
I'll send the report when it's done."
|
|
@@ -115,6 +135,12 @@ delivers the sub-agent's final result as a separate message when ready.
|
|
|
115
135
|
3. The bot will deliver the result as a separate message when ready.
|
|
116
136
|
You don't need to poll the outputFile proactively.
|
|
117
137
|
|
|
138
|
+
**For PARALLEL dispatch** (e.g. user says "research X and Y in parallel"):
|
|
139
|
+
Call \`mcp__alvin__dispatch_agent\` multiple times in the SAME assistant
|
|
140
|
+
turn, once per sub-task. Each returns its own agentId immediately. Your
|
|
141
|
+
turn ends as soon as all dispatches have returned — no sequential
|
|
142
|
+
waiting. The bot delivers each sub-agent's result separately when ready.
|
|
143
|
+
|
|
118
144
|
If the user asks "is it done yet?" before the bot delivers the result,
|
|
119
145
|
you MAY read the agent's \`outputFile\` (from the original tool result)
|
|
120
146
|
using the Read tool to peek at progress — but don't block on it.
|
package/package.json
CHANGED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — alvin_dispatch custom-tool service.
|
|
3
|
+
*
|
|
4
|
+
* `dispatchDetachedAgent(input)` spawns a truly independent `claude -p`
|
|
5
|
+
* subprocess that survives the parent handler's abort. This is the
|
|
6
|
+
* architectural replacement for SDK's built-in Task(run_in_background)
|
|
7
|
+
* tool, which was tied to the parent SDK subprocess lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* Contract:
|
|
10
|
+
* - Input: { prompt, description, chatId, userId, sessionKey }
|
|
11
|
+
* - Output (synchronous): { agentId, outputFile, spawned: true }
|
|
12
|
+
* - Side effect: spawns detached subprocess writing stream-json
|
|
13
|
+
* output to outputFile, registers with async-agent-watcher.
|
|
14
|
+
*
|
|
15
|
+
* These tests stub child_process.spawn so they run fast and deterministic.
|
|
16
|
+
* The "real subprocess survives parent" property was verified empirically
|
|
17
|
+
* in Phase A (see plan doc).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
20
|
+
import os from "os";
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import { resolve } from "path";
|
|
23
|
+
|
|
24
|
+
const TEST_DATA_DIR = resolve(
|
|
25
|
+
os.tmpdir(),
|
|
26
|
+
`alvin-dispatch-${process.pid}-${Date.now()}`,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
interface SpawnRecord {
|
|
30
|
+
cmd: string;
|
|
31
|
+
args: string[];
|
|
32
|
+
opts: {
|
|
33
|
+
detached?: boolean;
|
|
34
|
+
stdio?: unknown;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
env?: Record<string, string | undefined>;
|
|
37
|
+
};
|
|
38
|
+
unreffed: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let spawned: SpawnRecord[] = [];
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
if (fs.existsSync(TEST_DATA_DIR))
|
|
45
|
+
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
46
|
+
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
47
|
+
process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
|
|
48
|
+
spawned = [];
|
|
49
|
+
vi.resetModules();
|
|
50
|
+
|
|
51
|
+
vi.doMock("node:child_process", async () => {
|
|
52
|
+
const actual = await vi.importActual<typeof import("node:child_process")>(
|
|
53
|
+
"node:child_process",
|
|
54
|
+
);
|
|
55
|
+
return {
|
|
56
|
+
...actual,
|
|
57
|
+
spawn: (cmd: string, args: string[], opts: SpawnRecord["opts"]) => {
|
|
58
|
+
const record: SpawnRecord = {
|
|
59
|
+
cmd,
|
|
60
|
+
args,
|
|
61
|
+
opts,
|
|
62
|
+
unreffed: false,
|
|
63
|
+
};
|
|
64
|
+
spawned.push(record);
|
|
65
|
+
return {
|
|
66
|
+
pid: 12345,
|
|
67
|
+
unref() {
|
|
68
|
+
record.unreffed = true;
|
|
69
|
+
},
|
|
70
|
+
on() {},
|
|
71
|
+
kill() {},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
vi.doMock("../src/services/subagent-delivery.js", () => ({
|
|
78
|
+
deliverSubAgentResult: async () => {},
|
|
79
|
+
attachBotApi: () => {},
|
|
80
|
+
__setBotApiForTest: () => {},
|
|
81
|
+
}));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(async () => {
|
|
85
|
+
try {
|
|
86
|
+
const mod = await import("../src/services/async-agent-watcher.js");
|
|
87
|
+
mod.stopWatcher();
|
|
88
|
+
mod.__resetForTest();
|
|
89
|
+
} catch {
|
|
90
|
+
/* ignore */
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("dispatchDetachedAgent (v4.13)", () => {
|
|
95
|
+
it("spawns claude -p with detached: true and unrefs", async () => {
|
|
96
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
97
|
+
const result = mod.dispatchDetachedAgent({
|
|
98
|
+
prompt: "research X",
|
|
99
|
+
description: "X research",
|
|
100
|
+
chatId: 42,
|
|
101
|
+
userId: 42,
|
|
102
|
+
sessionKey: "s1",
|
|
103
|
+
});
|
|
104
|
+
expect(result.agentId).toMatch(/^alvin-[a-f0-9]{16,}$/);
|
|
105
|
+
expect(result.outputFile).toContain(TEST_DATA_DIR);
|
|
106
|
+
expect(result.spawned).toBe(true);
|
|
107
|
+
|
|
108
|
+
expect(spawned).toHaveLength(1);
|
|
109
|
+
const [s] = spawned;
|
|
110
|
+
expect(s.cmd).toMatch(/claude/);
|
|
111
|
+
expect(s.args).toContain("-p");
|
|
112
|
+
expect(s.args).toContain("research X");
|
|
113
|
+
expect(s.args).toContain("--output-format");
|
|
114
|
+
expect(s.args).toContain("stream-json");
|
|
115
|
+
expect(s.opts.detached).toBe(true);
|
|
116
|
+
expect(s.unreffed).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns unique agentIds for concurrent dispatches", async () => {
|
|
120
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
121
|
+
const r1 = mod.dispatchDetachedAgent({
|
|
122
|
+
prompt: "a",
|
|
123
|
+
description: "a",
|
|
124
|
+
chatId: 1,
|
|
125
|
+
userId: 1,
|
|
126
|
+
sessionKey: "s1",
|
|
127
|
+
});
|
|
128
|
+
const r2 = mod.dispatchDetachedAgent({
|
|
129
|
+
prompt: "b",
|
|
130
|
+
description: "b",
|
|
131
|
+
chatId: 1,
|
|
132
|
+
userId: 1,
|
|
133
|
+
sessionKey: "s1",
|
|
134
|
+
});
|
|
135
|
+
expect(r1.agentId).not.toBe(r2.agentId);
|
|
136
|
+
expect(r1.outputFile).not.toBe(r2.outputFile);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("registers the pending agent with the watcher", async () => {
|
|
140
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
141
|
+
const watcher = await import("../src/services/async-agent-watcher.js");
|
|
142
|
+
|
|
143
|
+
mod.dispatchDetachedAgent({
|
|
144
|
+
prompt: "x",
|
|
145
|
+
description: "X audit",
|
|
146
|
+
chatId: 42,
|
|
147
|
+
userId: 42,
|
|
148
|
+
sessionKey: "s1",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const pending = watcher.listPendingAgents();
|
|
152
|
+
expect(pending).toHaveLength(1);
|
|
153
|
+
expect(pending[0].description).toBe("X audit");
|
|
154
|
+
expect(pending[0].sessionKey).toBe("s1");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("increments session.pendingBackgroundCount on dispatch", async () => {
|
|
158
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
159
|
+
const { getSession } = await import("../src/services/session.js");
|
|
160
|
+
|
|
161
|
+
const session = getSession("s-count");
|
|
162
|
+
session.pendingBackgroundCount = 0;
|
|
163
|
+
|
|
164
|
+
mod.dispatchDetachedAgent({
|
|
165
|
+
prompt: "p",
|
|
166
|
+
description: "d",
|
|
167
|
+
chatId: 1,
|
|
168
|
+
userId: 1,
|
|
169
|
+
sessionKey: "s-count",
|
|
170
|
+
});
|
|
171
|
+
expect(session.pendingBackgroundCount).toBe(1);
|
|
172
|
+
|
|
173
|
+
mod.dispatchDetachedAgent({
|
|
174
|
+
prompt: "p2",
|
|
175
|
+
description: "d2",
|
|
176
|
+
chatId: 1,
|
|
177
|
+
userId: 1,
|
|
178
|
+
sessionKey: "s-count",
|
|
179
|
+
});
|
|
180
|
+
expect(session.pendingBackgroundCount).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("uses stdio redirect so child's stdout goes to outputFile", async () => {
|
|
184
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
185
|
+
mod.dispatchDetachedAgent({
|
|
186
|
+
prompt: "p",
|
|
187
|
+
description: "d",
|
|
188
|
+
chatId: 1,
|
|
189
|
+
userId: 1,
|
|
190
|
+
sessionKey: "s1",
|
|
191
|
+
});
|
|
192
|
+
const [s] = spawned;
|
|
193
|
+
// stdio should be an array with FD redirects (ignore, pipe-to-file, ignore)
|
|
194
|
+
// or similar. We verify it's NOT "inherit" (which would attach to parent).
|
|
195
|
+
expect(s.opts.stdio).not.toBe("inherit");
|
|
196
|
+
expect(s.opts.stdio).not.toBe(undefined);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("cleans env of CLAUDECODE/CLAUDE_CODE_ENTRYPOINT to prevent nested session errors", async () => {
|
|
200
|
+
const mod = await import("../src/services/alvin-dispatch.js");
|
|
201
|
+
process.env.CLAUDECODE = "1";
|
|
202
|
+
process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
203
|
+
try {
|
|
204
|
+
mod.dispatchDetachedAgent({
|
|
205
|
+
prompt: "p",
|
|
206
|
+
description: "d",
|
|
207
|
+
chatId: 1,
|
|
208
|
+
userId: 1,
|
|
209
|
+
sessionKey: "s1",
|
|
210
|
+
});
|
|
211
|
+
const [s] = spawned;
|
|
212
|
+
expect(s.opts.env).toBeDefined();
|
|
213
|
+
expect(s.opts.env?.CLAUDECODE).toBeUndefined();
|
|
214
|
+
expect(s.opts.env?.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
|
215
|
+
} finally {
|
|
216
|
+
delete process.env.CLAUDECODE;
|
|
217
|
+
delete process.env.CLAUDE_CODE_ENTRYPOINT;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4.13 — parseOutputFileStatus support for `claude -p --output-format stream-json`.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's built-in Task tool writes its sub-agent output in one JSONL
|
|
5
|
+
* format (events with `message.stop_reason: "end_turn"`). The new v4.13
|
|
6
|
+
* dispatch mechanism spawns `claude -p --output-format stream-json`
|
|
7
|
+
* which writes a DIFFERENT format:
|
|
8
|
+
*
|
|
9
|
+
* - Assistant messages have `message.stop_reason: null` (streaming shape)
|
|
10
|
+
* - A final `{"type":"result","subtype":"success","stop_reason":"end_turn",...}`
|
|
11
|
+
* event marks completion explicitly
|
|
12
|
+
* - `result.duration_ms`, `total_cost_usd`, `num_turns`, `usage`
|
|
13
|
+
* are the authoritative completion signals
|
|
14
|
+
*
|
|
15
|
+
* The parser must recognize BOTH formats. v4.13 adds detection for the
|
|
16
|
+
* result-event format while preserving backward compat with the existing
|
|
17
|
+
* SDK-internal format (tested in the sibling test files).
|
|
18
|
+
*/
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
20
|
+
import fs from "fs";
|
|
21
|
+
import os from "os";
|
|
22
|
+
import { resolve } from "path";
|
|
23
|
+
import { parseOutputFileStatus } from "../src/services/async-agent-parser.js";
|
|
24
|
+
|
|
25
|
+
const TMP_BASE = resolve(
|
|
26
|
+
os.tmpdir(),
|
|
27
|
+
`alvin-parser-streamjson-${process.pid}`,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
fs.mkdirSync(TMP_BASE, { recursive: true });
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
try {
|
|
35
|
+
fs.rmSync(TMP_BASE, { recursive: true, force: true });
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("parseOutputFileStatus — stream-json format (v4.13)", () => {
|
|
42
|
+
it("returns 'completed' when final event is type:result + subtype:success", async () => {
|
|
43
|
+
const path = resolve(TMP_BASE, "stream-success.jsonl");
|
|
44
|
+
const lines = [
|
|
45
|
+
{ type: "system", subtype: "init", session_id: "s1" },
|
|
46
|
+
{
|
|
47
|
+
type: "assistant",
|
|
48
|
+
message: {
|
|
49
|
+
role: "assistant",
|
|
50
|
+
content: [{ type: "text", text: "The answer is 42." }],
|
|
51
|
+
stop_reason: null, // streaming shape — NOT end_turn yet
|
|
52
|
+
},
|
|
53
|
+
session_id: "s1",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: "result",
|
|
57
|
+
subtype: "success",
|
|
58
|
+
stop_reason: "end_turn",
|
|
59
|
+
session_id: "s1",
|
|
60
|
+
total_cost_usd: 0.01,
|
|
61
|
+
duration_ms: 500,
|
|
62
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
63
|
+
result: "The answer is 42.",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
path,
|
|
68
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
69
|
+
"utf-8",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const status = await parseOutputFileStatus(path);
|
|
73
|
+
expect(status.state).toBe("completed");
|
|
74
|
+
if (status.state === "completed") {
|
|
75
|
+
expect(status.output).toContain("The answer is 42.");
|
|
76
|
+
expect(status.output).not.toMatch(/interrupted|partial/i);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("extracts tokens from result.usage when using stream-json format", async () => {
|
|
81
|
+
const path = resolve(TMP_BASE, "stream-tokens.jsonl");
|
|
82
|
+
const lines = [
|
|
83
|
+
{
|
|
84
|
+
type: "assistant",
|
|
85
|
+
message: {
|
|
86
|
+
content: [{ type: "text", text: "x" }],
|
|
87
|
+
stop_reason: null,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: "result",
|
|
92
|
+
subtype: "success",
|
|
93
|
+
stop_reason: "end_turn",
|
|
94
|
+
usage: { input_tokens: 1234, output_tokens: 567 },
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
fs.writeFileSync(
|
|
98
|
+
path,
|
|
99
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
100
|
+
"utf-8",
|
|
101
|
+
);
|
|
102
|
+
const status = await parseOutputFileStatus(path);
|
|
103
|
+
expect(status.state).toBe("completed");
|
|
104
|
+
if (status.state === "completed") {
|
|
105
|
+
expect(status.tokensUsed).toEqual({ input: 1234, output: 567 });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("recognises 'failed' state when result.is_error is true", async () => {
|
|
110
|
+
const path = resolve(TMP_BASE, "stream-failed.jsonl");
|
|
111
|
+
const lines = [
|
|
112
|
+
{
|
|
113
|
+
type: "assistant",
|
|
114
|
+
message: {
|
|
115
|
+
content: [{ type: "text", text: "I tried..." }],
|
|
116
|
+
stop_reason: null,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "result",
|
|
121
|
+
subtype: "error_max_turns",
|
|
122
|
+
is_error: true,
|
|
123
|
+
stop_reason: "max_turns",
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
fs.writeFileSync(
|
|
127
|
+
path,
|
|
128
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
129
|
+
"utf-8",
|
|
130
|
+
);
|
|
131
|
+
const status = await parseOutputFileStatus(path);
|
|
132
|
+
// With an is_error result + text content, we still deliver the text
|
|
133
|
+
// as completed (better to give the user SOMETHING than nothing).
|
|
134
|
+
// The delivery layer can annotate differently if it chooses.
|
|
135
|
+
expect(status.state).toBe("completed");
|
|
136
|
+
if (status.state === "completed") {
|
|
137
|
+
expect(status.output).toContain("I tried...");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns 'running' when stream-json events are present but no result yet", async () => {
|
|
142
|
+
const path = resolve(TMP_BASE, "stream-running.jsonl");
|
|
143
|
+
const lines = [
|
|
144
|
+
{ type: "system", subtype: "init", session_id: "s1" },
|
|
145
|
+
{
|
|
146
|
+
type: "assistant",
|
|
147
|
+
message: {
|
|
148
|
+
content: [{ type: "text", text: "Thinking..." }],
|
|
149
|
+
stop_reason: null,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "assistant",
|
|
154
|
+
message: {
|
|
155
|
+
content: [{ type: "tool_use", name: "Bash", input: {} }],
|
|
156
|
+
stop_reason: null,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
path,
|
|
162
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
163
|
+
"utf-8",
|
|
164
|
+
);
|
|
165
|
+
const status = await parseOutputFileStatus(path);
|
|
166
|
+
expect(status.state).toBe("running");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("aggregates text from ALL assistant messages when result arrives", async () => {
|
|
170
|
+
const path = resolve(TMP_BASE, "stream-multi-text.jsonl");
|
|
171
|
+
const lines = [
|
|
172
|
+
{
|
|
173
|
+
type: "assistant",
|
|
174
|
+
message: {
|
|
175
|
+
content: [{ type: "text", text: "First thought." }],
|
|
176
|
+
stop_reason: null,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: "user",
|
|
181
|
+
message: { content: [{ type: "tool_result", content: "ok" }] },
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "assistant",
|
|
185
|
+
message: {
|
|
186
|
+
content: [{ type: "text", text: "Continuing..." }],
|
|
187
|
+
stop_reason: null,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
type: "user",
|
|
192
|
+
message: { content: [{ type: "tool_result", content: "ok" }] },
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
type: "assistant",
|
|
196
|
+
message: {
|
|
197
|
+
content: [{ type: "text", text: "Final answer." }],
|
|
198
|
+
stop_reason: null,
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{ type: "result", subtype: "success", stop_reason: "end_turn" },
|
|
202
|
+
];
|
|
203
|
+
fs.writeFileSync(
|
|
204
|
+
path,
|
|
205
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
206
|
+
"utf-8",
|
|
207
|
+
);
|
|
208
|
+
const status = await parseOutputFileStatus(path);
|
|
209
|
+
expect(status.state).toBe("completed");
|
|
210
|
+
if (status.state === "completed") {
|
|
211
|
+
// All three text blocks must be present
|
|
212
|
+
expect(status.output).toContain("First thought");
|
|
213
|
+
expect(status.output).toContain("Continuing");
|
|
214
|
+
expect(status.output).toContain("Final answer");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("prefers result.result field as authoritative output when available", async () => {
|
|
219
|
+
// The stream-json's result event has a `result` field with the
|
|
220
|
+
// already-concatenated final answer. Use it directly when present
|
|
221
|
+
// (more accurate than re-aggregating from streaming chunks).
|
|
222
|
+
const path = resolve(TMP_BASE, "stream-result-field.jsonl");
|
|
223
|
+
const lines = [
|
|
224
|
+
{
|
|
225
|
+
type: "assistant",
|
|
226
|
+
message: {
|
|
227
|
+
content: [{ type: "text", text: "Intermediate chunk" }],
|
|
228
|
+
stop_reason: null,
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
type: "result",
|
|
233
|
+
subtype: "success",
|
|
234
|
+
stop_reason: "end_turn",
|
|
235
|
+
result: "FINAL AUTHORITATIVE ANSWER",
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
fs.writeFileSync(
|
|
239
|
+
path,
|
|
240
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
241
|
+
"utf-8",
|
|
242
|
+
);
|
|
243
|
+
const status = await parseOutputFileStatus(path);
|
|
244
|
+
expect(status.state).toBe("completed");
|
|
245
|
+
if (status.state === "completed") {
|
|
246
|
+
expect(status.output).toContain("FINAL AUTHORITATIVE ANSWER");
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("handles result event with only partial fields (defensive)", async () => {
|
|
251
|
+
const path = resolve(TMP_BASE, "stream-result-minimal.jsonl");
|
|
252
|
+
const lines = [
|
|
253
|
+
{
|
|
254
|
+
type: "assistant",
|
|
255
|
+
message: {
|
|
256
|
+
content: [{ type: "text", text: "Some output" }],
|
|
257
|
+
stop_reason: null,
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{ type: "result" }, // no subtype, no result field, no usage
|
|
261
|
+
];
|
|
262
|
+
fs.writeFileSync(
|
|
263
|
+
path,
|
|
264
|
+
lines.map((l) => JSON.stringify(l)).join("\n") + "\n",
|
|
265
|
+
"utf-8",
|
|
266
|
+
);
|
|
267
|
+
const status = await parseOutputFileStatus(path);
|
|
268
|
+
expect(status.state).toBe("completed");
|
|
269
|
+
if (status.state === "completed") {
|
|
270
|
+
expect(status.output).toContain("Some output");
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|