Files
nanoclaw/container/agent-runner/scripts/sdk-signal-probe.ts
gavrielc 47950671fa docs: add v1→v2 action-items analysis + SDK signal probe tool
- docs/v1-vs-v2/: full v1→v2 regression analysis (SUMMARY + 21 per-module
  docs + ACTION-ITEMS rollup with decisions + timezone recreation spec).
- container/agent-runner/scripts/sdk-signal-probe.ts: empirical harness
  used to characterise Claude Agent SDK event/hook/stderr timing for the
  stuck-detection design in item 9.
- src/channels/chat-sdk-bridge.ts: document the conversations Map staleness
  in a code comment; fix deferred to when dynamic group registration lands
  (ACTION-ITEMS item 17).

No runtime behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 01:00:04 +03:00

170 lines
5.5 KiB
TypeScript

#!/usr/bin/env bun
/**
* SDK signal probe: run a prompt, log every signal the Agent SDK emits —
* async-iterator events + hook callbacks + CLI stderr — with absolute
* and relative timing.
*
* Usage:
* bun run scripts/sdk-signal-probe.ts "<prompt>" # simple string mode
* bun run scripts/sdk-signal-probe.ts --stream "<prompt>" # streaming-input mode
* bun run scripts/sdk-signal-probe.ts --stream "<p>" \
* --push "5000:<text>" --push "15000:<text>" --timeout 60000 # multi-push
*
* Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`,
* which keeps the CLI subprocess alive past the first result (per SDK
* deep dive). Required for post-result pushes, agent teams, background
* task notifications.
*/
import { query } from '@anthropic-ai/claude-agent-sdk';
const args = process.argv.slice(2);
const prompts: string[] = [];
const pushes: Array<{ atMs: number; text: string }> = [];
let streamMode = false;
let timeoutMs: number | undefined;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--stream') streamMode = true;
else if (a === '--push') {
const val = args[++i] ?? '';
const ix = val.indexOf(':');
if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`);
pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) });
} else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10);
else if (a === '--prompt') prompts.push(args[++i] ?? '');
else prompts.push(a);
}
const prompt = prompts.join(' ');
if (!prompt) {
console.error('usage: sdk-signal-probe.ts [--stream] "<prompt>" [--push MS:<text>]... [--timeout MS]');
process.exit(1);
}
const T0 = Date.now();
let LAST = T0;
function log(source: string, type: string, payload: unknown = {}): void {
const now = Date.now();
const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload };
LAST = now;
console.log(JSON.stringify(entry));
}
function hookLogger(eventName: string) {
return async (input: unknown, toolUseID: string | undefined): Promise<any> => {
log('hook', eventName, { toolUseID, input });
// Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash,
// never resolve — simulates a tool that hangs forever.
if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') {
const toolName = (input as any)?.tool_name ?? (input as any)?.name;
if (toolName === 'Bash') {
log('meta', 'pre_tool_use_hanging', { toolUseID, toolName });
await new Promise(() => {
/* never resolves */
});
}
}
return { continue: true };
};
}
const HOOK_EVENTS = [
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
'Notification',
'UserPromptSubmit',
'SessionStart',
'SessionEnd',
'Stop',
'SubagentStart',
'SubagentStop',
'PreCompact',
'PermissionRequest',
] as const;
const hooks: Record<string, unknown[]> = {};
for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }];
// Build prompt — string (single-turn) or AsyncIterable (streaming-input)
let promptInput: any;
if (streamMode) {
const sessionId = `probe-${Date.now()}`;
async function* streamPrompt() {
// Initial user message
yield {
type: 'user' as const,
message: { role: 'user' as const, content: prompt },
parent_tool_use_id: null,
session_id: sessionId,
};
// Schedule subsequent pushes
const startT = Date.now();
const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs);
for (const p of sorted) {
const waitMs = Math.max(0, p.atMs - (Date.now() - startT));
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
log('meta', 'push_message', { atMs: p.atMs, text: p.text });
yield {
type: 'user' as const,
message: { role: 'user' as const, content: p.text },
parent_tool_use_id: null,
session_id: sessionId,
};
}
// Keep stream open for tail events; iterator ends when we return
// (no more work expected). For post-result-idle scenarios, wait here.
await new Promise((r) => setTimeout(r, 5000));
}
promptInput = streamPrompt();
} else {
promptInput = prompt;
}
log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs });
const q = query({
prompt: promptInput,
options: {
includePartialMessages: true,
hooks: hooks as any,
stderr: (data: string) => log('stderr', 'chunk', { data }),
settingSources: [],
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
},
});
// Absolute time cap — exit cleanly so the log flushes
if (timeoutMs) {
setTimeout(() => {
log('meta', 'timeout_hit', { timeoutMs });
setTimeout(() => process.exit(0), 250);
}, timeoutMs);
}
try {
for await (const event of q) {
const snapshot: any = { ...event };
try {
const raw = JSON.stringify(snapshot);
if (raw.length > 2000) {
snapshot._truncated_bytes = raw.length;
if (snapshot.message?.content) {
const c = JSON.stringify(snapshot.message.content);
snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` };
}
}
} catch {
/* best-effort */
}
log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot });
}
log('meta', 'iterator_done');
} catch (err: any) {
log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) });
}