import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { LLMEvent, ToolDef } from '../llm/openai-compat.js';
import type { ToolContext } from './tools/index.js';
import { stripThinkingTokens } from './agent-loop.js';

describe('stripThinkingTokens', () => {
  it('strips <think>...</think> blocks', () => {
    expect(stripThinkingTokens('<think>reasoning here</think>actual output')).toBe('actual output');
  });
  it('strips <|thinking|>...</|thinking|> blocks', () => {
    expect(stripThinkingTokens('<|thinking|>deep thought<|/thinking|>result')).toBe('result');
  });
  it('strips gemma4 thought<channel|> pattern', () => {
    expect(stripThinkingTokens('thought\n<channel|>')).toBe('');
    expect(stripThinkingTokens('thought <channel|>hello')).toBe('hello');
  });
  it('strips <channel|>...<channel|> blocks', () => {
    expect(stripThinkingTokens('<channel|>internal reasoning<channel|>visible output')).toBe('visible output');
  });
  it('returns normal text unchanged', () => {
    expect(stripThinkingTokens('hello world')).toBe('hello world');
  });
  it('handles multiline thinking blocks', () => {
    expect(stripThinkingTokens('<think>\nline1\nline2\n</think>output')).toBe('output');
  });
});

const { executeToolMock, getToolDefsMock } = vi.hoisted(() => ({
  executeToolMock: vi.fn(),
  getToolDefsMock: vi.fn(),
}));

vi.mock('./tools/index.js', () => ({
  executeTool: executeToolMock,
  getToolDefs: getToolDefsMock,
}));

import { executeMovement, type Movement } from './agent-loop.js';
import { ToolResultCache } from './context/tool-result-cache.js';

function makeMovement(allowedTools: string[]): Movement {
  return {
    name: 'execute',
    edit: true,
    persona: 'worker',
    instruction: 'Do the work.',
    allowedTools,
    rules: [{ condition: 'done', next: 'COMPLETE' }],
    defaultNext: 'COMPLETE',
  };
}

function makeToolDefs(names: string[]): ToolDef[] {
  return names.map((name) => ({
    type: 'function',
    function: {
      name,
      description: name,
      parameters: { type: 'object', properties: {}, required: [] },
    },
  }));
}

function makeContext(): ToolContext {
  return {
    workspacePath: '/tmp/agent-loop-test',
    editAllowed: true,
  };
}

function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
  let resolve!: (value: T) => void;
  const promise = new Promise<T>((res) => {
    resolve = res;
  });
  return { promise, resolve };
}

