Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
94 changes: 94 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,97 @@ 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
`;

// Echoes exactly what it received on stdin back to stdout on the fresh (non-resume) path — lets a
// test prove WHICH prompt string a fresh-session retry actually received, not just that it succeeded.
const FAKE_CLI_ECHO_ON_FRESH = `#!/bin/sh
prompt=$(cat)
case "$*" in
*--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;;
*) printf '%s' "$prompt" ;;
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/);
});

// PR #149 review (jmagly): the fresh-session retry has no history at all, so it must receive the
// FULL transcript (fallbackPrompt) — never the delta sized for the (failed) resumed attempt.
it('the fresh-session retry receives fallbackPrompt, not the delta prompt of the failed resumed attempt', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'DELTA-ONLY-CONTENT', {
sessionId: 'stale-uuid',
fallbackPrompt: 'FULL-TRANSCRIPT-CONTENT',
timeoutMs: 4000,
});

expect(out).toBe('FULL-TRANSCRIPT-CONTENT');
expect(out).not.toContain('DELTA-ONLY-CONTENT');
});

it('falls back to the (delta) prompt itself when no fallbackPrompt is supplied', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'ONLY-PROMPT-CONTENT', { sessionId: 'stale-uuid', timeoutMs: 4000 });
expect(out).toBe('ONLY-PROMPT-CONTENT');
});
});
135 changes: 134 additions & 1 deletion src/__tests__/local-agent-tool-calling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ vi.mock('fs/promises', async (orig) => {
return { ...actual, readFile: vi.fn() };
});

import { parseTextToolCalls, LLMBackbone } from '../llm/index.js';
import { parseTextToolCalls, LLMBackbone, type LLMMessage } from '../llm/index.js';
import { localAgentChat } from '../agent/local-agents.js';
import { readFile } from 'fs/promises';
const cli = vi.mocked(localAgentChat);
Expand Down Expand Up @@ -195,6 +195,139 @@ 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 });
});

// PR #149 review (jmagly): formatPrompt() always serialized the WHOLE messages array, so a
// resumed call duplicated the transcript into a session that already had it — the exact resend
// this feature exists to eliminate. These pin the fix: same-array (same task) reuse sends only
// the NEW messages once a session exists; a different array (a new task) still gets everything.
it('a resumed call on the SAME growing messages array sends only the delta, not the full transcript', async () => {
const be = claudeBackbone();
const messages: LLMMessage[] = [
{ role: 'system', content: 'SYSTEM-PROMPT-MARKER' },
{ role: 'user', content: 'USER-TASK-MARKER' },
];

cli.mockResolvedValueOnce(JSON.stringify({
result: '{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}',
session_id: 'sess-1',
}));
await be.chatWithTools(messages, TOOLS as never);

// AgentLoop's real growth pattern: push the assistant turn + tool result onto the SAME array.
messages.push({ role: 'assistant', content: 'reasoning', toolCalls: [{ id: 'c1', name: 'nmap_scan', arguments: { target: 'x' } }] });
messages.push({ role: 'tool', content: 'TOOL-RESULT-MARKER', toolCallId: 'c1', name: 'nmap_scan' });

cli.mockResolvedValueOnce(JSON.stringify({ result: 'Final debrief.', session_id: 'sess-1' }));
await be.chatWithTools(messages, TOOLS as never);

const secondPrompt = String(cli.mock.calls.at(-1)?.[1] || '');
expect(secondPrompt).not.toContain('SYSTEM-PROMPT-MARKER');
expect(secondPrompt).not.toContain('USER-TASK-MARKER');
expect(secondPrompt).toContain('TOOL-RESULT-MARKER');
expect(secondPrompt).toContain('ACTION CONTRACT'); // tool contract retained on the delta call
});

it('a NEW task (different messages array) still gets the full prompt even though the session resumes', async () => {
const be = claudeBackbone();
cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' }));
await be.chat([{ role: 'system', content: 'TASK-ONE-SYSTEM' }, { role: 'user', content: 'TASK-ONE-USER' }]);

// A brand-new task builds a brand-new array (AgentLoop.run() does this per task) — the
// resumed session has never seen this content, so it must go out in full despite --resume.
cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-1' }));
await be.chat([{ role: 'system', content: 'TASK-TWO-SYSTEM' }, { role: 'user', content: 'TASK-TWO-USER' }]);

const secondCall = cli.mock.calls.at(-1);
const secondPrompt = String(secondCall?.[1] || '');
expect(secondPrompt).toContain('TASK-TWO-SYSTEM');
expect(secondPrompt).toContain('TASK-TWO-USER');
expect(secondCall?.[2]).toMatchObject({ sessionId: 'sess-1', fallbackPrompt: undefined });
});

it('passes fallbackPrompt (the FULL transcript) alongside a delta send, so a stale-session retry never gets the delta', async () => {
const be = claudeBackbone();
const messages: LLMMessage[] = [{ role: 'system', content: 'S' }, { role: 'user', content: 'U' }];

cli.mockResolvedValueOnce(JSON.stringify({ result: 'first', session_id: 'sess-1' }));
await be.chat(messages);
expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ fallbackPrompt: undefined }); // nothing to fall back from yet

messages.push({ role: 'assistant', content: 'ok' });
cli.mockResolvedValueOnce(JSON.stringify({ result: 'second', session_id: 'sess-1' }));
await be.chat(messages);

const call = cli.mock.calls.at(-1);
const deltaPrompt = String(call?.[1] || '');
const fallbackPrompt = String((call?.[2] as { fallbackPrompt?: string } | undefined)?.fallbackPrompt || '');
expect(deltaPrompt).not.toContain('### SYSTEM\nS'); // delta omits the first task's system/user content
expect(deltaPrompt).not.toContain('### USER\nU');
expect(fallbackPrompt).toContain('### SYSTEM\nS'); // fallback carries the FULL transcript a fresh session would need
expect(fallbackPrompt).toContain('### USER\nU');
});
});

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
26 changes: 21 additions & 5 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; fallbackPrompt?: 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[], promptToSend: 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 @@ -543,6 +549,16 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
});
});
if (viaStdin && child.stdin) { child.stdin.write(prompt); child.stdin.end(); }
if (viaStdin && child.stdin) { child.stdin.write(promptToSend); child.stdin.end(); }
});

const first = runOnce(args, prompt);
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. The fresh session has no history, so it MUST get the full
// transcript (fallbackPrompt), never the delta `prompt` that was sized for the resumed attempt.
return first.catch(() => runOnce(claudeArgsNoResume as string[], opts.fallbackPrompt ?? prompt));
}
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
Loading
Loading