diff --git a/src/__tests__/local-agent-path-resolution.test.ts b/src/__tests__/local-agent-path-resolution.test.ts index 968e5dd..3ef2989 100644 --- a/src/__tests__/local-agent-path-resolution.test.ts +++ b/src/__tests__/local-agent-path-resolution.test.ts @@ -283,3 +283,59 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns })).resolves.toBe('--no-tools --model openai-codex/gpt-5|long planning prompt'); }); }); + +/** + * REGRESSION guard (Tier 2 — #139 follow-up, session resume): confirmed empirically against the + * real Claude Code CLI that an unknown/expired --resume id is a hard, fast failure (nonzero exit, + * "No conversation found with session ID: ..." on stderr, no JSON on stdout) — the CLI never falls + * back to a fresh session on its own. localAgentChat does that fallback itself, once. These tests + * drive the REAL function (not mocked) through a fake CLI script so the retry wiring is pinned, + * not just the higher-level LocalAgentAdapter call-shape covered in local-agent-tool-calling.test.ts. + */ +const FAKE_CLI_RESUME_STALE = `#!/bin/sh +cat >/dev/null 2>&1 +case "$*" in + *--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;; + *) echo '{"result":"fresh ok","session_id":"new-session-123"}' ;; +esac +`; + +describe('localAgentChat — stale Claude session fallback (Tier 2)', () => { + it('retries WITHOUT --resume when the resumed session is stale, and succeeds', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ping', { sessionId: 'stale-uuid', timeoutMs: 4000 }); + expect(JSON.parse(out).result).toBe('fresh ok'); + }); + + it('never adds --resume when no sessionId is supplied (no unnecessary retry path)', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + const out = await localAgentChat('claude', 'ping', { timeoutMs: 4000 }); + expect(JSON.parse(out).result).toBe('fresh ok'); + }); + + it('a genuine failure with no session in play still rejects (no unnecessary-retry regression)', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', '#!/bin/sh\ncat >/dev/null 2>&1\necho "boom" >&2\nexit 1\n'); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).rejects.toThrow(/boom/); + }); + + it('propagates the fallback attempt\'s own error when both the resumed and fresh calls fail', async () => { + const home = scratch(); + putExe(join(home, '.local', 'bin'), 'claude', '#!/bin/sh\ncat >/dev/null 2>&1\necho "both broken" >&2\nexit 1\n'); + process.env.T3MP3ST_AGENT_HOME = home; + process.env.PATH = '/usr/bin:/bin'; + + await expect(localAgentChat('claude', 'ping', { sessionId: 'whatever', timeoutMs: 4000 })).rejects.toThrow(/both broken/); + }); +}); diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts index fcb26c8..0095422 100644 --- a/src/__tests__/local-agent-tool-calling.test.ts +++ b/src/__tests__/local-agent-tool-calling.test.ts @@ -195,6 +195,70 @@ describe('Claude local-agent usage accounting (#139)', () => { }); }); +describe('Claude local-agent session resume (Tier 2)', () => { + it('the first call on a fresh adapter passes no sessionId', async () => { + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await claudeBackbone().chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('captures session_id from the response and passes it as --resume on the next call', async () => { + const be = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat([{ role: 'user', content: 'hello' }]); + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-1' })); + await be.chat([{ role: 'user', content: 'again' }]); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: 'sess-1' }); + }); + + it('resetLocalAgentSession() drops the tracked session so the next call starts fresh', async () => { + const be = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat([{ role: 'user', content: 'hello' }]); + + be.resetLocalAgentSession(); + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-2' })); + await be.chat([{ role: 'user', content: 'again' }]); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('a separate LLMBackbone instance never inherits another instance\'s session (per-operator isolation)', async () => { + const opA = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'a1', session_id: 'sess-a' })); + await opA.chat([{ role: 'user', content: 'hello' }]); + + const opB = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'b1', session_id: 'sess-b' })); + await opB.chat([{ role: 'user', content: 'hello' }]); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); + + it('a resumed session with no usable id in the envelope keeps the PRIOR session for the next call', async () => { + const be = claudeBackbone(); + cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' })); + await be.chat([{ role: 'user', content: 'hello' }]); + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'second' })); // no session_id this time + await be.chat([{ role: 'user', content: 'again' }]); + + cli.mockResolvedValueOnce(JSON.stringify({ result: 'third', session_id: 'sess-1' })); + await be.chat([{ role: 'user', content: 'once more' }]); + + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: 'sess-1' }); + }); + + it('non-claude local agents (codex CLI via local-agent provider) never receive a sessionId', async () => { + cli.mockResolvedValueOnce('done'); + await localBackbone().chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined }); + }); +}); + describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => { it('returns toolCalls when codex emits the contract', async () => { fileRead.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```'); diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index b2699b6..3ae774f 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -489,7 +489,7 @@ export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number): * clean reply, while Hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own * login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires. */ -export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise { +export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number; sessionId?: string } = {}): Promise { const spec = getSpec(id); if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`)); // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). @@ -498,6 +498,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); let args: string[]; + let claudeArgsNoResume: string[] | null = null; let viaStdin = true; let outFile: string | null = null; let workDir: string | null = null; @@ -505,7 +506,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin // json (not text): the envelope carries REAL per-call token usage (input/output tokens // plus prompt-cache creation/read) that LocalAgentAdapter.chat() parses to drive // AgentLoop's token budget check. text mode reports no usage at all. - args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])]; + claudeArgsNoResume = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])]; + // --resume continues a prior Claude Code session (LocalAgentAdapter tracks the id) so the CLI + // carries the accumulated transcript itself instead of the caller resending it every turn. + args = opts.sessionId + ? ['-p', '--output-format', 'json', '--resume', opts.sessionId, ...(model ? ['--model', model] : [])] + : claudeArgsNoResume; } else if (id === 'codex') { workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-')); outFile = join(workDir, 'reply.txt'); @@ -523,8 +529,8 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } }; const resolvedBin = resolveBin(spec.bin) || spec.bin; - return new Promise((resolve, reject) => { - const child = spawnAgent(resolvedBin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); + const runOnce = (argv: string[]): Promise => new Promise((resolve, reject) => { + const child = spawnAgent(resolvedBin, argv, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] }); let out = ''; let errOut = ''; let done = false; @@ -545,4 +551,13 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin }); if (viaStdin && child.stdin) { child.stdin.write(prompt); child.stdin.end(); } }); + + const first = runOnce(args); + if (!claudeArgsNoResume || args === claudeArgsNoResume) return first; + // A resumed Claude session can go stale (CLI storage pruned, different session dir, expired). + // Confirmed empirically: an unknown/invalid --resume id is a hard, fast failure (nonzero exit, + // "No conversation found with session ID: ..." on stderr, no JSON on stdout) — the CLI never + // falls back to a fresh session on its own. Do that fallback here, once, rather than failing + // the whole task over a stale id. + return first.catch(() => runOnce(claudeArgsNoResume as string[])); } diff --git a/src/index.ts b/src/index.ts index b9d162d..5da13f7 100755 --- a/src/index.ts +++ b/src/index.ts @@ -999,6 +999,12 @@ export class TempestCommand extends EventEmitter { this.mission.generateNextPhaseTasks(target.address); } + // Local-agent operators are pre-spawned once and reused for the whole mission (see + // autoSpawnForPhase below), so a resumed CLI session (Claude Code --resume) would + // otherwise span every phase. Drop it here so each phase starts a fresh session instead + // of one unbounded session for the entire kill chain. + for (const op of this.cell.getAllOperators()) op.resetLLMSession(); + // Auto-spawn operators for the new phase const nextPhase = mission.currentPhase; this.autoSpawnForPhase(nextPhase); diff --git a/src/llm/index.ts b/src/llm/index.ts index 001601c..430f3aa 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -48,6 +48,8 @@ export interface LLMProviderAdapter { chat(messages: LLMMessage[], options?: ChatOptions): Promise; stream?(messages: LLMMessage[], options?: ChatOptions): AsyncGenerator; validateConfig(): { valid: boolean; error?: string }; + /** Drop any resumed backend session so the next chat() starts clean. No-op where not applicable. */ + resetSession?(): void; } export interface ChatOptions { @@ -1315,6 +1317,7 @@ class CodexAdapter implements LLMProviderAdapter { // see and which measured 4-25k tokens on a single trivial call in testing. interface ClaudeJsonEnvelope { result?: string; + session_id?: string; usage?: { input_tokens?: number; output_tokens?: number; @@ -1324,7 +1327,7 @@ interface ClaudeJsonEnvelope { } /** Parse the envelope; invalid usage is omitted so the caller can retain content and estimate. */ -function parseClaudeJsonEnvelope(raw: string): { content: string; usage?: LLMResponse['usage'] } | null { +function parseClaudeJsonEnvelope(raw: string): { content: string; sessionId?: string; usage?: LLMResponse['usage'] } | null { let json: ClaudeJsonEnvelope; try { json = JSON.parse(raw); @@ -1332,16 +1335,17 @@ function parseClaudeJsonEnvelope(raw: string): { content: string; usage?: LLMRes return null; } if (typeof json.result !== 'string') return null; + const sessionId = typeof json.session_id === 'string' && json.session_id ? json.session_id : undefined; const u = json.usage || {}; const tokenValues = [u.input_tokens, u.output_tokens, u.cache_creation_input_tokens, u.cache_read_input_tokens]; if (!tokenValues.some((v) => v !== undefined) || tokenValues.some((v) => v !== undefined && (!Number.isFinite(v) || v < 0))) { - return { content: json.result }; + return { content: json.result, sessionId }; } const promptTokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0); const completionTokens = u.output_tokens ?? 0; - if (promptTokens + completionTokens === 0) return { content: json.result }; - return { content: json.result, usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } }; + if (promptTokens + completionTokens === 0) return { content: json.result, sessionId }; + return { content: json.result, sessionId, usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens } }; } // Character-based fallback ONLY — used when the real envelope can't be parsed (older CLI, @@ -1360,7 +1364,14 @@ function estimateUsage(promptText: string, completionText: string): LLMResponse[ class LocalAgentAdapter implements LLMProviderAdapter { name = 'local-agent'; private config: LLMConfig; + // Claude Code session to --resume (see local-agents.ts). Lives as long as this adapter does — + // one LocalAgentAdapter is held for an operator's whole life, so this naturally spans every task + // until the caller (TempestCommand, on phase advance) calls resetSession() to start a new one. + private claudeSessionId?: string; constructor(config: LLMConfig) { this.config = config; } + resetSession(): void { + this.claudeSessionId = undefined; + } // Split "agentId[::model]" → { agentId, agentModel }. No separator = just the agent id (CLI default model). private parseAgentSpec(): { agentId: string; agentModel?: string } { const raw = this.config.model || 'codex'; @@ -1392,7 +1403,11 @@ class LocalAgentAdapter implements LLMProviderAdapter { const { agentId, agentModel } = this.parseAgentSpec(); const prompt = this.formatPrompt(messages, options); const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined; - const raw = (await localAgentChat(agentId, prompt, { model: agentModel, timeoutMs })).trim(); + const raw = (await localAgentChat(agentId, prompt, { + model: agentModel, + timeoutMs, + sessionId: agentId === 'claude' ? this.claudeSessionId : undefined, + })).trim(); // Claude requests --output-format json (see local-agents.ts) so this parses to REAL usage. // Anything else (parse failure, or a non-claude agent) falls back to the raw text — claude // additionally gets a character-based usage ESTIMATE so its budget check is never blind again. @@ -1401,6 +1416,10 @@ class LocalAgentAdapter implements LLMProviderAdapter { const usage = agentId === 'claude' ? (parsed?.usage ?? estimateUsage(prompt, parsed?.content ?? raw)) : undefined; + // Carry the session forward so the NEXT call (--resume) picks up where this one left off. + // Empirically stable across --resume (confirmed against the real CLI), but re-capture anyway + // in case a future CLI version rotates it. + if (agentId === 'claude' && parsed?.sessionId) this.claudeSessionId = parsed.sessionId; // Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer. const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; @@ -1574,6 +1593,11 @@ export class LLMBackbone extends EventEmitter { return this.config.model; } + /** Drop any resumed local-agent session (e.g. Claude Code's --resume id) so the next chat() starts fresh. */ + resetLocalAgentSession(): void { + this.adapter.resetSession?.(); + } + /** * Validate the configuration */ diff --git a/src/operators/index.ts b/src/operators/index.ts index 32abc63..a66640a 100755 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -413,6 +413,16 @@ export class OperatorAgent extends EventEmitter { this.agentLoop = agentLoop; } + /** + * Drop any resumed local-agent session (e.g. Claude Code's --resume id). Called at kill-chain + * phase boundaries: local-agent operators are pre-spawned once and reused for the whole + * mission (auto-spawn-per-phase is disabled for local-agent), so nothing else would naturally + * end a resumed session between phases. + */ + resetLLMSession(): void { + this.llm?.resetLocalAgentSession(); + } + /** Attach the shared pack board so this operator sees the swarm's live lead-board (Phase-2). */ attachBoard(board: PackBoard): void { this.board = board;