import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// cspell:ignore fscrud unstub wsarecv

import {
  OllamaPromptTurnRunner,
  parseActionEnvelope,
  simplifyPromptLanguageEnvelope,
} from './ollama-prompt-turn-runner.js';

// cspell:ignore hidethinking nowordwrap

describe('parseActionEnvelope', () => {
  it('parses a strict json action envelope', () => {
    expect(
      parseActionEnvelope(
        '{"actions":[{"type":"write_file","path":"hello.txt","content":"Hello"}]}',
      ),
    ).toEqual({
      actions: [{ type: 'write_file', path: 'hello.txt', content: 'Hello' }],
    });
  });

  it('extracts json from fenced content', () => {
    expect(
      parseActionEnvelope('```json\n{"actions":[{"type":"done","message":"ok"}]}\n```'),
    ).toEqual({
      actions: [{ type: 'done', message: 'ok' }],
    });
  });

  it('rejects invalid action shapes', () => {
    expect(parseActionEnvelope('{"actions":[{"type":"write_file"}]}')).toBeUndefined();
  });
});

describe('OllamaPromptTurnRunner capabilities', () => {
  it('advertises the current action-loop runner as in-process recorded-only evidence', () => {
    expect(new OllamaPromptTurnRunner().capabilities).toEqual({
      externalProcess: false,
      terminate: false,
      cwdOverride: true,
      modelPassThrough: true,
      stateDirPolling: false,
      inProcessExecution: true,
    });
  });
});