async function waitFor(predicate: () => boolean, timeoutMs: number = 250): Promise<void> {
  const start = Date.now();
  while (!predicate()) {
    if (Date.now() - start > timeoutMs) {
      throw new Error('Timed out waiting for condition');
    }
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
}

class FakeClient {
  private index = 0;
  readonly calls: Array<{ messages: unknown; tools?: unknown }> = [];

  constructor(private readonly responses: LLMEvent[][]) {}

  async *chat(messages: unknown, tools?: unknown, _signal?: AbortSignal): AsyncGenerator<LLMEvent> {
    this.calls.push({ messages, tools });
    const response = this.responses[this.index++] ?? [];
    for (const event of response) {
      yield event;
    }
  }
}

describe('executeMovement parallel tool execution', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('runs consecutive safe tool calls in parallel', async () => {
    const readGate = deferred<{ output: string; isError: boolean }>();
    const globGate = deferred<{ output: string; isError: boolean }>();
    const started: string[] = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Glob']));
    executeToolMock.mockImplementation(async (name: string) => {
      started.push(name);
      if (name === 'Read') return readGate.promise;
      if (name === 'Glob') return globGate.promise;
      return { output: 'ok', isError: false };
    });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'input/a.txt' } },
        { type: 'tool_use', id: 'glob-1', name: 'Glob', input: { pattern: 'input/*' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    const runPromise = executeMovement(makeMovement(['Read', 'Glob']), 'task', client as never, makeContext());

    await waitFor(() => started.length === 2);
    expect(started).toEqual(['Read', 'Glob']);

    readGate.resolve({ output: 'read ok', isError: false });
    globGate.resolve({ output: 'glob ok', isError: false });

    const result = await runPromise;
    expect(result.next).toBe('COMPLETE');
    expect(executeToolMock).toHaveBeenCalledTimes(2);
  });

  it('keeps side-effecting tools sequential as a barrier', async () => {
    const readGate = deferred<{ output: string; isError: boolean }>();
    const writeGate = deferred<{ output: string; isError: boolean }>();
    const started: string[] = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Write']));
    executeToolMock.mockImplementation(async (name: string) => {
      started.push(name);
      if (name === 'Read') return readGate.promise;
      if (name === 'Write') return writeGate.promise;
      return { output: 'ok', isError: false };
    });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'input/a.txt' } },
        { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'output/a.txt', content: 'x' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    const runPromise = executeMovement(makeMovement(['Read', 'Write']), 'task', client as never, makeContext());

    await waitFor(() => started.length === 1);
    expect(started).toEqual(['Read']);

    readGate.resolve({ output: 'read ok', isError: false });
    await waitFor(() => started.length === 2);
    expect(started).toEqual(['Read', 'Write']);

    writeGate.resolve({ output: 'write ok', isError: false });

    const result = await runPromise;
    expect(result.next).toBe('COMPLETE');
    expect(executeToolMock).toHaveBeenCalledTimes(2);
  });

  it('executes regular tools before transition even if transition appears mid-batch', async () => {
    const readGate = deferred<{ output: string; isError: boolean }>();
    const globGate = deferred<{ output: string; isError: boolean }>();
    const started: string[] = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Glob']));
    executeToolMock.mockImplementation(async (name: string) => {
      started.push(name);
      if (name === 'Read') return readGate.promise;
      if (name === 'Glob') return globGate.promise;
      return { output: 'glob ok', isError: false };
    });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'input/a.txt' } },
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'tool_use', id: 'glob-1', name: 'Glob', input: { pattern: 'input/*' } },
        { type: 'done' },
      ],
    ]);

    const runPromise = executeMovement(makeMovement(['Read', 'Glob']), 'task', client as never, makeContext());

    await waitFor(() => started.length === 2);
    expect(started).toEqual(['Read', 'Glob']);

    readGate.resolve({ output: 'read ok', isError: false });
    globGate.resolve({ output: 'glob ok', isError: false });

    const result = await runPromise;
    expect(result.next).toBe('COMPLETE');
    expect(executeToolMock).toHaveBeenCalledTimes(2);
  });

  it('emits text, preview, and tool callbacks in order', async () => {
    const textChunks: string[] = [];
    const previews: Array<{ movement: string; preview: string }> = [];
    const toolUses: Array<{ name: string; input: Record<string, unknown> }> = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'read ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'text', text: 'first ' },
        { type: 'text', text: 'second' },
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'input/a.txt' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      {
        callbacks: {
          onText: (text) => textChunks.push(text),
          onTextPreview: (movementName, preview) => previews.push({ movement: movementName, preview }),
          onToolUse: (toolName, input) => toolUses.push({ name: toolName, input }),
        },
      },
    );

    expect(result.next).toBe('COMPLETE');
    expect(textChunks).toEqual(['first ', 'second']);
    expect(previews).toEqual([{ movement: 'execute', preview: 'first second' }]);
    expect(toolUses).toContainEqual({ name: 'Read', input: { file_path: 'input/a.txt' } });
  });

  it('fires onToolResult callback after tool execution', async () => {
    const toolResults: Array<{ name: string; isError: boolean }> = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'read ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      {
        callbacks: {
          onToolResult: (name, info) => toolResults.push({ name, isError: info.isError }),
        },
      },
    );

    expect(toolResults).toContainEqual({ name: 'Read', isError: false });
  });

  it('fires onMemoryCheckpoint callback after regular tool use', async () => {
    const checkpoints: number[] = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'read ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      {
        callbacks: {
          onMemoryCheckpoint: (count) => checkpoints.push(count),
        },
      },
    );

    expect(checkpoints).toEqual([1]);
  });

  it('triggers force_transition when context manager signals exhaustion', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({
      thresholds: [{ ratio: 0.005, action: 'force_transition' }],
      limitTokens: 10_000,
    });

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done', usage: { prompt_tokens: 80, completion_tokens: 10 } },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    // Codex follow-up #2: makeMovement.defaultNext is the terminal COMPLETE, but a
    // context-overflow force_transition must NOT be reported as a successful
    // completion — that would skip worker retry. It aborts with context_overflow.
    expect(result.next).toBe('ABORT');
    expect(result.abortCode).toBe('context_overflow');
    expect(result.output).toContain('Context limit reached');
  });

  it('fires onContextAction callback when context threshold crossed', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({
      thresholds: [{ ratio: 0.005, action: 'warn' }],
      limitTokens: 10_000,
    });

    const contextActions: Array<{ type: string }> = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done', usage: { prompt_tokens: 60, completion_tokens: 10 } },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      {
        callbacks: {
          onContextAction: (action) => contextActions.push({ type: action.type }),
        },
        contextManager: cm,
      },
    );

    expect(contextActions).toContainEqual({ type: 'warn' });
  });

  it('fires onContextUpdate callback after each LLM usage update', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({ limitTokens: 100_000 });

    const updates: Array<{ promptTokens: number; limitTokens: number }> = [];

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done', usage: { prompt_tokens: 1234, completion_tokens: 10 } },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      {
        callbacks: {
          onContextUpdate: (payload) => updates.push(payload),
        },
        contextManager: cm,
      },
    );

    expect(updates.length).toBeGreaterThanOrEqual(1);
    expect(updates).toContainEqual({ promptTokens: 0, limitTokens: 100_000 });
    expect(updates).toContainEqual({ promptTokens: 1234, limitTokens: 100_000 });
  });

  it('compacts oversized tool results before the next LLM request', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({ limitTokens: 20_000 });
    const hugeBase64 = `<html><img src="data:text/html;base64,${'A'.repeat(80_000)}"></html>`;

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: hugeBase64, isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'input/page.html' } },
        { type: 'done', usage: { prompt_tokens: 1000, completion_tokens: 10 } },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done', usage: { prompt_tokens: 2000, completion_tokens: 10 } },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    expect(result.next).toBe('COMPLETE');
    expect(client.calls).toHaveLength(2);
    const secondMessages = client.calls[1]!.messages as Array<{ role: string; content?: string }>;
    const toolMessage = secondMessages.find((message) => message.role === 'tool');
    expect(toolMessage?.content).toContain('[Tool result omitted before LLM request]');
    expect(toolMessage?.content).not.toContain(hugeBase64);
    // The agent should also receive a user-facing instruction to retry with a
    // narrower scope, instead of silently dropping content.
    const feedbackMessage = secondMessages.find(
      (message) => message.role === 'user' && message.content?.includes('LLM コンテキストに入れられませんでした'),
    );
    expect(feedbackMessage).toBeDefined();
    expect(feedbackMessage?.content).toMatch(/Read\(offset\/limit\)|Grep/);
  });

  it('dedupes duplicate file reads before compacting tool results', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({ limitTokens: 40_000 });
    // 80% guard => 32_000 tokens. Each large ASCII read is roughly 20k tokens.
    // Two duplicate reads exceed the guard without dedup; after dedup, only
    // the latest read survives and the request fits.

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    const largeContent = 'X'.repeat(72_000);
    executeToolMock.mockResolvedValue({ output: largeContent, isError: false });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: '/dup.ts' } },
        { type: 'done', usage: { prompt_tokens: 500, completion_tokens: 10 } },
      ],
      [
        { type: 'tool_use', id: 'r2', name: 'Read', input: { file_path: '/dup.ts' } },
        { type: 'done', usage: { prompt_tokens: 18_000, completion_tokens: 10 } },
      ],
      [
        { type: 'tool_use', id: 't', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    expect(result.next).toBe('COMPLETE');
    expect(client.calls.length).toBe(3);
    // The 3rd LLM call should see the older Read replaced with a placeholder
    const thirdCallMessages = client.calls[2]!.messages as Array<{ role: string; content?: string }>;
    const placeholderMessages = thirdCallMessages.filter(
      (m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes('Duplicate Read of /dup.ts'),
    );
    expect(placeholderMessages.length).toBe(1);
    // The latest Read should still carry the full content
    const fullReadMessages = thirdCallMessages.filter(
      (m) => m.role === 'tool' && typeof m.content === 'string' && m.content === largeContent,
    );
    expect(fullReadMessages.length).toBe(1);
  });

  it('summarizes old turns when dedup+compact alone cannot fit prompt', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const { SUMMARY_MARKER_PREFIX } = await import('./context/history-compactor.js');
    // Tight context, distinct Bash outputs (so dedup cannot help) each below
    // LARGE_TOOL_RESULT_TOKENS (so compaction cannot help either) — only
    // history summarization can keep the conversation going.
    // The test limit is sized so summarization fires on the 5th Bash turn
    // rather than the 4th, matching the FakeClient slot layout below.
    const cm = new ContextManager({ limitTokens: 35_000 });
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Bash']));
    executeToolMock.mockResolvedValue({ output: 'B'.repeat(18_000), isError: false });

    // FakeClient.chat() walks a single response list, so the isolated
    // summarization LLM call (runIsolatedLlm) consumes the next slot. Slot 5
    // is the summary response; slot 6 is the post-summary transition.
    const client = new FakeClient([
      [{ type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'echo 1' } }, { type: 'done', usage: { prompt_tokens: 1_000, completion_tokens: 5 } }],
      [{ type: 'tool_use', id: 'b2', name: 'Bash', input: { command: 'echo 2' } }, { type: 'done', usage: { prompt_tokens: 5_500, completion_tokens: 5 } }],
      [{ type: 'tool_use', id: 'b3', name: 'Bash', input: { command: 'echo 3' } }, { type: 'done', usage: { prompt_tokens: 10_000, completion_tokens: 5 } }],
      [{ type: 'tool_use', id: 'b4', name: 'Bash', input: { command: 'echo 4' } }, { type: 'done', usage: { prompt_tokens: 15_000, completion_tokens: 5 } }],
      [{ type: 'tool_use', id: 'b5', name: 'Bash', input: { command: 'echo 5' } }, { type: 'done', usage: { prompt_tokens: 20_000, completion_tokens: 5 } }],
      [{ type: 'text', text: '## ゴール\nrun bash commands\n## 進捗\nDone: 5 commands' }, { type: 'done' }],
      [
        { type: 'tool_use', id: 't', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Bash']),
      'task',
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    expect(result.next).toBe('COMPLETE');
    const finalMessages = client.calls[client.calls.length - 1]!.messages as Array<{ role: string; content?: string }>;
    const markerMessages = finalMessages.filter(
      (m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith(SUMMARY_MARKER_PREFIX),
    );
    // The invariant we care about is "summarization fired at least once
    // before completion".
    expect(markerMessages.length).toBeGreaterThanOrEqual(1);
  });

  it('aborts when initial prompt is oversized and defaultNext is terminal', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({ limitTokens: 1_000 });

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    // The guard will fail before the main LLM is called, but the
    // last-resort summary path uses runIsolatedLlm (also via FakeClient),
    // so we provide one response for that summary call.
    const client = new FakeClient([
      [{ type: 'text', text: '### Status\nstuck on overflow' }, { type: 'done' }],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'x'.repeat(10_000),
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    // A terminal fallback would report a false completion, so overflow aborts.
    expect(result.next).toBe('ABORT');
    expect(result.abortCode).toBe('context_overflow');
    expect(result.output).toContain('LLM request blocked before send');
    // Only the isolated summary call should have happened — no main LLM call
    expect(executeToolMock).not.toHaveBeenCalled();
  });

  it('falls back to ABORT when oversized prompt has no defaultNext to force-transition to', async () => {
    const { ContextManager } = await import('./context-manager.js');
    const cm = new ContextManager({ limitTokens: 1_000 });

    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [{ type: 'text', text: 'should not be called' }, { type: 'done' }],
    ]);

    // Movement without defaultNext
    const movement: Movement = {
      name: 'execute',
      edit: true,
      persona: 'worker',
      instruction: 'Do the work.',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
    };

    const result = await executeMovement(
      movement,
      'x'.repeat(10_000),
      client as never,
      makeContext(),
      { contextManager: cm },
    );

    expect(result.next).toBe('ABORT');
    expect(result.output).toContain('LLM request blocked before send');
    expect(executeToolMock).not.toHaveBeenCalled();
  });

  it('returns ABORT immediately when cancelSignal is already aborted', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done' },
      ],
    ]);

    const controller = new AbortController();
    controller.abort();

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { cancelSignal: controller.signal },
    );

    expect(result.next).toBe('ABORT');
    expect(result.output).toContain('cancelled');
    expect(executeToolMock).not.toHaveBeenCalled();
  });

  it('does not let a co-emitted complete mask a cancel that fired during tool dispatch', async () => {
    // ②A makes web/MCP/browser tools swallow the abort and return a normal
    // ToolResult instead of throwing. Simulate that: the tool observes the
    // cancel and aborts the signal while it runs. The SAME LLM response also
    // contains a `complete(success)`. Without the post-dispatch re-check the loop
    // would honour the complete and the worker would persist 'succeeded' — the
    // cancel must win instead.
    const controller = new AbortController();
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') {
        controller.abort(); // user cancel observed mid-tool
        return { output: 'Movement cancelled by caller', isError: true };
      }
      return { output: 'ok', isError: false };
    });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'tool_use', id: 'done-1', name: 'complete', input: { status: 'success', result: 'all done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { cancelSignal: controller.signal },
    );

    expect(executeToolMock).toHaveBeenCalledTimes(1); // Read ran (and aborted)
    expect(result.next).toBe('ABORT');
    expect(result.output).toContain('cancelled');
    expect(result.abortCode).toBe('cancelled');
  });

  it('classifies a deadline abort during dispatch as a timeout, not a success', async () => {
    const controller = new AbortController();
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') {
        controller.abort('deadline_exceeded'); // hard deadline fired mid-tool
        return { output: 'aborted', isError: true };
      }
      return { output: 'ok', isError: false };
    });

    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'tool_use', id: 'done-1', name: 'complete', input: { status: 'success', result: 'all done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { cancelSignal: controller.signal },
    );

    expect(result.next).toBe('ABORT');
    expect(result.abortCode).toBe('deadline_exceeded');
    expect(result.output).toContain('実行時間の上限');
  });

  it('aborts after text-only responses in a NON-terminal movement', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    // Non-terminal movement (defaultNext is another movement, not COMPLETE): a
    // text-only run here is mid-work prose, not a deliverable, so after
    // MAX_TEXT_ONLY_RETRIES (3) reminders it must ABORT — not be salvaged.
    const nonTerminalMovement: Movement = {
      name: 'execute',
      edit: true,
      persona: 'worker',
      instruction: 'Do the work.',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'verify' }],
      defaultNext: 'verify',
    };

    const client = new FakeClient([
      [{ type: 'text', text: 'I will do the work' }, { type: 'done' }],
      [{ type: 'text', text: 'Still working on it' }, { type: 'done' }],
      [{ type: 'text', text: 'Almost done' }, { type: 'done' }],
      // Should not reach this
      [{ type: 'text', text: 'Never reached' }, { type: 'done' }],
    ]);

    const result = await executeMovement(
      nonTerminalMovement,
      'task',
      client as never,
      makeContext(),
    );

    expect(result.next).toBe('ABORT');
    expect(result.abortCode).toBe('text_only_limit');
    expect(executeToolMock).not.toHaveBeenCalled();
  });

  it('salvages text-only prose as a completion in a STRICTLY-terminal movement (defaultNext COMPLETE, no rules)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    // Strictly terminal: defaultNext COMPLETE *and* no rules (like chat.respond).
    // When the model answers in prose but never calls `complete`, the final prose
    // IS the deliverable — salvage it as a successful completion rather than
    // aborting into text_only_limit (which the worker would bounce into a retry).
    const terminalMovement: Movement = {
      name: 'respond',
      edit: false,
      persona: 'worker',
      instruction: 'Answer.',
      allowedTools: ['Read'],
      rules: [],
      defaultNext: 'COMPLETE',
    };

    const client = new FakeClient([
      [{ type: 'text', text: 'Here is my answer, part 1' }, { type: 'done' }],
      [{ type: 'text', text: 'Here is my answer, part 2' }, { type: 'done' }],
      [{ type: 'text', text: 'Final answer: 42' }, { type: 'done' }],
      // Should not be reached — salvage happens on the 3rd text-only response.
      [{ type: 'text', text: 'Never reached' }, { type: 'done' }],
    ]);

    const result = await executeMovement(terminalMovement, 'task', client as never, makeContext());

    expect(result.next).toBe('COMPLETE');
    expect(result.abortCode).toBeUndefined();
    expect(result.output).toBe('Final answer: 42');
    expect(executeToolMock).not.toHaveBeenCalled();
  });

  it('does NOT salvage in a COMPLETE-default movement that still has rules (e.g. verify)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    // A `verify`-style movement: default_next is COMPLETE but rules can send work
    // back to `execute`. Text-only prose here ("not good enough, redo X") must NOT
    // be salvaged as success — that would falsely complete the whole task. It
    // aborts into text_only_limit instead.
    const verifyMovement: Movement = {
      name: 'verify',
      edit: false,
      persona: 'reviewer',
      instruction: 'Review.',
      allowedTools: ['Read'],
      rules: [{ condition: 'needs work', next: 'execute' }],
      defaultNext: 'COMPLETE',
    };

    const client = new FakeClient([
      [{ type: 'text', text: 'Not good enough, the title is wrong' }, { type: 'done' }],
      [{ type: 'text', text: 'Still not addressed' }, { type: 'done' }],
      [{ type: 'text', text: 'Go back and fix it' }, { type: 'done' }],
      [{ type: 'text', text: 'Never reached' }, { type: 'done' }],
    ]);

    const result = await executeMovement(verifyMovement, 'task', client as never, makeContext());

    expect(result.next).toBe('ABORT');
    expect(result.abortCode).toBe('text_only_limit');
  });

  it('resets text-only counter when tool calls happen in between', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    // Alternate: text-only → tool → text-only → tool → text-only → tool → complete
    // Without the fix, the 3 text-only responses would trigger abort.
    // With the fix, each tool call resets the counter so it never reaches 3 consecutive.
    const client = new FakeClient([
      [{ type: 'text', text: 'Thinking about the answer...' }, { type: 'done' }],
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.txt' } },
        { type: 'done' },
      ],
      [{ type: 'text', text: 'Found some info, analyzing...' }, { type: 'done' }],
      [
        { type: 'tool_use', id: 'read-2', name: 'Read', input: { file_path: 'b.txt' } },
        { type: 'done' },
      ],
      [{ type: 'text', text: 'Almost done with analysis' }, { type: 'done' }],
      [
        { type: 'tool_use', id: 'complete-1', name: 'complete', input: { status: 'success', result: 'Done' } },
        { type: 'done' },
      ],
    ]);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
    );

    expect(result.next).not.toBe('ABORT');
    expect(executeToolMock).toHaveBeenCalled();
  });

  it('aborts when maxIterations is exceeded', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    // LLM keeps using tools but never calls transition
    const toolResponses = Array.from({ length: 4 }, () => [
      { type: 'tool_use' as const, id: `read-${Math.random()}`, name: 'Read', input: { file_path: 'a.txt' } },
      { type: 'done' as const },
    ]);

    const client = new FakeClient(toolResponses);

    const result = await executeMovement(
      makeMovement(['Read']),
      'task',
      client as never,
      makeContext(),
      { maxIterations: 2 },
    );

    expect(result.next).toBe('ABORT');
    expect(result.output).toContain('iteration');
  });

});

