Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/__tests__/local-agent-path-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
64 changes: 64 additions & 0 deletions src/__tests__/local-agent-tool-calling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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```');
Expand Down
23 changes: 19 additions & 4 deletions src/agent/local-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number; sessionId?: string } = {}): Promise<string> {
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).
Expand All @@ -498,14 +498,20 @@ 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;
if (id === 'claude') {
// 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');
Expand All @@ -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<string> => new Promise((resolve, reject) => {
const child = spawnAgent(resolvedBin, argv, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
Expand All @@ -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[]));
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
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);
Expand Down
34 changes: 29 additions & 5 deletions src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export interface LLMProviderAdapter {
chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse>;
stream?(messages: LLMMessage[], options?: ChatOptions): AsyncGenerator<string, void, unknown>;
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 {
Expand Down Expand Up @@ -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;
Expand All @@ -1324,24 +1327,25 @@ 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);
} catch {
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,
Expand All @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -1574,6 +1593,11 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
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
*/
Expand Down
10 changes: 10 additions & 0 deletions src/operators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,16 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
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;
Expand Down
Loading