describe('simplifyPromptLanguageEnvelope', () => {
  it('compacts prompt-language headless envelopes for local models', () => {
    const original = [
      '[prompt-language] Flow: test let/var | Status: active',
      '',
      '~ let greeting = "hello world"  [= hello world]',
      '~ let ver = run "node -v"  [= v25.6.1]',
      '~ prompt: Write the greeting "${greeting}" and node version "${ver}" to result.txt',
      '',
      'Variables:',
      '  greeting = hello world',
      '  ver = v25.6.1',
      '',
      'Write the greeting "hello world" and node version "v25.6.1" to result.txt',
      '',
      '[prompt-language: step 0/3 "done", vars: 7]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(
      [
        'Context:',
        'Flow: test let/var | Status: active',
        '',
        'Variables:',
        '  greeting = hello world',
        '  ver = v25.6.1',
        '',
        'Current task:',
        'Write the greeting "hello world" and node version "v25.6.1" to result.txt',
      ].join('\n'),
    );
  });

  it('leaves non-envelope prompts unchanged', () => {
    expect(simplifyPromptLanguageEnvelope('Create hello.txt containing OK')).toBe(
      'Create hello.txt containing OK',
    );
  });

  it('preserves prior prompt history when it carries recall context', () => {
    const original = [
      '[prompt-language] Flow: recall test | Status: active',
      '',
      '~ prompt: The code is "alpha-bravo-99". Acknowledge it.',
      '~ prompt: Create recall.txt containing the code from the previous step.',
      '',
      'Create a file called recall.txt containing exactly the code from the previous step, nothing else.',
      '',
      '[prompt-language: step 0/2 "done", vars: 2]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(
      [
        'Context:',
        'Flow: recall test | Status: active',
        '',
        '~ prompt: The code is "alpha-bravo-99". Acknowledge it.',
        '',
        'Current task:',
        'Create a file called recall.txt containing exactly the code from the previous step, nothing else.',
      ].join('\n'),
    );
  });

  it('drops the current marked prompt line when compacting an active step envelope', () => {
    const original = [
      '[prompt-language] Flow: recall test | Status: active',
      '',
      '~ prompt: The code is "alpha-bravo-99". Acknowledge it.',
      '> prompt: Create a file called recall.txt containing exactly the code from the previous step, nothing else.  <-- current',
      '',
      'The code is "alpha-bravo-99". Acknowledge it.',
      '',
      '[prompt-language: step 2/2 "prompt: Create recall...", vars: 0]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(
      [
        'Context:',
        'Flow: recall test | Status: active',
        '',
        'Current task:',
        'The code is "alpha-bravo-99". Acknowledge it.',
      ].join('\n'),
    );
  });

  it('compacts capture envelopes to the current task and capture instruction', () => {
    const original = [
      '[prompt-language] Flow: test let-prompt capture | Status: active',
      '',
      '> let items = prompt "List exactly three colors: red, green, blue. One per line, no bullets."  [awaiting response...]  <-- current',
      '  foreach item in ${items}',
      '    run: echo ${item} >> colors.txt',
      '  end',
      '',
      '[Capture active: write response to .prompt-language/vars/items using Write tool]',
      '',
      'List exactly three colors: red, green, blue. One per line, no bullets.',
      '',
      '[Internal — prompt-language variable capture: After completing the task above, save your answer to `.prompt-language/vars/items` using the Write tool.]',
      '',
      '[prompt-language: step 1/3 "let items", vars: 0]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(
      [
        'List exactly three colors: red, green, blue. One per line, no bullets.',
        '',
        '[Internal — prompt-language variable capture: After completing the task above, save your answer to `.prompt-language/vars/items` using the Write tool.]',
        '',
        'Do not execute future flow steps. Do not create or edit workspace files during capture.',
      ].join('\n'),
    );
  });

  it('leaves resumed envelopes unchanged so recovery context is not compacted away', () => {
    const original = [
      '[prompt-language] Flow: resume flow | Status: active',
      '',
      '~ prompt: Rebuild the auth module.',
      '> run: npm test  <-- current',
      '',
      '[resumed from [prompt-language: step 2/2 "run: npm test", vars: 1]]',
      'Continue from the restored step.',
      '',
      '[prompt-language: step 2/2 "run: npm test", vars: 1]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(original);
  });

  it('leaves pending-gate envelopes unchanged so gate uncertainty stays visible', () => {
    const original = [
      '[prompt-language] Flow: gated flow | Status: active',
      '',
      '> prompt: Keep going  <-- current',
      '',
      'done when:',
      '  tests_pass  [pending]',
      '  lint_pass  [pass]',
      '',
      'Continue.',
      '',
      '[prompt-language: step 1/1 "prompt: Keep going", vars: 0, gates: 1/2 passed]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(original);
  });

  it('leaves resumed spawn/await envelopes unchanged so child recovery stays inspectable', () => {
    const original = [
      '[prompt-language] Flow: spawn flow | Status: active',
      '',
      '~ spawn "fix-auth"  [running]',
      '> await all  <-- current',
      '',
      '[resumed from [prompt-language: step 2/3 "await all", vars: 1]]',
      'Continue after restore.',
      '',
      '[prompt-language: step 2/3 "await all", vars: 1]',
    ].join('\n');

    expect(simplifyPromptLanguageEnvelope(original)).toBe(original);
  });
});

describe('OllamaPromptTurnRunner', () => {
  let tempDir = '';
  let fetchMock: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    vi.unstubAllEnvs();
    fetchMock = vi.fn();
    vi.stubGlobal('fetch', fetchMock);
  });

  afterEach(async () => {
    vi.unstubAllGlobals();
    if (!tempDir) return;
    await rm(tempDir, { recursive: true, force: true });
    tempDir = '';
  });

  it('writes files requested by the model and reports progress', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content:
            '{"actions":[{"type":"write_file","path":"hello.txt","content":"Hello, world!"},{"type":"done","message":"created"}]}',
        },
        prompt_eval_count: 17,
        eval_count: 9,
        total_duration: 2_000_000_000,
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: 'Create hello.txt containing exactly Hello, world!',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'created',
      madeProgress: true,
    });
    await expect(readFile(join(tempDir, 'hello.txt'), 'utf8')).resolves.toBe('Hello, world!');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'ollama-turns.jsonl'), 'utf8'),
    ).resolves.toContain('"workspaceActions":1');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"totalTokens":26');
  });

  it('allows done-only acknowledgements for non-workspace prompts', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content: '{"actions":[{"type":"done","message":"Acknowledged"}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: 'The code is alpha-bravo-99. Acknowledge it.',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'Acknowledged',
      madeProgress: true,
    });
  });

  it('sends explicit context size to Ollama HTTP requests when configured', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_NUM_CTX', '4096');
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content: '{"actions":[{"type":"done","message":"context ok"}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: 'Acknowledge the task.',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'context ok',
      madeProgress: true,
    });
    const request = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
    expect(request.think).toBe(false);
    expect(request.options).toMatchObject({ temperature: 0, num_ctx: 4096 });
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"numCtx":4096');
  });

  it('treats a compacted first-step acknowledgement as progress even when the raw envelope contains later file work', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content: '{"actions":[{"type":"done","message":"Code alpha-bravo-99 acknowledged."}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: [
        '[prompt-language] Flow: recall test | Status: active',
        '',
        '~ prompt: The code is "alpha-bravo-99". Acknowledge it.',
        '~ prompt: Create a file called recall.txt containing exactly the code from the previous step, nothing else.',
        '',
        'The code is "alpha-bravo-99". Acknowledge it.',
        '',
        '[prompt-language: step 0/2 "done", vars: 0]',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'Code alpha-bravo-99 acknowledged.',
      madeProgress: true,
    });
  });

  it('marks done-only workspace prompts as no observable progress', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content: '{"actions":[{"type":"done","message":"OK"}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: 'Create secret.txt containing exactly "magic-unicorn-42"',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'OK',
      madeProgress: false,
    });
  });

  it('returns an actionable diagnostic when action rounds are exhausted', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_ACTION_ROUNDS', '2');
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content: '{"actions":[{"type":"list_files","path":"."}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Create secret.txt containing exactly "magic-unicorn-42"',
    });

    expect(result.exitCode).toBe(1);
    expect(result.madeProgress).toBe(false);
    expect(result.assistantText).toContain('Ollama runner exceeded the action round limit (2).');
    expect(result.assistantText).toContain('local runner budget stop');
    expect(result.assistantText).toContain('PROMPT_LANGUAGE_OLLAMA_ACTION_ROUNDS=16');
    expect(result.assistantText).toContain('PROMPT_LANGUAGE_OLLAMA_ACTION_ROUNDS=24');
    expect(result.assistantText).toContain('made no workspace progress');
    expect(fetchMock).toHaveBeenCalledTimes(2);
    await expect(
      readFile(join(tempDir, '.prompt-language', 'ollama-turns.jsonl'), 'utf8'),
    ).resolves.toContain('"rounds":2');
  });

  it('falls back to gemma4-cpu:31b when gemma4:31b fails to load', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: false,
        status: 500,
        json: async () => ({
          error:
            'model failed to load, this may be due to resource limitations or an internal error, check ollama server logs for details',
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"hello.txt","content":"OK"},{"type":"done","message":"created"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/gemma4:31b',
      prompt: 'Create a file named hello.txt containing exactly OK',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'created',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(fetchMock.mock.calls[0]?.[0]).toBe('http://127.0.0.1:11434/api/chat');
    expect(fetchMock.mock.calls[1]?.[0]).toBe('http://127.0.0.1:11434/api/chat');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'ollama-turns.jsonl'), 'utf8'),
    ).resolves.toContain('"actualModel":"gemma4-cpu:31b"');
  });

  it('retries transient fetch failures before failing the turn', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_RETRY_DELAY_MS', '1');
    fetchMock.mockRejectedValueOnce(new TypeError('fetch failed')).mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        message: {
          content:
            '{"actions":[{"type":"write_file","path":"hello.txt","content":"OK"},{"type":"done","message":"created"}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: 'Create a file named hello.txt containing exactly OK',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'created',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    await expect(readFile(join(tempDir, 'hello.txt'), 'utf8')).resolves.toBe('OK');
  });

  it('uses explicit CLI transport without requiring the Ollama HTTP endpoint', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakeCliPath = join(tempDir, 'fake-ollama-cli.mjs');
    const argLogPath = join(tempDir, 'fake-ollama-args.jsonl');
    await writeFile(
      fakeCliPath,
      [
        '#!/usr/bin/env node',
        "import { appendFileSync } from 'node:fs';",
        'appendFileSync(process.env.FAKE_OLLAMA_ARGS_PATH, `${JSON.stringify(process.argv.slice(2))}\\n`);',
        "process.stdout.write('\\u001b[?25l\\r\\u001b[K');",
        'process.stdout.write(\'{"actions":[{"type":"write_file","path":"hello.txt","content":"CLI OK"},{"type":"done","message":"cli created"}]}\');',
      ].join('\n'),
      'utf8',
    );
    await chmod(fakeCliPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'cli');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_CLI_PATH', fakeCliPath);
    vi.stubEnv('FAKE_OLLAMA_ARGS_PATH', argLogPath);

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Create a file named hello.txt containing exactly CLI OK',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'cli created',
      madeProgress: true,
    });
    expect(fetchMock).not.toHaveBeenCalled();
    await expect(readFile(join(tempDir, 'hello.txt'), 'utf8')).resolves.toBe('CLI OK');
    const args = JSON.parse((await readFile(argLogPath, 'utf8')).trim()) as string[];
    expect(args.slice(0, 4)).toEqual(['run', '--hidethinking', '--nowordwrap', '--keepalive']);
    expect(args).toContain('qwen3-coder:30b');
    expect(args.at(-1)).toContain('SYSTEM:');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"transport":"cli"');
  });

  it('uses explicit PowerShell transport for Windows-local Ollama HTTP', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakePowerShellPath = join(tempDir, 'fake-powershell.mjs');
    const argLogPath = join(tempDir, 'fake-powershell-args.jsonl');
    await writeFile(
      fakePowerShellPath,
      [
        '#!/usr/bin/env node',
        "import { appendFileSync } from 'node:fs';",
        "let input = '';",
        "process.stdin.setEncoding('utf8');",
        "process.stdin.on('data', (chunk) => { input += chunk; });",
        "process.stdin.on('end', () => {",
        '  appendFileSync(process.env.FAKE_POWERSHELL_ARGS_PATH, `${JSON.stringify({ args: process.argv.slice(2), input })}\\n`);',
        '  process.stdout.write(\'{"model":"qwen3-coder:30b","message":{"content":"{\\\\\\"actions\\\\\\":[{\\\\\\"type\\\\\\":\\\\\\"done\\\\\\",\\\\\\"message\\\\\\":\\\\\\"powershell ok\\\\\\"}]}"},"prompt_eval_count":3,"eval_count":4}\');',
        '});',
      ].join('\n'),
      'utf8',
    );
    await chmod(fakePowerShellPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'powershell');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_POWERSHELL_PATH', fakePowerShellPath);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_NUM_CTX', '4096');
    vi.stubEnv('FAKE_POWERSHELL_ARGS_PATH', argLogPath);

    const runner = new OllamaPromptTurnRunner();
    const longMarker = `powershell-transport-smoke-${'x'.repeat(50_000)}`;
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: `The code is ${longMarker}. Acknowledge it.`,
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'powershell ok',
      madeProgress: true,
    });
    expect(fetchMock).not.toHaveBeenCalled();
    const log = JSON.parse((await readFile(argLogPath, 'utf8')).trim()) as {
      args: string[];
      input: string;
    };
    const args = log.args;
    expect(args.slice(0, 2)).toEqual(['-NoProfile', '-Command']);
    expect(args.at(-1)).toContain('Invoke-RestMethod');
    expect(args.join('\n')).not.toContain(longMarker);
    expect(log.input).toContain(longMarker);
    expect(JSON.parse(log.input).think).toBe(false);
    expect(JSON.parse(log.input).options).toMatchObject({ temperature: 0, num_ctx: 4096 });
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"transport":"powershell"');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"numCtx":4096');
  });

  it('rejects explicit context size on CLI transport because the local CLI has no context flag', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakeCliPath = join(tempDir, 'should-not-run.mjs');
    await writeFile(
      fakeCliPath,
      [
        '#!/usr/bin/env node',
        "throw new Error('CLI transport should not run when context override is configured');",
      ].join('\n'),
      'utf8',
    );
    await chmod(fakeCliPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'cli');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_CLI_PATH', fakeCliPath);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_NUM_CTX', '4096');

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Acknowledge the task.',
    });

    expect(result.exitCode).toBe(1);
    expect(result.assistantText).toContain(
      'PROMPT_LANGUAGE_OLLAMA_NUM_CTX is only supported by http or powershell transport',
    );
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('rejects oversized CLI transport prompts before launching Ollama', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakeCliPath = join(tempDir, 'should-not-run.mjs');
    await writeFile(
      fakeCliPath,
      [
        '#!/usr/bin/env node',
        "throw new Error('CLI transport should not run for oversized prompts');",
      ].join('\n'),
      'utf8',
    );
    await chmod(fakeCliPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'cli');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_CLI_PATH', fakeCliPath);

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: `Acknowledge this large prompt: ${'x'.repeat(31_000)}`,
    });

    expect(result.exitCode).toBe(1);
    expect(result.assistantText).toContain('Ollama CLI prompt exceeds 30000 bytes');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('reports CLI transport process stderr when Ollama exits unsuccessfully', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakeCliPath = join(tempDir, 'failing-ollama-cli.mjs');
    await writeFile(
      fakeCliPath,
      [
        '#!/usr/bin/env node',
        "process.stderr.write('cli transport unavailable');",
        'process.exit(2);',
      ].join('\n'),
      'utf8',
    );
    await chmod(fakeCliPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'cli');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_CLI_PATH', fakeCliPath);

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Acknowledge the task.',
    });

    expect(result.exitCode).toBe(1);
    expect(result.assistantText).toContain('cli transport unavailable');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('reports PowerShell transport stderr when the bridge command fails', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakePowerShellPath = join(tempDir, 'failing-powershell.mjs');
    await writeFile(
      fakePowerShellPath,
      [
        '#!/usr/bin/env node',
        "process.stderr.write('powershell bridge unavailable');",
        'process.exit(1);',
      ].join('\n'),
      'utf8',
    );
    await chmod(fakePowerShellPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'powershell');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_POWERSHELL_PATH', fakePowerShellPath);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_BASE_URL', 'http://127.0.0.1:11435');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TIMEOUT_MS', '12345');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_NUM_CTX', '4096');

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Acknowledge the task.',
    });

    expect(result.exitCode).toBe(1);
    expect(result.assistantText).toContain('powershell bridge unavailable');
    expect(result.assistantText).toContain('model=qwen3-coder:30b');
    expect(result.assistantText).toContain('endpoint=http://127.0.0.1:11435/api/chat');
    expect(result.assistantText).toContain('timeoutMs=12345');
    expect(result.assistantText).toContain('numCtx=4096');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('retries transient PowerShell transport socket resets', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    const fakePowerShellPath = join(tempDir, 'flaky-powershell.mjs');
    const attemptPath = join(tempDir, 'flaky-powershell-attempts.txt');
    await writeFile(
      fakePowerShellPath,
      [
        '#!/usr/bin/env node',
        "import { existsSync, readFileSync, writeFileSync } from 'node:fs';",
        'const attemptPath = process.env.FAKE_POWERSHELL_ATTEMPTS_PATH;',
        "const attempts = existsSync(attemptPath) ? Number(readFileSync(attemptPath, 'utf8')) : 0;",
        'writeFileSync(attemptPath, String(attempts + 1));',
        'process.stdin.resume();',
        'if (attempts === 0) {',
        '  process.stderr.write(\'Invoke-RestMethod : {"error":"read tcp 127.0.0.1:61634->127.0.0.1:61869: wsarecv: An existing connection was forcibly closed by the remote host."}\');',
        '  process.exit(1);',
        '}',
        'process.stdout.write(\'{"model":"qwen3-coder:30b","message":{"content":"{\\\\\\"actions\\\\\\":[{\\\\\\"type\\\\\\":\\\\\\"done\\\\\\",\\\\\\"message\\\\\\":\\\\\\"powershell retried\\\\\\"}]}"},"prompt_eval_count":3,"eval_count":4}\');',
      ].join('\n'),
      'utf8',
    );
    await chmod(fakePowerShellPath, 0o755);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_TRANSPORT', 'powershell');
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_POWERSHELL_PATH', fakePowerShellPath);
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_RETRY_DELAY_MS', '1');
    vi.stubEnv('FAKE_POWERSHELL_ATTEMPTS_PATH', attemptPath);

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-coder:30b',
      prompt: 'Acknowledge the task after retry.',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'powershell retried',
      madeProgress: true,
    });
    await expect(readFile(attemptPath, 'utf8')).resolves.toBe('2');
    await expect(
      readFile(join(tempDir, '.prompt-language', 'provider-telemetry.jsonl'), 'utf8'),
    ).resolves.toContain('"retryCount":1');
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it('retries Ollama cold-start model runner crashes', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    vi.stubEnv('PROMPT_LANGUAGE_OLLAMA_RETRY_DELAY_MS', '1');
    fetchMock
      .mockResolvedValueOnce({
        ok: false,
        status: 500,
        json: async () => ({
          error:
            'model runner has unexpectedly stopped, this may be due to resource limitations or an internal error',
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content: '{"actions":[{"type":"done","message":"ready"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: 'Acknowledge the task.',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'ready',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });

  it('feeds file action failures back to the model instead of aborting the turn', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content: '{"actions":[{"type":"read_file","path":"package.json"}]}',
          },
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"workspace/fscrud-01/package.json","content":"{\\"scripts\\":{\\"test\\":\\"node --test\\"}}"},{"type":"done","message":"repaired"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: 'Repair package.json only under workspace/fscrud-01.',
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'repaired',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    const secondPrompt = JSON.stringify(fetchMock.mock.calls[1]?.[1]);
    expect(secondPrompt).toContain('read_file(package.json) failed');
    await expect(
      readFile(join(tempDir, 'workspace', 'fscrud-01', 'package.json'), 'utf8'),
    ).resolves.toContain('node --test');
  });

  it('roots relative file actions under the declared workspace variable', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock.mockResolvedValue({
      ok: true,
      json: async () => ({
        message: {
          content:
            '{"actions":[{"type":"write_file","path":"src/domain.js","content":"module.exports = {};"},{"type":"done","message":"domain created"}]}',
        },
      }),
    });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: [
        'Variables:',
        '  fscrud_workspace = workspace/fscrud-01',
        '',
        'Current task:',
        'Repair src/domain.js only.',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'domain created',
      madeProgress: true,
    });
    await expect(readFile(join(tempDir, 'src', 'domain.js'), 'utf8')).rejects.toThrow();
    await expect(
      readFile(join(tempDir, 'workspace', 'fscrud-01', 'src', 'domain.js'), 'utf8'),
    ).resolves.toBe('module.exports = {};');
  });

  it('rejects nested app-root paths under the declared workspace variable', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"fscrud-01/src/server.js","content":"nested"}]}',
          },
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"workspace/fscrud-01/src/server.js","content":"correct"},{"type":"done","message":"server repaired"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: [
        'Variables:',
        '  fscrud_workspace = workspace/fscrud-01',
        '',
        'Current task:',
        'Repair workspace/fscrud-01/src/server.js only.',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'server repaired',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    const secondPrompt = JSON.stringify(fetchMock.mock.calls[1]?.[1]);
    expect(secondPrompt).toContain('duplicates the app root');
    await expect(
      readFile(join(tempDir, 'workspace', 'fscrud-01', 'fscrud-01', 'src', 'server.js'), 'utf8'),
    ).rejects.toThrow();
    await expect(
      readFile(join(tempDir, 'workspace', 'fscrud-01', 'src', 'server.js'), 'utf8'),
    ).resolves.toBe('correct');
  });

  it('rejects workspace writes during capture turns', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"workspace/app.js","content":"bad"}]}',
          },
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":".prompt-language/vars/items","content":"{\\"ok\\":true}"},{"type":"done","message":"captured"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: [
        '[prompt-language] Flow: capture | Status: active',
        '',
        '> let items = prompt "Return JSON."  [awaiting response...]  <-- current',
        '  prompt: Create workspace/app.js',
        '',
        '[Capture active: write response to .prompt-language/vars/items using Write tool]',
        '',
        'Return JSON.',
        '',
        '[Internal — prompt-language variable capture: After completing the task above, save your answer to `.prompt-language/vars/items` using the Write tool.]',
        '',
        '[prompt-language: step 1/2 "let items", vars: 0]',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'Captured .prompt-language/vars/items',
      madeProgress: true,
    });
    await expect(readFile(join(tempDir, 'workspace', 'app.js'), 'utf8')).rejects.toThrow();
    await expect(
      readFile(join(tempDir, '.prompt-language', 'vars', 'items'), 'utf8'),
    ).resolves.toBe('{"ok":true}');
  });

  it('rejects commands during capture turns and accepts equivalent capture paths', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"run_command","command":"Set-Content leaked.txt bad"},{"type":"done","message":"not captured"}]}',
          },
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":"./.prompt-language/vars/items","content":"done"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: [
        '[prompt-language] Flow: capture | Status: active',
        '',
        '> let items = prompt "Return text."  [awaiting response...]  <-- current',
        '',
        '[Capture active: write response to .prompt-language/vars/items using Write tool]',
        '',
        'Return text.',
        '',
        '[Internal — prompt-language variable capture: After completing the task above, save your answer to `.prompt-language/vars/items` using the Write tool.]',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'Captured .prompt-language/vars/items',
      madeProgress: true,
    });
    await expect(readFile(join(tempDir, 'leaked.txt'), 'utf8')).rejects.toThrow();
    await expect(
      readFile(join(tempDir, '.prompt-language', 'vars', 'items'), 'utf8'),
    ).resolves.toBe('done');
  });

  it('detects JSON capture instructions as capture turns', async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'pl-ollama-runner-'));
    fetchMock
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content: '{"actions":[{"type":"done","message":"Frame completed."}]}',
          },
        }),
      })
      .mockResolvedValueOnce({
        ok: true,
        json: async () => ({
          message: {
            content:
              '{"actions":[{"type":"write_file","path":".prompt-language/vars/senior_frame","content":"{\\"objective\\":\\"ship\\"}"}]}',
          },
        }),
      });

    const runner = new OllamaPromptTurnRunner();
    const result = await runner.run({
      cwd: tempDir,
      model: 'ollama/qwen3-opencode-big:30b',
      prompt: [
        '[prompt-language] Flow: capture | Status: active',
        '',
        '> let senior_frame = prompt "Return strict JSON only." as json { ... }  [awaiting response...]  <-- current',
        '',
        '[Capture active: write response to .prompt-language/vars/senior_frame using Write tool]',
        '',
        'Return strict JSON only.',
        '',
        '[Internal — prompt-language JSON capture: Respond with a JSON object that matches this schema:',
        '```',
        '"objective": "string"',
        '```',
        'Save your JSON answer to `.prompt-language/vars/senior_frame` using the Write tool. Respond with ONLY valid JSON — no explanation, no markdown fences, just the JSON object. Maximum 2000 characters.]',
      ].join('\n'),
    });

    expect(result).toEqual({
      exitCode: 0,
      assistantText: 'Captured .prompt-language/vars/senior_frame',
      madeProgress: true,
    });
    expect(fetchMock).toHaveBeenCalledTimes(2);
    await expect(
      readFile(join(tempDir, '.prompt-language', 'vars', 'senior_frame'), 'utf8'),
    ).resolves.toBe('{"objective":"ship"}');
  });
});