describe('executeMovement cross-movement Read cache', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('returns a cached Read result when a later movement reads the same file', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));

    const fileContent = 'const answer = 42;\n';
    executeToolMock.mockImplementation(async (name: string, input: Record<string, unknown>) => {
      if (name === 'Read' && input['file_path'] === 'foo.ts') {
        return { output: fileContent, isError: false };
      }
      return { output: 'unexpected', isError: true };
    });

    const cache = new ToolResultCache();

    const movementA: Movement = {
      name: 'investigate',
      edit: false,
      persona: 'investigator',
      instruction: 'Look at foo.ts.',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'plan' }],
      defaultNext: 'plan',
    };
    const clientA = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'foo.ts' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'transition', input: { next_step: 'plan', summary: 'foo.ts read' } },
        { type: 'done' },
      ],
    ]);
    const resultA = await executeMovement(movementA, 'task', clientA as never, makeContext(), {
      toolResultCache: cache,
    });
    expect(resultA.next).toBe('plan');
    expect(executeToolMock).toHaveBeenCalledTimes(1);
    expect(cache.size()).toBe(1);

    const observedToolResults: string[] = [];
    const movementB: Movement = {
      name: 'plan',
      edit: false,
      persona: 'planner',
      instruction: 'Plan based on foo.ts.',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [
        { type: 'tool_use', id: 'read-2', name: 'Read', input: { file_path: 'foo.ts' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-2', name: 'complete', input: { status: 'success', result: 'used cached read' } },
        { type: 'done' },
      ],
    ]);
    const resultB = await executeMovement(movementB, 'task', clientB as never, makeContext(), {
      toolResultCache: cache,
      callbacks: {
        onToolResult: (_name, info) => observedToolResults.push(info.result),
      },
    });

    expect(resultB.next).toBe('COMPLETE');
    // executeTool was NOT invoked again — the cache short-circuited the second Read.
    expect(executeToolMock).toHaveBeenCalledTimes(1);
    // The LLM saw a cached-tagged result, not the raw content.
    expect(observedToolResults.some((r) => r.startsWith('[cached: Read foo.ts from movement investigate at '))).toBe(true);
    expect(observedToolResults.some((r) => r.endsWith(fileContent))).toBe(true);
  });

  it('does not cache error results', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ENOENT: no such file', isError: true });

    const cache = new ToolResultCache();
    const movement: Movement = {
      name: 'investigate',
      edit: false,
      persona: 'investigator',
      instruction: 'Look at missing.ts.',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'missing.ts' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'tried' } },
        { type: 'done' },
      ],
    ]);
    await executeMovement(movement, 'task', client as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(0);
  });

  it('skips caching tools outside the cacheable allowlist (Bash etc.)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Bash']));
    executeToolMock.mockResolvedValue({ output: 'shell ok', isError: false });

    const cache = new ToolResultCache();
    const movement: Movement = {
      name: 'investigate',
      edit: false,
      persona: 'investigator',
      instruction: 'Run.',
      allowedTools: ['Bash'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'b-1', name: 'Bash', input: { command: 'echo hi' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'transition-1', name: 'complete', input: { status: 'success', result: 'ran' } },
        { type: 'done' },
      ],
    ]);
    await executeMovement(movement, 'task', client as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(0);
  });
});

