blob: b0f173db28bd239a5adc6015da5b1c7756ae9fda [file]
import { createTestToolRuntime } from './execution-boundary-test-helpers.js';
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { z } from 'zod';
import type { SessionEvent, ToolResultContent } from '@maka/core';
import type { SessionHeader, StoredMessage } from '@maka/core';
import type { ToolInvocationRecord } from '@maka/core';
import {
ToolRuntime,
formatLoopGateText,
formatAmbiguousComputerLoopGateText,
formatDeferredNotLoadedText,
LOOP_GATE_IDENTICAL_THRESHOLD,
type MakaTool,
} from '../tool-runtime.js';
type ShellRunToolResult = Extract<ToolResultContent, { kind: 'shell_run' }>;
type ObservedShellRunStatus = Extract<
ShellRunToolResult['status'],
'failed' | 'timed_out' | 'cancelled' | 'orphaned'
>;
const observedShellRunStatuses = [
'failed',
'timed_out',
'cancelled',
'orphaned',
] as const satisfies readonly ObservedShellRunStatus[];
// The loop-gate blocks a back-to-back run of byte-identical tool calls (same
// tool + same args) only after they have FAILED N-1 times in a row (#92). A
// success — or any different call — resets the streak, so legitimate polling and
// iterate-then-retry are never gated. These tests drive ToolRuntime directly so
// the path resolves synchronously (no streaming, no permission parking).
function header(): SessionHeader {
return {
id: 'session-1',
workspaceRoot: '/tmp/maka',
cwd: '/tmp/maka',
createdAt: 1,
lastUsedAt: 1,
name: 'Test',
titleIsManual: true,
isFlagged: false,
labels: [],
isArchived: false,
status: 'active',
statusUpdatedAt: 1,
hasUnread: false,
backend: 'ai-sdk',
llmConnectionSlug: 'c',
connectionLocked: true,
model: 'm',
permissionMode: 'ask',
schemaVersion: 1,
};
}
interface Harness {
runtime: ToolRuntime;
pushed: SessionEvent[];
impl: string[];
invocations: Array<Pick<ToolInvocationRecord, 'toolName' | 'status'>>;
}
function makeHarness(): Harness {
const appended: StoredMessage[] = [];
const pushed: SessionEvent[] = [];
const impl: string[] = [];
const invocations: Array<Pick<ToolInvocationRecord, 'toolName' | 'status'>> = [];
let n = 0;
const runtime = createTestToolRuntime({
sessionId: 'session-1',
header: header(),
connection: { providerType: 'openai', slug: 'c' } as never,
modelId: 'm',
appendMessage: async (m) => {
appended.push(m);
},
newId: () => `id-${++n}`,
now: () => 1,
getPermissionPauseTarget: () => null,
recordToolInvocation: (record) => {
invocations.push({ toolName: record.toolName, status: record.status });
},
});
return { runtime, pushed, impl, invocations };
}
// A tool that succeeds (returns {ok:true}). Used for the polling / no-gate cases.
function makeTool(name: string, impl: string[]): MakaTool {
return {
name,
description: name,
parameters: z.object({}).passthrough(),
impl: (args) => {
impl.push(`${name}:${JSON.stringify(args)}`);
return { ok: true };
},
};
}
// A tool that always throws — its synthetic error counts as a loop-gate failure.
function makeFailingTool(name: string, impl: string[], message = 'boom'): MakaTool {
return {
name,
description: name,
parameters: z.object({}).passthrough(),
impl: (args) => {
impl.push(`${name}:${JSON.stringify(args)}`);
throw new Error(message);
},
};
}
// A tool whose outcome flips with `box.fail`, so one tool can both fail and
// succeed across calls (for the success-resets-the-streak case).
function makeFlakyTool(
name: string,
impl: string[],
box: { fail: boolean },
message = 'boom',
): MakaTool {
return {
name,
description: name,
parameters: z.object({}).passthrough(),
impl: (args) => {
impl.push(`${name}:${JSON.stringify(args)}`);
if (box.fail) throw new Error(message);
return { ok: true };
},
};
}
// A tool that RETURNS a terminal result instead of throwing. Terminal status
// must be classified by deriveToolResultStatus() for the loop-gate to see
// failures.
function makeTerminalTool(name: string, impl: string[], exitCode: number): MakaTool {
return {
name,
description: name,
parameters: z.object({}).passthrough(),
impl: (args) => {
impl.push(`${name}:${JSON.stringify(args)}`);
return {
kind: 'terminal',
cwd: '/tmp/maka',
cmd: 'cmd',
status: exitCode === 0 ? 'completed' : 'failed',
exitCode,
output: {
mode: 'pipes',
stdout: '',
stderr: exitCode === 0 ? '' : 'boom\n',
stdoutTruncated: false,
stderrTruncated: false,
redacted: false,
},
};
},
};
}
function makeShellRunTool(name: string, impl: string[], status: ObservedShellRunStatus): MakaTool {
return {
name,
description: name,
parameters: z.object({}).passthrough(),
impl: (args) => {
impl.push(`${name}:${JSON.stringify(args)}`);
return {
kind: 'shell_run',
ref: 'maka://runtime/background-tasks/shell-run-1',
mode: 'pipes',
status,
cwd: '/tmp/maka',
cmd: 'cmd',
startedAt: 1,
updatedAt: 2,
completedAt: 2,
...(status === 'orphaned'
? { failureMessage: 'missing live shell process handle' }
: {
exitCode: status === 'timed_out' ? 124 : status === 'cancelled' ? 130 : 1,
failureMessage:
status === 'timed_out'
? 'Command timed out after 10000ms'
: status === 'cancelled'
? 'Command cancelled'
: 'Command failed',
}),
revision: 2,
output: {
mode: 'pipes',
stdout: '',
stderr: status === 'failed' ? 'boom\n' : '',
stdoutTruncated: false,
stderrTruncated: false,
redacted: false,
},
} satisfies ShellRunToolResult;
},
};
}
function makeComputerFailureTool(impl: string[], failureClass?: 'ambiguous_target'): MakaTool {
return {
name: 'maka_computer',
description: 'computer',
parameters: z.object({}).passthrough(),
categoryHint: 'computer_use',
permissionArgs: (args) => {
const record = args as Record<string, unknown>;
return record.action === 'observe'
? record
: {
...record,
app: 'Fixture',
window_id: 7,
element_identity: {
token: `snapshot:${String(record.element_id ?? 'unknown')}`,
role: 'AXButton',
label: 'Duplicate',
},
};
},
impl: (args) => {
impl.push(JSON.stringify(args));
if ((args as { action?: string }).action === 'observe') {
return { text: 'observed' };
}
return {
text: 'maka_computer failed: stale_frame',
error: 'stale_frame',
...(failureClass ? { failureClass } : {}),
};
},
};
}
let callSeq = 0;
function call(
h: Harness,
t: MakaTool,
args: unknown,
turnId = 'turn-1',
origin: 'provider' | 'code_mode' = 'provider',
): Promise<unknown> {
return h.runtime
.settleToolCall({
tool: t,
turnId,
toolCallId: `tc-${++callSeq}`,
input: args,
origin,
abortSignal: new AbortController().signal,
eventSink: {
push: (event) => h.pushed.push(event),
pushAndWaitUntilConsumed: async (event) => {
h.pushed.push(event);
},
},
})
.then((settlement) => settlement.result);
}
describe('loop-gate for repeated identical FAILING tool calls', () => {
test('keeps nested CodeMode failures out of the provider loop-gate streak', async () => {
const h = makeHarness();
const t = makeFailingTool('Read', h.impl);
const args = { path: 'README.md' };
await call(h, t, args, 'turn-1', 'code_mode');
await call(h, t, args, 'turn-1', 'code_mode');
const providerResult = await call(h, t, args, 'turn-1', 'provider');
assert.deepEqual(providerResult, { error: 'boom' });
assert.equal(h.impl.length, 3);
});
test('runs the first N-1 identical failing calls then blocks the Nth (and keeps blocking)', async () => {
const h = makeHarness();
const t = makeFailingTool('Edit', h.impl, 'edit failed');
const args = { path: 'a.ts', old_string: 'x', new_string: 'y' };
const results: unknown[] = [];
for (let i = 0; i < LOOP_GATE_IDENTICAL_THRESHOLD; i++) results.push(await call(h, t, args));
assert.equal(
h.impl.length,
LOOP_GATE_IDENTICAL_THRESHOLD - 1,
'only the calls before the gate ran',
);
assert.deepEqual(results[LOOP_GATE_IDENTICAL_THRESHOLD - 1], {
error: formatLoopGateText('Edit'),
});
const again = await call(h, t, args);
assert.deepEqual(
again,
{ error: formatLoopGateText('Edit') },
'further identical calls stay blocked',
);
assert.equal(h.impl.length, LOOP_GATE_IDENTICAL_THRESHOLD - 1, 'no further impl runs');
});
test('identical SUCCEEDING calls are never gated — polling is allowed', async () => {
const h = makeHarness();
const poll = makeTool('Bash', h.impl);
const args = { command: 'git status --porcelain' };
const runs = LOOP_GATE_IDENTICAL_THRESHOLD + 2;
const results: unknown[] = [];
for (let i = 0; i < runs; i++) results.push(await call(h, poll, args));
assert.equal(h.impl.length, runs, 'every successful poll ran');
assert.deepEqual(
results,
Array.from({ length: runs }, () => ({ ok: true })),
'no poll was gated',
);
});
test('a success between failures resets the streak', async () => {
const h = makeHarness();
const box = { fail: true };
const t = makeFlakyTool('Bash', h.impl, box);
const args = { command: 'npm test' };
box.fail = true;
await call(h, t, args); // fail → streak 1
box.fail = false;
await call(h, t, args); // success → streak reset to 0
box.fail = true;
await call(h, t, args); // fail → streak 1
const stillRuns = await call(h, t, args); // fail → streak 2 (was 1 at the gate, so it ran)
assert.deepEqual(
stillRuns,
{ error: 'boom' },
'the success reset the streak, so this still ran',
);
assert.equal(h.impl.length, 4, 'all four ran — the success prevented an early block');
// Only now, after two fresh back-to-back failures, is the next identical call blocked.
const blocked = await call(h, t, args);
assert.deepEqual(
blocked,
{ error: formatLoopGateText('Bash') },
'blocked after two fresh failures',
);
assert.equal(h.impl.length, 4, 'the blocked call did not run');
});
test('a different tool or args between failures resets the streak', async () => {
const h = makeHarness();
const bash = makeFailingTool('Bash', h.impl);
const edit = makeFailingTool('Edit', h.impl);
const cmd = { command: 'npm test' };
// Three identical Bash failures, but never back-to-back — iterate-then-retry
// (fail a test, edit, re-run the same failing test) must not be gated.
await call(h, bash, cmd);
await call(h, edit, { path: 'a' });
await call(h, bash, cmd);
await call(h, edit, { path: 'a' });
const last = await call(h, bash, cmd);
assert.deepEqual(last, { error: 'boom' }, 'the re-run after a different call is not blocked');
assert.equal(h.impl.length, 5, 'all five calls ran');
});
test('treats args as identical regardless of key order', async () => {
const h = makeHarness();
const t = makeFailingTool('Write', h.impl);
await call(h, t, { path: 'a', content: 'x' }); // fail → streak 1
await call(h, t, { content: 'x', path: 'a' }); // same canonical args → fail → streak 2
const third = await call(h, t, { path: 'a', content: 'x' }); // blocked
assert.deepEqual(third, { error: formatLoopGateText('Write') });
assert.equal(h.impl.length, 2);
});
test('the block is recoverable — a different call afterwards still runs', async () => {
const h = makeHarness();
const grep = makeFailingTool('Grep', h.impl);
const read = makeTool('Read', h.impl);
const args = { pattern: 'foo' };
for (let i = 0; i < LOOP_GATE_IDENTICAL_THRESHOLD; i++) await call(h, grep, args);
assert.ok(
h.pushed.some((e) => e.type === 'tool_result' && e.isError && e.toolUseId.startsWith('tc-')),
'a synthetic error result is emitted for the blocked call',
);
await call(h, read, { path: 'x' });
assert.ok(h.impl.includes('Read:{"path":"x"}'), 'a different call after a block runs normally');
});
test('a repeatedly not-loaded tool trips the gate — guard rejections count as failures', async () => {
const h = makeHarness();
const gated = makeTool('browser_click', h.impl);
// browser_click is gated and never active this turn, so the availability guard
// rejects every call before it runs. The first N-1 rejections give the
// actionable load hint; the Nth identical rejection is loop-gated.
h.runtime.setGating({ gatedNames: new Set(['browser_click']), activeNames: () => new Set() });
const args = { sel: '#x' };
const r1 = await call(h, gated, args);
const r2 = await call(h, gated, args);
const r3 = await call(h, gated, args);
assert.deepEqual(
r1,
{ error: formatDeferredNotLoadedText('browser_click') },
'first: load hint',
);
assert.deepEqual(
r2,
{ error: formatDeferredNotLoadedText('browser_click') },
'second: load hint',
);
assert.deepEqual(r3, { error: formatLoopGateText('browser_click') }, 'third: loop-gated');
assert.equal(h.impl.length, 0, 'the gated tool never actually ran');
});
test('the failure streak is per-turn: a turn reset clears it so a new turn is not falsely blocked', async () => {
const h = makeHarness();
const t = makeFailingTool('Edit', h.impl);
const args = { path: 'a.ts', old_string: 'x', new_string: 'y' };
// Two identical failures in the first turn — the streak builds to N-1 but the
// gate has not fired yet, so both run.
await call(h, t, args, 'turn-1');
await call(h, t, args, 'turn-1');
assert.equal(h.impl.length, 2, 'both first-turn failures ran');
// ToolRuntime state is per-instance, not auto-keyed on turnId: a third
// identical failing call carrying a NEW turn id but with no reset is still the
// 3rd back-to-back failure and is blocked. This is exactly why send() must
// reset per turn rather than relying on the turn id alone.
const withoutReset = await call(h, t, args, 'turn-2');
assert.deepEqual(
withoutReset,
{ error: formatLoopGateText('Edit') },
'without a reset the streak leaks across turns',
);
// send() resets ToolRuntime at each turn boundary (at turn start, and via
// cleanupAfterTurn at turn end). After the reset, the same call is the first
// of a fresh turn and runs (failing on its own merits) — not mistaken for a
// 3rd repeat.
h.runtime.resetTurnState();
const afterReset = await call(h, t, args, 'turn-3');
assert.deepEqual(
afterReset,
{ error: 'boom' },
'after the per-turn reset the identical call runs again',
);
assert.equal(h.impl.length, 3, 'the post-reset call ran');
});
// Headless Bash returns a terminal result instead of throwing, so its failure
// is only counted if deriveToolResultStatus() classifies the terminal exitCode.
test('a returned terminal result with a non-zero exit counts as a failure', async () => {
const h = makeHarness();
const t = makeTerminalTool('Bash', h.impl, 1);
const args = { command: 'npm test' };
const results: unknown[] = [];
for (let i = 0; i < LOOP_GATE_IDENTICAL_THRESHOLD; i++) results.push(await call(h, t, args));
assert.equal(
h.impl.length,
LOOP_GATE_IDENTICAL_THRESHOLD - 1,
'impl ran only before the gate fired',
);
for (let i = 0; i < LOOP_GATE_IDENTICAL_THRESHOLD - 1; i++) {
assert.equal(
(results[i] as { kind?: string }).kind,
'terminal',
'failing runs still return their terminal output',
);
}
assert.deepEqual(
results[LOOP_GATE_IDENTICAL_THRESHOLD - 1],
{ error: formatLoopGateText('Bash') },
'the Nth identical failure is gated',
);
});
test('a returned terminal result with exit 0 is a success — polling is not gated', async () => {
const h = makeHarness();
const t = makeTerminalTool('Bash', h.impl, 0);
const args = { command: 'git status --porcelain' };
const runs = LOOP_GATE_IDENTICAL_THRESHOLD + 2;
for (let i = 0; i < runs; i++) await call(h, t, args);
assert.equal(h.impl.length, runs, 'every exit-0 poll ran — none was gated');
});
test('returned shell_run terminal states are observations, not tool failures', async () => {
for (const status of observedShellRunStatuses) {
const h = makeHarness();
const t = makeShellRunTool('StopBackgroundTask', h.impl, status);
const args = { ref: 'maka://runtime/background-tasks/shell-run-1' };
const result = await call(h, t, args);
assert.equal((result as { kind?: string }).kind, 'shell_run', status);
const event = h.pushed.find((candidate) => candidate.type === 'tool_result');
assert.equal(event?.type === 'tool_result' ? event.isError : true, false, status);
assert.equal(h.invocations[0]?.status, 'success', status);
}
});
test('a returned top-level error is classified as a failed tool result', async () => {
const h = makeHarness();
const tool = makeComputerFailureTool(h.impl);
const result = await call(h, tool, {
action: 'click_element',
observation_id: 'obs-1',
element_id: 'duplicate',
});
assert.deepEqual(result, {
text: 'maka_computer failed: stale_frame',
error: 'stale_frame',
});
const event = h.pushed.find((candidate) => candidate.type === 'tool_result');
assert.equal(event?.type === 'tool_result' ? event.isError : false, true);
assert.equal(h.invocations[0]?.status, 'error');
});
test('an ambiguous semantic target is gated across fresh observation ids', async () => {
const h = makeHarness();
const tool = makeComputerFailureTool(h.impl, 'ambiguous_target');
const first = await call(h, tool, {
action: 'click_element',
observation_id: 'obs-1',
element_id: 'duplicate',
});
assert.equal((first as { error?: string }).error, 'stale_frame');
await call(h, tool, {
action: 'observe',
app: 'Fixture',
});
const second = await call(h, tool, {
action: 'click_element',
observation_id: 'obs-2',
element_id: 'renumbered-duplicate',
});
assert.deepEqual(second, { error: formatAmbiguousComputerLoopGateText() });
assert.equal(
h.impl.length,
2,
'observe runs but the repeated mutation never reaches the backend',
);
});
test('repeated shell_run observations are not loop-gated by process status', async () => {
const h = makeHarness();
const t = makeShellRunTool('StopBackgroundTask', h.impl, 'timed_out');
const args = { ref: 'maka://runtime/background-tasks/shell-run-1' };
const runs = LOOP_GATE_IDENTICAL_THRESHOLD + 2;
for (let i = 0; i < runs; i++) await call(h, t, args);
assert.equal(h.impl.length, runs, 'every background-task observation ran');
assert.equal(
h.pushed.every((event) => event.type !== 'tool_result' || event.isError === false),
true,
'ShellRun observations do not surface as tool errors',
);
});
});