describe('executeMovement Phase 2 cache invalidation', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('invalidates cached Read after a successful Edit on the same path', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Edit']));

    const reads: number[] = [];
    let version = 1;
    executeToolMock.mockImplementation(async (name: string, input: Record<string, unknown>) => {
      if (name === 'Read' && input['file_path'] === 'foo.ts') {
        reads.push(version);
        return { output: `version=${version}`, isError: false };
      }
      if (name === 'Edit' && input['file_path'] === 'foo.ts') {
        version++;
        return { output: 'edited', isError: false };
      }
      return { output: 'unexpected', isError: true };
    });

    const cache = new ToolResultCache();

    const movementA: Movement = {
      name: 'investigate',
      edit: true,
      persona: 'p',
      instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 'read' } }, { type: 'done' }],
    ]);
    await executeMovement(movementA, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(1);

    const movementB: Movement = {
      name: 'execute',
      edit: true,
      persona: 'p',
      instruction: 'i',
      allowedTools: ['Edit'],
      rules: [{ condition: 'done', next: 'verify' }],
      defaultNext: 'verify',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'transition', input: { next_step: 'verify', summary: 'edited' } }, { type: 'done' }],
    ]);
    await executeMovement(movementB, 'task', clientB as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(0); // Edit evicted the Read entry

    const observed: string[] = [];
    const movementC: Movement = {
      name: 'verify',
      edit: false,
      persona: 'p',
      instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientC = new FakeClient([
      [{ type: 'tool_use', id: 'r2', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't3', name: 'complete', input: { status: 'success', result: 're-read' } }, { type: 'done' }],
    ]);
    await executeMovement(movementC, 'task', clientC as never, makeContext(), {
      toolResultCache: cache,
      callbacks: { onToolResult: (_n, info) => observed.push(info.result) },
    });

    // Re-read happened (no cache hit) and saw the post-edit version.
    expect(reads).toEqual([1, 2]);
    expect(observed.some((r) => r === 'version=2')).toBe(true);
    expect(observed.some((r) => r.startsWith('[cached:'))).toBe(false);
  });

  it('invalidates only the affected path; other cached files survive', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Edit']));

    const callLog: Array<{ name: string; path?: string }> = [];
    executeToolMock.mockImplementation(async (name: string, input: Record<string, unknown>) => {
      callLog.push({ name, path: input['file_path'] as string | undefined });
      if (name === 'Read') return { output: `${input['file_path']} body`, isError: false };
      if (name === 'Edit') return { output: 'edited', isError: false };
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();
    const movA: Movement = {
      name: 'investigate', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'r2', name: 'Read', input: { file_path: 'bar.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(2);

    const movB: Movement = {
      name: 'execute', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Edit', 'Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const observed: string[] = [];
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [
        { type: 'tool_use', id: 'r3', name: 'Read', input: { file_path: 'foo.ts' } },
        { type: 'tool_use', id: 'r4', name: 'Read', input: { file_path: 'bar.ts' } },
        { type: 'done' },
      ],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), {
      toolResultCache: cache,
      callbacks: { onToolResult: (_n, info) => observed.push(info.result) },
    });

    const fooReadCount = callLog.filter((c) => c.name === 'Read' && c.path === 'foo.ts').length;
    const barReadCount = callLog.filter((c) => c.name === 'Read' && c.path === 'bar.ts').length;
    expect(fooReadCount).toBe(2); // re-read after Edit
    expect(barReadCount).toBe(1); // bar still cached
    expect(observed.some((r) => r.startsWith('[cached: Read bar.ts'))).toBe(true);
    expect(observed.some((r) => r.startsWith('[cached: Read foo.ts'))).toBe(false);
  });

  it('Bash invalidates every file-derived entry conservatively', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Bash']));

    let readCalls = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') {
        readCalls++;
        return { output: `read ${readCalls}`, isError: false };
      }
      if (name === 'Bash') return { output: 'shell ok', isError: false };
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();

    const movA: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'r2', name: 'Read', input: { file_path: 'bar.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(2);

    const movB: Movement = {
      name: 'execute', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Bash'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'echo hi' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(0); // every Read evicted
  });

  it('does NOT invalidate when Edit/Write/Bash returned an error', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Edit']));

    let editAttempts = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') return { output: 'body', isError: false };
      if (name === 'Edit') {
        editAttempts++;
        return { output: 'permission denied', isError: true };
      }
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();
    const movA: Movement = {
      name: 'investigate', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(cache.size()).toBe(1);

    const movB: Movement = {
      name: 'execute', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Edit'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), { toolResultCache: cache });

    expect(editAttempts).toBe(1);
    expect(cache.size()).toBe(1); // failed Edit kept the cache intact
  });
});

describe('executeMovement Phase 4 cache extension', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('caches Grep results and serves them to a later movement', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Grep']));
    let grepCalls = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Grep') {
        grepCalls++;
        return { output: 'matches in src/foo.ts:1\n', isError: false };
      }
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();

    const movA: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Grep'],
      rules: [{ condition: 'done', next: 'plan' }],
      defaultNext: 'plan',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'g1', name: 'Grep', input: { pattern: 'foo', path: 'src/' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'plan', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(grepCalls).toBe(1);

    const observed: string[] = [];
    const movB: Movement = {
      name: 'plan', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Grep'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'g2', name: 'Grep', input: { pattern: 'foo', path: 'src/' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), {
      toolResultCache: cache,
      callbacks: { onToolResult: (_n, info) => observed.push(info.result) },
    });

    expect(grepCalls).toBe(1);
    expect(observed.some((r) => r.startsWith('[cached: Grep foo'))).toBe(true);
  });

  it('Edit invalidates ALL search entries (Grep) regardless of scope', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Grep', 'Edit']));
    let grepCalls = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Grep') {
        grepCalls++;
        return { output: `match v${grepCalls}`, isError: false };
      }
      if (name === 'Edit') return { output: 'edited', isError: false };
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();

    const movA: Movement = {
      name: 'investigate', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Grep'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'g1', name: 'Grep', input: { pattern: 'x', path: 'src/' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });

    const movB: Movement = {
      name: 'execute', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Edit', 'Grep'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'unrelated.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'g2', name: 'Grep', input: { pattern: 'x', path: 'src/' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), { toolResultCache: cache });

    expect(grepCalls).toBe(2);
  });

  it('caches WebFetch and survives Edit invalidation', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['WebFetch', 'Edit']));
    let fetchCalls = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'WebFetch') {
        fetchCalls++;
        return { output: 'page body', isError: false };
      }
      if (name === 'Edit') return { output: 'edited', isError: false };
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();

    const movA: Movement = {
      name: 'investigate', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['WebFetch'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'w1', name: 'WebFetch', input: { url: 'https://example.com/x' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'execute', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, makeContext(), { toolResultCache: cache });

    const movB: Movement = {
      name: 'execute', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Edit', 'WebFetch'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'w2', name: 'WebFetch', input: { url: 'https://example.com/x' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, makeContext(), { toolResultCache: cache });

    expect(fetchCalls).toBe(1);
  });

  it('caches Read results for office extensions keyed by path + format args', async () => {
    // ReadPdf/ReadExcel/… were merged into Read: a Read of an office extension is
    // cached in the OFFICE namespace so distinct format args (page_range/sheet/…)
    // don't collide, while an identical Read is served from the cache.
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    let readCalls = 0;
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') {
        readCalls++;
        return { output: 'pdf body', isError: false };
      }
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();

    const mov: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'p1', name: 'Read', input: { file_path: 'doc.pdf', page_range: '1-3' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(mov, 'task', clientA as never, makeContext(), { toolResultCache: cache });
    expect(readCalls).toBe(1);

    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'p2', name: 'Read', input: { file_path: 'doc.pdf', page_range: '1-3' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'p3', name: 'Read', input: { file_path: 'doc.pdf', page_range: '4-6' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't2', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(mov, 'task', clientB as never, makeContext(), { toolResultCache: cache });
    expect(readCalls).toBe(2);
  });
});

describe('buildSystemPrompt checklist guidance', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('emits a "進捗管理" section with strengthened CreateChecklist guidance', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const movement: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const client = new FakeClient([
      [{ type: 'tool_use', id: 't1', name: 'complete', input: { status: 'success', result: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movement, 'task', client as never, makeContext());

    const systemMsg = client.calls[0]?.messages as Array<{ role: string; content: string }>;
    expect(systemMsg[0]!.role).toBe('system');
    expect(systemMsg[0]!.content).toContain('## 進捗管理');
    expect(systemMsg[0]!.content).toContain('CreateChecklist');
    expect(systemMsg[0]!.content).toContain('全 piece で常時利用可能');
    // Stronger language (option A): explicit "3 個以上のツール" criterion +
    // "判断に迷ったら作る" + 2 回目以降への明示注意。
    expect(systemMsg[0]!.content).toContain('3 個以上のツール呼び出し');
    expect(systemMsg[0]!.content).toContain('判断に迷ったら作る');
    expect(systemMsg[0]!.content).toContain('2 回目以降のやり取り');
  });
});

describe('checklist watchdog (option B)', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('injects a one-shot reminder after 5 iterations without any checklist tool', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    // 6 iterations of plain Read → 1 iteration with complete. The watchdog
    // should fire on iteration 5 (0-indexed).
    const responses: Array<Array<{ type: string; id?: string; name?: string; input?: unknown; text?: string }>> = [];
    for (let i = 0; i < 6; i++) {
      responses.push([
        { type: 'tool_use', id: `r-${i}`, name: 'Read', input: { file_path: `f${i}.ts` } },
        { type: 'done' },
      ]);
    }
    responses.push([
      { type: 'tool_use', id: 'c', name: 'complete', input: { status: 'success', result: 'done' } },
      { type: 'done' },
    ]);
    const client = new FakeClient(responses as never);

    await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());

    // Find any user-role message containing the watchdog tag.
    const allMessagesAtFinalCall = client.calls[client.calls.length - 1]!.messages as Array<{ role: string; content?: unknown }>;
    const watchdogMessages = allMessagesAtFinalCall.filter(
      (m) => m.role === 'user' && typeof m.content === 'string' && (m.content as string).includes('[checklist watchdog]'),
    );
    expect(watchdogMessages.length).toBe(1);
  });

  it('does NOT fire when CreateChecklist is called early', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'CreateChecklist']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    // CreateChecklist on iteration 0, then several Read, then complete.
    const responses: Array<Array<{ type: string; id?: string; name?: string; input?: unknown }>> = [
      [{ type: 'tool_use', id: 'cc', name: 'CreateChecklist', input: { name: 'plan', items: [] } }, { type: 'done' }],
    ];
    for (let i = 0; i < 7; i++) {
      responses.push([
        { type: 'tool_use', id: `r-${i}`, name: 'Read', input: { file_path: `f${i}.ts` } },
        { type: 'done' },
      ]);
    }
    responses.push([
      { type: 'tool_use', id: 'c', name: 'complete', input: { status: 'success', result: 'done' } },
      { type: 'done' },
    ]);
    const client = new FakeClient(responses as never);

    await executeMovement(makeMovement(['Read', 'CreateChecklist']), 'task', client as never, makeContext());

    const allMessagesAtFinalCall = client.calls[client.calls.length - 1]!.messages as Array<{ role: string; content?: unknown }>;
    const watchdogMessages = allMessagesAtFinalCall.filter(
      (m) => m.role === 'user' && typeof m.content === 'string' && (m.content as string).includes('[checklist watchdog]'),
    );
    expect(watchdogMessages.length).toBe(0);
  });
});

// ============================================================
// Phase 6a — `complete` tool (§7.1–7.7 of design plan)
// ============================================================

describe('Phase 6a: complete tool — §7.1 native basics', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('success status with result becomes the movement output (no chatter contamination)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'text', text: "I'll figure this out first" }, // chatter to be ignored
        { type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'final answer' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.next).toBe('COMPLETE');
    expect(result.output).toBe('final answer');
    expect(result.output).not.toContain("I'll figure this out first");
  });

  it('aborted status routes via ABORT next', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'aborted', abort_reason: 'tool unavailable' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.next).toBe('ABORT');
    expect(result.output).toBe('tool unavailable');
  });

  it('needs_user_input routes via ASK next', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        {
          type: 'tool_use', id: 'c1', name: 'complete',
          input: { status: 'needs_user_input', missing_info: 'which dataset?', why_no_default: 'multiple options' },
        },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.next).toBe('ASK');
    expect(result.output).toBe('which dataset?');
  });

  it('rejects success with empty result and forces retry (no accumulatedText fallback for native)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'text', text: 'this would have been the fallback' },
        { type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: '' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'c2', name: 'complete', input: { status: 'success', result: 'real answer' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.next).toBe('COMPLETE');
    expect(result.output).toBe('real answer');
    // The fallback chatter must NOT have leaked into the output via retry.
    expect(result.output).not.toContain('this would have been the fallback');
  });

  it('rejects aborted without abort_reason and forces retry', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'aborted' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'c2', name: 'complete', input: { status: 'aborted', abort_reason: 'real reason' } }, { type: 'done' }],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.next).toBe('ABORT');
    expect(result.output).toBe('real reason');
  });
});

describe('Phase 6a: complete tool — §7.2 precedence rules (§2.5)', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('invalid native complete forces retry (no fallback)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      // First iteration: invalid (empty result) — must retry
      [
        { type: 'text', text: 'this would have been the fallback' },
        { type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: '' } },
        { type: 'done' },
      ],
      // Second iteration: LLM corrects with valid native
      [
        { type: 'tool_use', id: 'c2', name: 'complete', input: { status: 'success', result: 'native corrected' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.output).toBe('native corrected');
    // accumulatedText must NOT have leaked into the output via retry
    expect(result.output).not.toContain('fallback');
  });

  it('two native completes with conflicting args → retry', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'a', name: 'complete', input: { status: 'success', result: 'A' } },
        { type: 'tool_use', id: 'b', name: 'complete', input: { status: 'success', result: 'B' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'c', name: 'complete', input: { status: 'success', result: 'consistent' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.output).toBe('consistent');
  });

  it('two native completes with identical args → first one used', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'a', name: 'complete', input: { status: 'success', result: 'same answer' } },
        { type: 'tool_use', id: 'b', name: 'complete', input: { status: 'success', result: 'same answer' } },
        { type: 'done' },
      ],
    ]);
    const result = await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());
    expect(result.output).toBe('same answer');
  });

});

describe('Phase 6a: complete tool — §7.4 regressions', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('non-terminal transition (movement-to-movement) still works', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const movement: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'plan' }],
      defaultNext: 'plan',
    };
    const client = new FakeClient([
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'plan', summary: 'investigated' } }, { type: 'done' }],
    ]);
    const result = await executeMovement(movement, 'task', client as never, makeContext());
    expect(result.next).toBe('plan');
    expect(result.output).toBe('investigated');
  });

  it('transition({next_step:"COMPLETE"}) is rejected (Phase 6b)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    // FakeClient bypasses tool-schema validation, but the engine's
    // validateTransition still rejects unknown next values, so an LLM
    // emitting a legacy terminal transition gets a tool-result error and
    // must retry. This locks in the no-shim behavior.
    const client = new FakeClient([
      [{
        type: 'tool_use', id: 't1', name: 'transition',
        input: { next_step: 'COMPLETE', summary: 'should not be accepted' },
      }, { type: 'done' }],
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'corrected' } }, { type: 'done' }],
    ]);
    const movement: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      // No COMPLETE in rules — legacy terminal cannot succeed.
      rules: [{ condition: 'fix needed', next: 'fix' }],
    };
    const result = await executeMovement(movement, 'task', client as never, makeContext());
    expect(result.output).toBe('corrected');
  });
});

describe('Phase 6a: complete tool — §7.7 v3 critical (Conditional Go) tests', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('all tool_use ids get a tool_result on retry (conversation history integrity)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'fail-1', name: 'complete', input: { status: 'success', result: '' } },
        { type: 'tool_use', id: 'ignored-1', name: 'complete', input: { status: 'success', result: 'X' } },
        { type: 'done' },
      ],
      [
        { type: 'tool_use', id: 'good', name: 'complete', input: { status: 'success', result: 'recovered' } },
        { type: 'done' },
      ],
    ]);
    await executeMovement(makeMovement(['Read']), 'task', client as never, makeContext());

    // Inspect the messages sent on the second call — every prior tool_use must have a tool_result.
    const secondCallMessages = client.calls[1]?.messages as Array<{ role: string; tool_call_id?: string }>;
    const toolResultIds = secondCallMessages.filter((m) => m.role === 'tool').map((m) => m.tool_call_id);
    expect(toolResultIds).toContain('fail-1');
    expect(toolResultIds).toContain('ignored-1');
  });
});

// ============================================================
// Traceability T-1 — events.jsonl emission scenarios
// ============================================================

import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createFileEventLogger, parseEventLine, type EventBase } from '../progress/event-log.js';

function readEvents(workspace: string): EventBase[] {
  const path = join(workspace, 'logs', 'events.jsonl');
  const lines = readFileSync(path, 'utf-8').trim().split('\n').filter((l) => l.length > 0);
  return lines.map((l) => {
    const parsed = parseEventLine(l);
    if (parsed.kind !== 'ok') throw new Error(`unexpected non-ok event line: ${l}`);
    return parsed.event;
  });
}

describe('Traceability T-1: agent-loop emission', () => {
  let workspace: string;

  beforeEach(() => {
    workspace = mkdtempSync(join(tmpdir(), 'trace-emission-'));
  });

  afterEach(() => {
    rmSync(workspace, { recursive: true, force: true });
  });

  function makeContextWithEvents(workspaceLocal: string): ToolContext {
    return {
      workspacePath: workspaceLocal,
      editAllowed: true,
      eventLogger: createFileEventLogger({ workspacePath: workspaceLocal, runId: 'test-run' }),
    };
  }

  it('emits movement_start, tool_call/result pair, and movement_complete for a single Read flow', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'body', isError: false });

    const client = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'done' } }, { type: 'done' }],
    ]);
    await executeMovement(makeMovement(['Read']), 'task', client as never, makeContextWithEvents(workspace));

    const events = readEvents(workspace);
    const kinds = events.map((e) => e.kind);
    expect(kinds).toContain('movement_start');
    expect(kinds).toContain('tool_call');
    expect(kinds).toContain('tool_result');
    expect(kinds).toContain('complete');
    expect(kinds).toContain('movement_complete');

    const call = events.find((e) => e.kind === 'tool_call' && e.llmToolCallId === 'r1');
    const result = events.find((e) => e.kind === 'tool_result' && e.llmToolCallId === 'r1');
    expect(call?.correlationId).toBeTruthy();
    expect(result?.correlationId).toBe(call?.correlationId);
    expect((result?.payload as { cacheHit: boolean }).cacheHit).toBe(false);
  });

  it('emits cache_set on first Read and cache_hit on second Read of the same file', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'body', isError: false });

    const cache = new ToolResultCache();
    const ctx = makeContextWithEvents(workspace);

    const movA: Movement = {
      name: 'investigate', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'plan' }],
      defaultNext: 'plan',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 't1', name: 'transition', input: { next_step: 'plan', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(movA, 'task', clientA as never, ctx, { toolResultCache: cache });

    const movB: Movement = {
      name: 'plan', edit: false, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'r2', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'done' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, ctx, { toolResultCache: cache });

    const events = readEvents(workspace);
    expect(events.some((e) => e.kind === 'cache_set')).toBe(true);
    expect(events.some((e) => e.kind === 'cache_hit')).toBe(true);
    const cacheHit = events.find((e) => e.kind === 'cache_hit');
    expect((cacheHit?.payload as { sourceMovement: string }).sourceMovement).toBe('investigate');
  });

  it('emits cache_invalidate after a successful Edit', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read', 'Edit']));
    executeToolMock.mockImplementation(async (name: string) => {
      if (name === 'Read') return { output: 'body', isError: false };
      if (name === 'Edit') return { output: 'edited', isError: false };
      return { output: 'x', isError: true };
    });

    const cache = new ToolResultCache();
    const ctx = makeContextWithEvents(workspace);

    const movA: Movement = {
      name: 'investigate', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Read'],
      rules: [{ condition: 'done', next: 'execute' }],
      defaultNext: 'execute',
    };
    const clientA = new FakeClient([
      [{ type: 'tool_use', id: 'r1', name: 'Read', input: { file_path: 'foo.ts' } }, { type: 'done' }],
      [
        { type: 'tool_use', id: 't1', name: 'transition', input: {
          next_step: 'execute',
          summary: 's',
        } },
        { type: 'done' },
      ],
    ]);
    await executeMovement(movA, 'task', clientA as never, ctx, { toolResultCache: cache });

    const movB: Movement = {
      name: 'execute', edit: true, persona: 'p', instruction: 'i',
      allowedTools: ['Edit'],
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
    const clientB = new FakeClient([
      [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }, { type: 'done' }],
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'done' } }, { type: 'done' }],
    ]);
    await executeMovement(movB, 'task', clientB as never, ctx, { toolResultCache: cache });

    const events = readEvents(workspace);
    expect(events.some((e) => e.kind === 'cache_invalidate')).toBe(true);
  });

  it('emits watchdog_fire when no checklist tool is used in 5 iterations', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const responses: Array<Array<{ type: string; id?: string; name?: string; input?: unknown }>> = [];
    for (let i = 0; i < 6; i++) {
      responses.push([
        { type: 'tool_use', id: `r-${i}`, name: 'Read', input: { file_path: `f${i}.ts` } },
        { type: 'done' },
      ]);
    }
    responses.push([
      { type: 'tool_use', id: 'c', name: 'complete', input: { status: 'success', result: 'done' } },
      { type: 'done' },
    ]);
    const client = new FakeClient(responses as never);
    await executeMovement(makeMovement(['Read']), 'task', client as never, makeContextWithEvents(workspace));

    const events = readEvents(workspace);
    expect(events.some((e) => e.kind === 'watchdog_fire')).toBe(true);
  });

  it('all events share the same runId across one piece run', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs(['Read']));
    const ctx = makeContextWithEvents(workspace);
    const client = new FakeClient([
      [{ type: 'tool_use', id: 'c1', name: 'complete', input: { status: 'success', result: 'done' } }, { type: 'done' }],
    ]);
    await executeMovement(makeMovement(['Read']), 'task', client as never, ctx);

    const events = readEvents(workspace);
    expect(events.length).toBeGreaterThan(0);
    const runIds = new Set(events.map((e) => e.runId));
    expect(runIds.size).toBe(1);
    expect(runIds.has('test-run')).toBe(true);
  });
});

// ---------------------------------------------------------------------------
// Phase 4 (SSH Console): buildSystemPrompt screen injection
// ---------------------------------------------------------------------------
import { buildSystemPrompt, buildGlobalPreamble, buildMovementGuidance, __setActiveSessionLookup, type HandoffContext } from './agent-loop.js';

describe('buildSystemPrompt script-work guidance (柱2)', () => {
  const mv = (): Movement => ({
    name: 'm', edit: false, persona: 'p', instruction: 'i',
    allowedTools: [], rules: [{ condition: 'done', next: 'COMPLETE' }], defaultNext: 'COMPLETE',
  });
  const toolDef = (name: string) => ({ type: 'function' as const, function: { name, description: 'd' } });

  it('injects the script section when Bash is among the presented tools', () => {
    const sys = buildSystemPrompt(mv(), 1, 5, [toolDef('Read'), toolDef('Bash')] as never);
    expect(sys).toContain('## スクリプトでの作業');
    expect(sys).toContain('python3');
    expect(sys).toContain('大きな出力はファイルへ');
  });

  it('omits the script section when Bash is not presented', () => {
    const sys = buildSystemPrompt(mv(), 1, 5, [toolDef('Read')] as never);
    expect(sys).not.toContain('## スクリプトでの作業');
  });
});

describe('buildSystemPrompt console injection', () => {
  afterEach(() => {
    __setActiveSessionLookup(null);
  });

  function makeConsoleMovement(allowedTools: string[], allowedSshConnections: string[] = ['*']): Movement {
    return {
      name: 'm1',
      edit: false,
      persona: 'p',
      instruction: 'i',
      allowedTools,
      allowedSshConnections,
      rules: [{ condition: 'done', next: 'COMPLETE' }],
      defaultNext: 'COMPLETE',
    };
  }

  it('appends screen block when SshConsole* in allowed_tools and session exists', () => {
    const fakeSession = {
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      snapshotScreen: () => ({ text: 'mock-screen-line-1\nmock-screen-line-2' }),
    };
    __setActiveSessionLookup((_tid: string) => [fakeSession]);

    const sys = buildSystemPrompt(
      makeConsoleMovement(['SshConsoleEnsure', 'SshConsoleSend', 'SshConsoleSnapshot']),
      1,
      5,
      [],
      null,
      undefined,
      undefined,
      undefined,
      't1',
    );
    expect(sys).toContain('Console screen');
    expect(sys).toContain('mock-screen-line-1');
    expect(sys).toContain('mock-screen-line-2');
  });

  it('does NOT inject when piece does not allow console tools', () => {
    const fakeSession = {
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      snapshotScreen: () => ({ text: 'mock-screen-X' }),
    };
    __setActiveSessionLookup((_tid: string) => [fakeSession]);

    const sys = buildSystemPrompt(
      makeConsoleMovement(['Read', 'Bash']),
      1,
      5,
      [],
      null,
      undefined,
      undefined,
      undefined,
      't1',
    );
    expect(sys).not.toContain('Console screen');
    expect(sys).not.toContain('mock-screen-X');
  });

  it('does NOT inject when no active session is registered for the task', () => {
    __setActiveSessionLookup((_tid: string) => []);

    const sys = buildSystemPrompt(
      makeConsoleMovement(['SshConsoleSend']),
      1,
      5,
      [],
      null,
      undefined,
      undefined,
      undefined,
      't1',
    );
    expect(sys).not.toContain('Console screen');
  });

  it('does NOT inject when taskId is missing (subtask without local_task binding)', () => {
    const fakeSession = {
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      snapshotScreen: () => ({ text: 'mock-screen' }),
    };
    let lookedUp: string | null = null;
    __setActiveSessionLookup((tid: string) => {
      lookedUp = tid;
      return [fakeSession];
    });

    const sys = buildSystemPrompt(
      makeConsoleMovement(['SshConsoleSnapshot']),
      1,
      5,
      [],
      null,
      undefined,
      undefined,
      undefined,
      null,
    );
    expect(sys).not.toContain('Console screen');
    expect(lookedUp).toBeNull();
  });

  it('truncates injected screen to the configured tail length', () => {
    const allLines = Array.from({ length: 200 }, (_, i) => `line-${i + 1}`);
    const fakeSession = {
      connectionId: 'conn-1',
      cols: 80,
      rows: 24,
      snapshotScreen: () => ({ text: allLines.join('\n') }),
    };
    __setActiveSessionLookup((_tid: string) => [fakeSession]);

    const sys = buildSystemPrompt(
      makeConsoleMovement(['SshConsoleSend']),
      1,
      5,
      [],
      null,
      undefined,
      undefined,
      undefined,
      't1',
    );
    // Default tail = SSH_CONSOLE_DEFAULTS.autoInjectScreenLines (24): should contain the last line but not very early lines.
    expect(sys).toContain('line-200');
    expect(sys).toContain('line-177'); // 200 - 24 + 1 = 177 (inclusive tail)
    expect(sys).not.toContain('line-1\n');
    expect(sys).not.toContain('line-50');
  });
});

describe('buildSystemPrompt — handoff blocks', () => {
  // buildSystemPrompt is pure — we can call it directly. Build a minimal
  // Movement that won't trigger errors in the rendering pipeline.
  const movement: Movement = {
    name: 'investigate',
    persona: 'investigator',
    instruction: 'do the thing',
    rules: [{ condition: 'done', next: 'plan' }],
    allowedTools: [],
    edit: false,
  };

  it('always includes the static Continue block', () => {
    const prompt = buildSystemPrompt(movement);
    expect(prompt).toContain('Continue 機能');
    expect(prompt).toContain('output/ ファイルは次の piece でもそのまま参照されます');
  });

  it('omits the dynamic block when handoffContext is not provided', () => {
    const prompt = buildSystemPrompt(movement);
    expect(prompt).not.toContain('前 piece からの引き継ぎ');
  });

  it('includes the dynamic block with prev piece + result when handoffContext is provided', () => {
    const handoff: HandoffContext = {
      prevPiece: 'manual-writer',
      prevResult: 'output/manual.md is ready, with 5 sections covering setup',
    };
    const prompt = buildSystemPrompt(movement, 1, 5, [], null, undefined, undefined, undefined, null, handoff);
    expect(prompt).toContain('前 piece からの引き継ぎ');
    expect(prompt).toContain('manual-writer');
    expect(prompt).toContain('output/manual.md is ready');
  });

  it('handles null prevResult gracefully', () => {
    const handoff: HandoffContext = { prevPiece: 'manual-writer', prevResult: null };
    const prompt = buildSystemPrompt(movement, 1, 5, [], null, undefined, undefined, undefined, null, handoff);
    expect(prompt).toContain('前 piece からの引き継ぎ');
    expect(prompt).toContain('前 piece は最終出力を残しませんでした');
  });

  it('truncates very long prevResult to ~2500 chars (head + tail)', () => {
    // Construct: 3000 'A's + unique 'BBBBBMIDDLE' + 3000 'C's. After
    // truncation: head=2000 of 'A', truncated marker, tail=last 500 of 'C'.
    // The middle marker should be cut out.
    const longResult = 'A'.repeat(3000) + 'BBBBBMIDDLE' + 'C'.repeat(3000);
    const handoff: HandoffContext = { prevPiece: 'manual-writer', prevResult: longResult };
    const prompt = buildSystemPrompt(movement, 1, 5, [], null, undefined, undefined, undefined, null, handoff);
    expect(prompt).toContain('[truncated]');
    expect(prompt).toContain('A'.repeat(100)); // head present
    expect(prompt).toContain('C'.repeat(100)); // tail present
    expect(prompt).not.toContain('BBBBBMIDDLE'); // middle was cut
  });
});

describe('buildSystemPrompt — existing workspace files', () => {
  it('injects existing workspace files section when files exist', () => {
    const ws = mkdtempSync(join(tmpdir(), 'alp-'));
    writeFileSync(join(ws, 'report.pdf'), 'x');
    const prompt = buildSystemPrompt(makeMovement([]), 1, 5, [], undefined, undefined, undefined, ws);
    expect(prompt).toContain('## ワークスペースの既存ファイル');
    expect(prompt).toContain('- report.pdf');
    rmSync(ws, { recursive: true, force: true });
  });

  it('omits the section when workspace has no input files', () => {
    const ws = mkdtempSync(join(tmpdir(), 'alp-'));
    const prompt = buildSystemPrompt(makeMovement([]), 1, 5, [], undefined, undefined, undefined, ws);
    expect(prompt).not.toContain('## ワークスペースの既存ファイル');
    rmSync(ws, { recursive: true, force: true });
  });
});

describe('buildGlobalPreamble / buildMovementGuidance split (Phase A)', () => {
  const toolDef = (name: string) => ({ type: 'function' as const, function: { name, description: 'd。' } });
  const mv = (over: Partial<Movement> = {}): Movement => ({
    name: 'analyze', persona: 'アナリスト', instruction: 'データを分析する',
    allowed_tools: ['Read'], edit: false,
    rules: [{ condition: '分析が終わった', next: 'report' }],
    ...over,
  }) as unknown as Movement;

  it('preamble is byte-identical across two different movements (job-stable)', () => {
    const tools = [toolDef('Read'), toolDef('Bash')];
    const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }) });
    const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }) });
    expect(p1).toBe(p2);
  });

  it('preamble keeps the 柱2 script guidance when Bash is present', () => {
    const p = buildGlobalPreamble([toolDef('Read'), toolDef('Bash')], { workspacePath: '/ws' });
    expect(p).toContain('## スクリプトでの作業');
    expect(p).toContain('python3');
  });

  it('guidance carries movement-specific instruction, persona, transitions', () => {
    const g = buildMovementGuidance(mv(), 1, 5);
    expect(g).toContain('## 現在のステップ: analyze');
    expect(g).toContain('アナリスト');
    expect(g).toContain('分析が終わった');
    expect(g).toContain('データを分析する');
    expect(g).toContain('report');
  });

  it('guidance injects progressive-pressure warning on revisit', () => {
    expect(buildMovementGuidance(mv(), 3, 5)).toContain('警告');
    expect(buildMovementGuidance(mv(), 1, 5)).not.toContain('警告');
  });

  it('buildSystemPrompt wrapper still contains both halves', () => {
    const tools = [toolDef('Read'), toolDef('Bash')];
    const full = buildSystemPrompt(mv(), 1, 5, tools, undefined, 'u1', undefined, '/ws');
    expect(full).toContain('## スクリプトでの作業');     // preamble half
    expect(full).toContain('## 現在のステップ: analyze'); // guidance half
  });
});

import { Conversation } from './context/conversation.js';

describe('executeMovement conversation wiring (Phase A)', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  function makeFakeClientThatCompletes(resultText: string): FakeClient {
    return new FakeClient([
      [
        { type: 'tool_use', id: `complete-${resultText}`, name: 'complete', input: { status: 'success', result: resultText } },
        { type: 'done' },
      ],
    ]);
  }

  it('second movement appends guidance without re-seeding preamble/task', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conv = new Conversation();

    const client1 = makeFakeClientThatCompletes('done-1');
    await executeMovement(makeMovement([]), 'ORIGINAL TASK', client1 as never, makeContext(), {
      conversation: conv, maxIterations: 2,
    });
    const afterM1 = conv.messages.length;
    // task instruction appears exactly once after movement 1
    expect(conv.messages.filter((m) => m.content === 'ORIGINAL TASK')).toHaveLength(1);

    const client2 = makeFakeClientThatCompletes('done-2');
    const m2 = makeMovement([]);
    (m2 as Movement & { name: string }).name = 'm2';
    await executeMovement(m2, 'IGNORED-FOR-CONV', client2 as never, makeContext(), {
      conversation: conv, maxIterations: 2,
    });

    // preamble/task are NOT re-injected — still exactly one
    expect(conv.messages.filter((m) => m.content === 'ORIGINAL TASK')).toHaveLength(1);
    // m1 history is retained
    expect(conv.messages.length).toBeGreaterThan(afterM1);
    // m2 guidance is injected mid-conversation as a `user` message (never
    // `system`, which strict chat templates reject after the first index).
    expect(conv.messages.some((m) => m.role === 'user' && String(m.content).includes('現在のステップ: m2'))).toBe(true);
    // exactly one system message survives (the merged seed preamble+guidance).
    expect(conv.messages.filter((m) => m.role === 'system')).toHaveLength(1);
  });

  // Codex follow-up #1: a transition exits the movement, but its tool_call must
  // still get a matching tool result. The next movement reuses the SAME
  // conversation.messages, so a dangling control tool_call would be sent to the
  // model and rejected by strict OpenAI-compatible providers.
  it('leaves no dangling control tool_call in the shared conversation after a transition (Codex #1)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conv = new Conversation();
    const m1: Movement = {
      name: 'execute', edit: true, persona: 'worker', instruction: 'x',
      allowedTools: [], rules: [{ condition: 'done', next: 'plan' }], defaultNext: 'plan',
    };
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'trans-1', name: 'transition', input: { next_step: 'plan', summary: 's' } },
        { type: 'done' },
      ],
    ]);

    const res = await executeMovement(m1, 'TASK', client as never, makeContext(), { conversation: conv, maxIterations: 2 });
    expect(res.next).toBe('plan');

    const resultIds = new Set(conv.messages.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
    const dangling = conv.messages
      .flatMap((m) => (m.role === 'assistant' && m.tool_calls ? m.tool_calls : []))
      .filter((c) => !resultIds.has(c.id));
    expect(dangling).toEqual([]);
  });

  // Codex follow-up #1, the literal acceptance criterion + the complete path:
  // run a REAL second movement that reuses the shared conversation, and inspect
  // the exact messages the model receives for it. Also covers the native_winner
  // `complete` path's tool-result recording, which the transition-only E2E does
  // not exercise (a regression there would dangle the complete call).
  it('feeds the second movement a clean model input and records the complete result (Codex #1)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conv = new Conversation();
    const m1: Movement = {
      name: 'plan', edit: true, persona: 'worker', instruction: 'x',
      allowedTools: [], rules: [{ condition: 'done', next: 'respond' }], defaultNext: 'respond',
    };
    const m2: Movement = {
      name: 'respond', edit: true, persona: 'worker', instruction: 'x',
      allowedTools: [], rules: [], defaultNext: 'COMPLETE',
    };

    const client1 = new FakeClient([
      [{ type: 'tool_use', id: 'trans-1', name: 'transition', input: { next_step: 'respond', summary: 's' } }, { type: 'done' }],
    ]);
    await executeMovement(m1, 'TASK', client1 as never, makeContext(), { conversation: conv, maxIterations: 2 });

    const client2 = new FakeClient([
      [{ type: 'tool_use', id: 'comp-1', name: 'complete', input: { status: 'success', result: 'done' } }, { type: 'done' }],
    ]);
    const res2 = await executeMovement(m2, 'TASK', client2 as never, makeContext(), { conversation: conv, maxIterations: 2 });
    expect(res2.next).toBe('COMPLETE');

    const noDangling = (messages: Message[]): ToolCall[] => {
      const ids = new Set(messages.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
      return messages.flatMap((m) => (m.role === 'assistant' && m.tool_calls ? m.tool_calls : [])).filter((c) => !ids.has(c.id));
    };

    // The exact model input for movement 2's first request — the literal criterion.
    const modelInput = client2.calls[0]!.messages as Message[];
    expect(noDangling(modelInput)).toEqual([]);
    // And the winning complete in movement 2 is itself resolved (no dangling complete).
    expect(noDangling(conv.messages)).toEqual([]);
  });
});

import type { Message, ToolCall } from '../llm/openai-compat.js';

describe('executeMovement P2 continuation seed', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  function makeFakeClientThatCompletes(resultText: string): FakeClient {
    return new FakeClient([
      [
        { type: 'tool_use', id: `complete-p2-${resultText}`, name: 'complete', input: { status: 'success', result: resultText } },
        { type: 'done' },
      ],
    ]);
  }

  it('replays priorTurns and suppresses handoff block when priorTurns present', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conversation = new Conversation(undefined);
    const priorTurns: Message[] = [
      { role: 'user', content: 'earlier ask' },
      { role: 'assistant', content: 'earlier answer' },
    ];
    const client = makeFakeClientThatCompletes('done');
    await executeMovement(makeMovement([]), 'new ask', client as never, makeContext(), {
      conversation,
      priorTurns,
      handoffContext: { prevPiece: 'chat', prevResult: 'HANDOFF_SENTINEL_XYZ' },
      maxIterations: 2,
    });

    // messages layout: [system(merged preamble+guidance), user(earlier), assistant(earlier), user(new ask), ...]
    expect(conversation.messages[1]).toEqual({ role: 'user', content: 'earlier ask' });
    expect(conversation.messages[2]).toEqual({ role: 'assistant', content: 'earlier answer' });
    // preamble must NOT contain the handoff LIMIT-1 result when priorTurns present
    expect(conversation.messages[0].content).not.toContain('HANDOFF_SENTINEL_XYZ');
  });

  it('without priorTurns, uses plain seed and includes handoff block', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conversation = new Conversation(undefined);
    const client = makeFakeClientThatCompletes('done');
    await executeMovement(makeMovement([]), 'ask', client as never, makeContext(), {
      conversation,
      handoffContext: { prevPiece: 'chat', prevResult: 'CARRYOVER_TEXT' },
      maxIterations: 2,
    });

    expect(conversation.messages[0].content).toContain('CARRYOVER_TEXT');
  });

  it('with empty priorTurns array, behaves like plain seed (handoff included)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const conversation = new Conversation(undefined);
    const client = makeFakeClientThatCompletes('done');
    await executeMovement(makeMovement([]), 'ask', client as never, makeContext(), {
      conversation,
      priorTurns: [],
      handoffContext: { prevPiece: 'chat', prevResult: 'EMPTY_PRIOR_SENTINEL' },
      maxIterations: 2,
    });

    // empty array = no prior turns = behave like plain seed
    expect(conversation.messages[0].content).toContain('EMPTY_PRIOR_SENTINEL');
    // messages[1] should be the user task instruction, not a replayed turn
    // (seed now emits a single merged system message, so the task sits at index 1).
    expect(conversation.messages[1].content).toBe('ask');
  });
});

describe('executeMovement tool list — transition omitted when rules is empty', () => {
  afterEach(() => {
    executeToolMock.mockReset();
    getToolDefsMock.mockReset();
  });

  it('omits the transition tool when movement.rules is empty (only complete offered)', async () => {
    getToolDefsMock.mockResolvedValue(makeToolDefs([]));
    executeToolMock.mockResolvedValue({ output: 'ok', isError: false });

    const noRulesMovement: Movement = {
      name: 'delegate',
      edit: false,
      persona: 'worker',
      instruction: 'Do the work.',
      allowedTools: [],
      rules: [],
      defaultNext: 'COMPLETE',
    };

    // Scripted client: immediately calls complete on the first chat call.
    const client = new FakeClient([
      [
        { type: 'tool_use', id: 'complete-1', name: 'complete', input: { status: 'success', result: 'done' } },
        { type: 'done' },
      ],
    ]);

    await executeMovement(noRulesMovement, 'task', client as never, makeContext());

    expect(client.calls.length).toBeGreaterThan(0);
    const toolsPassedToClient = client.calls[0].tools as Array<{ type: string; function: { name: string } }> | undefined;
    const toolNames = (toolsPassedToClient ?? []).map((t) => t.function.name);

    expect(toolNames).toContain('complete');
    expect(toolNames).not.toContain('transition');
  });
});
