import type http from 'node:http';
import Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import {
  chmodSync,
  existsSync,
  mkdirSync,
  mkdtempSync,
  promises as fsp,
  readFileSync,
  realpathSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join, resolve } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
  composeLiveInstructionPrompt,
  resolveGrantedCodexImagegenOverride,
  resolveCodexGeneratedImagesDir,
  resolveChatExtraAllowedDirs,
  resolveResearchCommandContract,
  startServer,
  validateCodexGeneratedImagesDir,
} from '../src/server.js';
import { skillCwdAliasSegment } from '../src/cwd-aliases.js';
import { getAgentDef } from '../src/agents.js';
import { readMemoryConfig, writeMemoryConfig } from '../src/memory.js';
import { upsertMessage } from '../src/db.js';
import { renderCodexImagegenOverride } from '../src/prompts/system.js';

const FAKE_VELA_FIXTURE = resolve(process.cwd(), 'tests', 'fixtures', 'fake-vela.mjs');

function symlinkDir(target: string, link: string): void {
  symlinkSync(target, link, process.platform === 'win32' ? 'junction' : 'dir');
}

async function withFakeAgent<T>(
  binName: string,
  script: string,
  run: () => Promise<T>,
): Promise<T> {
  const dir = await fsp.mkdtemp(join(tmpdir(), 'od-chat-route-bin-'));
  const oldPath = process.env.PATH;
  try {
    if (process.platform === 'win32') {
      const runner = join(dir, `${binName}-test-runner.cjs`);
      await fsp.writeFile(runner, script);
      await fsp.writeFile(
        join(dir, `${binName}.cmd`),
        `@echo off\r\nnode "${runner}" %*\r\n`,
      );
    } else {
      const bin = join(dir, binName);
      await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
      await fsp.chmod(bin, 0o755);
    }
    process.env.PATH = `${dir}${delimiter}${oldPath ?? ''}`;
    return await run();
  } finally {
    process.env.PATH = oldPath;
    await fsp.rm(dir, { recursive: true, force: true });
  }
}

describe('/api/chat', () => {
  let server: http.Server;
  let baseUrl: string;
  let originalMemoryConfig: Awaited<ReturnType<typeof readMemoryConfig>> | null = null;
  const originalPath = process.env.PATH;
  const originalAgentHome = process.env.OD_AGENT_HOME;
  const tempDirs: string[] = [];

  async function createPluginFixture(args: {
    pluginId: string;
    dirName: string;
    localSkillPath?: string;
  }): Promise<string> {
    const root = await fsp.mkdtemp(join(tmpdir(), 'od-plugin-fixture-'));
    tempDirs.push(root);
    const fixtureDir = resolve(root, args.dirName);
    const baseFixtureDir = resolve(
      process.cwd(),
      'tests',
      'fixtures',
      'plugin-fixtures',
      'sample-plugin',
    );
    await fsp.cp(baseFixtureDir, fixtureDir, { recursive: true });
    const manifestPath = resolve(fixtureDir, 'open-design.json');
    const manifest = JSON.parse(await fsp.readFile(manifestPath, 'utf8')) as {
      name: string;
      title: string;
      od?: { context?: { skills?: Array<{ ref?: string; path?: string }> } };
    };
    manifest.name = args.pluginId;
    manifest.title = args.pluginId;
    if (args.localSkillPath) {
      manifest.od ??= {};
      manifest.od.context ??= {};
      manifest.od.context.skills = [{ path: args.localSkillPath }];
    }
    await fsp.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
    return fixtureDir;
  }

  beforeAll(async () => {
    if (process.env.OD_DATA_DIR) {
      originalMemoryConfig = await readMemoryConfig(process.env.OD_DATA_DIR);
      await writeMemoryConfig(process.env.OD_DATA_DIR, {
        enabled: false,
        extraction: null,
      });
    }
    const started = await startServer({ port: 0, returnServer: true }) as {
      url: string;
      server: http.Server;
    };
    baseUrl = started.url;
    server = started.server;
  });

  afterEach(() => {
    if (originalPath == null) {
      delete process.env.PATH;
    } else {
      process.env.PATH = originalPath;
    }
    if (originalAgentHome == null) {
      delete process.env.OD_AGENT_HOME;
    } else {
      process.env.OD_AGENT_HOME = originalAgentHome;
    }
  });

  afterAll(async () => {
    for (const dir of tempDirs.splice(0)) {
      rmSync(dir, { recursive: true, force: true });
    }
    if (server) {
      await new Promise<void>((resolve) => server.close(() => resolve()));
    }
    if (process.env.OD_DATA_DIR && originalMemoryConfig) {
      await writeMemoryConfig(process.env.OD_DATA_DIR, {
        enabled: originalMemoryConfig.enabled,
        extraction: originalMemoryConfig.extraction,
      });
    }
  });

  it('does not reference an out-of-scope response while starting a run', async () => {
    process.env.PATH = '';
    const emptyAgentHome = mkdtempSync(join(tmpdir(), 'od-empty-agent-home-'));
    tempDirs.push(emptyAgentHome);
    process.env.OD_AGENT_HOME = emptyAgentHome;

    const response = await fetch(`${baseUrl}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        agentId: 'claude',
        message: 'hello',
      }),
    });
    const body = await response.text();

    expect(response.ok).toBe(true);
    expect(body).not.toContain('res is not defined');
    expect(body).toContain('AGENT_UNAVAILABLE');
  });

  it('marks json stream runs failed when an error frame exits with code 0', async () => {
    const conversationId = `conv-${randomUUID()}`;

    await withFakeAgent(
      'opencode',
      `
console.log(JSON.stringify({
  type: 'error',
  error: { message: 'model not found: fake-opencode-model' },
}));
process.exit(0);
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            conversationId,
            message: 'hello',
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('AGENT_EXECUTION_FAILED');
        expect(body).toContain('model not found: fake-opencode-model');
        expect(body).toContain('"status":"failed"');
        expect(body).not.toContain('"status":"succeeded"');

        const runsResponse = await fetch(
          `${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
        );
        const runsBody = (await runsResponse.json()) as {
          runs: Array<{ conversationId: string | null; status: string; exitCode: number | null }>;
        };

        expect(runsBody.runs).toHaveLength(1);
        expect(runsBody.runs[0]).toMatchObject({
          conversationId,
          status: 'failed',
          exitCode: 0,
        });
      },
    );
  });


  it('reuses an existing assistant message row instead of creating a duplicate when assistantMessageId is supplied', async () => {
    if (!process.env.OD_DATA_DIR) {
      throw new Error('OD_DATA_DIR is required for assistant message reuse tests');
    }
    const projectId = `proj-${randomUUID()}`;
    const assistantMessageId = `assistant-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: projectId, name: 'Assistant row reuse fixture' }),
    });
    expect(createProjectResponse.ok).toBe(true);

    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.ok).toBe(true);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    const dbFile = resolve(process.env.OD_DATA_DIR, 'app.sqlite');
    const sqlite = new Database(dbFile);
    try {
      upsertMessage(sqlite as never, conversationId!, {
        id: assistantMessageId,
        role: 'assistant',
        content: '',
        runStatus: 'failed',
        startedAt: Date.now() - 1_000,
        endedAt: Date.now() - 500,
      });
    } finally {
      sqlite.close();
    }

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: 'reused-assistant-row-ok' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            assistantMessageId,
            message: 'retry this turn',
          }),
        });
        const body = await response.text();
        expect(response.ok).toBe(true);
        expect(body).toContain('reused-assistant-row-ok');
      },
    );

    const verifyDb = new Database(dbFile, { readonly: true });
    try {
      const rows = verifyDb
        .prepare(`SELECT id, content, run_id FROM messages WHERE conversation_id = ? AND role = 'assistant'`)
        .all(conversationId) as Array<{ id: string; content: string; run_id: string | null }>;
      expect(rows.filter((row) => row.id === assistantMessageId)).toHaveLength(1);
      expect(rows.some((row) => row.id !== assistantMessageId && row.content.includes('reused-assistant-row-ok'))).toBe(false);
      const reused = rows.find((row) => row.id === assistantMessageId);
      expect(reused?.content).toContain('reused-assistant-row-ok');
    } finally {
      verifyDb.close();
    }
  });

  it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
    const conversationId = `conv-${randomUUID()}`;

    await withFakeAgent(
      'opencode',
      `
process.stderr.write('json-rpc id 4: opencode event stream: read opencode SSE: bufio.Scanner: token too long\\n');
process.exit(1);
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            conversationId,
            message: 'hello',
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('AGENT_EXECUTION_FAILED');
        expect(body).toContain('The run failed due to an unknown upstream streaming error. Please retry.');
        expect(body).toContain('event: stderr');
        expect(body).toContain('"status":"failed"');
      },
    );
  });

  it('retries transient AMR Link catalog failures before aborting startup', async () => {
    const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
    const previousLinkUrl = process.env.VELA_LINK_URL;
    const stateFile = join(tmpdir(), `od-amr-model-retry-${randomUUID()}.json`);
    try {
      process.env.VELA_RUNTIME_KEY = 'fake-runtime-key';
      process.env.VELA_LINK_URL = 'https://amr-link.open-design.ai/v1';

      await withFakeAgent(
        'vela',
        `
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
const { spawn } = require('node:child_process');
const fixture = ${JSON.stringify(FAKE_VELA_FIXTURE)};
const stateFile = ${JSON.stringify(stateFile)};
const args = process.argv.slice(2);
if (args[0] === 'model' && args[1] === 'list') {
  const state = existsSync(stateFile)
    ? JSON.parse(readFileSync(stateFile, 'utf8'))
    : { attempts: 0 };
  state.attempts += 1;
  writeFileSync(stateFile, JSON.stringify(state), 'utf8');
  if (state.attempts < 3) {
    process.stderr.write('Get "https://amr-link.open-design.ai/v1/models": context deadline exceeded\\n');
    process.exit(1);
  }
}
const child = spawn(process.execPath, [fixture, ...args], {
  stdio: 'inherit',
  env: process.env,
});
child.on('exit', (code, signal) => {
  if (signal) process.kill(process.pid, signal);
  process.exit(code ?? 0);
});
`,
        async () => {
          const response = await fetch(`${baseUrl}/api/chat`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'amr',
              message: 'hello',
              model: 'deepseek-v3.2',
            }),
          });
          const body = await response.text();

          expect(response.ok).toBe(true);
          expect(body).toContain('"type":"text_delta","delta":"Hello from fake "');
          expect(body).toContain('"type":"text_delta","delta":"vela."');
          expect(body).not.toContain('model_catalog_unavailable');
          const attempts = JSON.parse(readFileSync(stateFile, 'utf8')) as { attempts: number };
          expect(attempts.attempts).toBe(3);
        },
      );
    } finally {
      rmSync(stateFile, { force: true });
      if (previousRuntimeKey == null) delete process.env.VELA_RUNTIME_KEY;
      else process.env.VELA_RUNTIME_KEY = previousRuntimeKey;
      if (previousLinkUrl == null) delete process.env.VELA_LINK_URL;
      else process.env.VELA_LINK_URL = previousLinkUrl;
    }
  });

  it('allows plugin authoring to succeed when the requested generated-plugin artifacts exist before close', async () => {
    const projectId = `proj-plugin-authoring-success-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring artifact success fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
const fs = require('node:fs');
const path = require('node:path');
process.stdin.resume();
process.stdin.on('end', () => {
  const pluginDir = path.join(process.cwd(), 'generated-plugin');
  fs.mkdirSync(pluginDir, { recursive: true });
  fs.writeFileSync(path.join(pluginDir, 'open-design.json'), JSON.stringify({ name: 'generated-plugin' }, null, 2));
  fs.writeFileSync(path.join(pluginDir, 'SKILL.md'), '# Generated plugin\\n');
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范，再生成插件文件。' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('先读取文档规范，再生成插件文件');
        expect(statusBody.status).toBe('succeeded');

        const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
        expect(filesResponse.status).toBe(200);
        const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
        expect(filesBody.files.some((file) => file.name === 'generated-plugin/open-design.json')).toBe(true);
        expect(filesBody.files.some((file) => file.name === 'generated-plugin/SKILL.md')).toBe(true);
      },
    );
  });

  it('does not report plugin authoring as succeeded when the agent only emits planning text without artifacts', async () => {
    const projectId = `proj-plugin-authoring-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring completion fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: '我来帮你创建一个通用的 Open Design 插件脚手架。先读取文档规范，再生成插件文件。' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '请创建一个可刷新、可审计、由 API 驱动的 Open Design 插件脚手架。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const {
          runId,
          pluginId,
          appliedPluginSnapshotId,
        } = await createResponse.json() as {
          runId: string;
          pluginId: string | null;
          appliedPluginSnapshotId: string | null;
        };
        expect(pluginId).toBe('od-plugin-authoring');
        expect(appliedPluginSnapshotId).toBeTruthy();

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('先读取文档规范，再生成插件文件');
        expect(statusBody.status).not.toBe('succeeded');

        const filesResponse = await fetch(`${baseUrl}/api/projects/${projectId}/files`);
        expect(filesResponse.status).toBe(200);
        const filesBody = await filesResponse.json() as { files: Array<{ name: string }> };
        expect(filesBody.files.some((file) => file.name.startsWith('generated-plugin/'))).toBe(false);
      },
    );
  });
  it('does not fail plugin authoring when the turn-1 reply is a clarifying question-form awaiting the brief', async () => {
    // The `od-plugin-authoring` plugin's turn-1 flow is to emit a
    // `<question-form>` collecting the plugin brief, then STOP and wait for
    // the user to answer — artifacts only land on the follow-up turn. The
    // missing-artifacts guard must not treat that expected pause as a
    // failure (regression: "Plugin authoring ended before generating the
    // required generated-plugin artifacts.").
    const projectId = `proj-plugin-authoring-question-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring question-form fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题再开始搭建。\\n<question-form id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</question-form>' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '帮我做个插件。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('<question-form');
        expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
        expect(statusBody.status).toBe('succeeded');
      },
    );
  });
  it('does not fail plugin authoring when the clarifying form uses the <ask-question> alias', async () => {
    // `<ask-question>` is the alias the web form parser accepts alongside
    // the canonical `<question-form>` (apps/web/src/artifacts/question-form.ts).
    // Models sometimes drift to it; the UI still renders a valid brief form,
    // so the daemon's missing-artifacts guard must recognize the alias too —
    // otherwise the same "ended before generating…" regression returns for a
    // supported clarification shape.
    const projectId = `proj-plugin-authoring-alias-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring ask-question alias fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题再开始搭建。\\n<ask-question id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</ask-question>' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '帮我做个插件。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('<ask-question');
        expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
        expect(statusBody.status).toBe('succeeded');
      },
    );
  });
  it('still fails plugin authoring when a question-form tag wraps a non-renderable (non-JSON) body', async () => {
    // The clarification carve-out must match the web parser's renderable-form
    // contract (JSON body with a `questions` array), not just the opening
    // tag. A `<question-form>` whose body is not valid form JSON renders as
    // raw prose in the UI — no usable brief card — so suppressing the
    // missing-artifacts failure for it would turn a hard failure into a false
    // success. This pins that the guard stays gated on a renderable body.
    const projectId = `proj-plugin-authoring-badform-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring malformed-form fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: '先确认几个问题。\\n<question-form id="discovery">\\nWhat should it do? (free text)\\n</question-form>' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '帮我做个插件。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('ended before generating the required generated-plugin artifacts');
        expect(statusBody.status).not.toBe('succeeded');
      },
    );
  });
  it('does not fail plugin authoring when a valid form follows a Unicode preamble that expands under toLowerCase', async () => {
    // The mirrored close-tag scan must stay in the original-string coordinate
    // space. Some code points expand under toLowerCase ("İ" -> "i̇"), so
    // lowercasing the whole buffer before indexing would desync the close-tag
    // offset and corrupt the JSON body slice, failing a valid form. The
    // preamble here contains "İ" before a well-formed form block.
    const projectId = `proj-plugin-authoring-unicode-${randomUUID()}`;

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin authoring unicode-preamble fixture',
        skillId: null,
        designSystemId: null,
      }),
    });
    expect(createProjectResponse.status).toBe(200);
    const conversationsResponse = await fetch(`${baseUrl}/api/projects/${projectId}/conversations`);
    expect(conversationsResponse.status).toBe(200);
    const conversationsBody = await conversationsResponse.json() as {
      conversations: Array<{ id: string }>;
    };
    const conversationId = conversationsBody.conversations[0]?.id;
    expect(conversationId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: 'İstanbul brief — 先确认几个问题。\\n<ask-question id="discovery" title="Plugin brief">\\n{"questions":[{"id":"purpose","label":"What should it do?","type":"text"}]}\\n</ask-question>' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            conversationId,
            pluginId: 'od-plugin-authoring',
            message: '帮我做个插件。',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`);
        const eventsBody = await readSseUntil(eventsResponse, 'event: final');
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).not.toContain('ended before generating the required generated-plugin artifacts');
        expect(statusBody.status).toBe('succeeded');
      },
    );
  });
  it('closes the # Instructions block with an explicit "do not echo" guard so models do not parrot the prompt back', async () => {
    // claude-opus-4-7 (and a few other instruction-tuned models) start
    // their reply by echoing the # Instructions block verbatim, which
    // shows up to users as the system prompt leading the visible
    // answer. server.ts:9934 closes every Instructions block with a
    // trailing guard line; this test pins the literal so a future
    // refactor cannot silently drop it.
    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('Do not quote, restate, or echo the # Instructions block above')
      ? 'has-echo-guard'
      : 'missing-echo-guard',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            message: 'hello',
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('has-echo-guard');
        expect(body).not.toContain('missing-echo-guard');
      },
    );
  });

  it('injects @-mention skillIds into the composed system prompt', async () => {
    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
    prompt.includes('# FAQ Page Skill') ? 'has-faq-skill-body' : 'missing-faq-skill-body',
    prompt.includes('category filtering') ? 'has-faq-skill-content' : 'missing-faq-skill-content',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            message: 'build an faq page',
            skillIds: ['faq-page'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('has-composed-skill-header');
        expect(body).toContain('has-faq-skill-body');
        expect(body).toContain('has-faq-skill-content');
        expect(body).not.toContain('missing-composed-skill-header');
        expect(body).not.toContain('missing-faq-skill-body');
        expect(body).not.toContain('missing-faq-skill-content');
      },
    );
  });

  it('stages ad-hoc skill side files into the project cwd', async () => {
    const projectId = `project-${randomUUID()}`;
    const stagedRelativePath = `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`;
    const expectedChecklist = await fsp.readFile(
      resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
      'utf8',
    );

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Ad hoc staged skill project',
      }),
    });

    expect(createProjectResponse.ok).toBe(true);

    const fakeAgentScript = `
const fs = require('node:fs');
const stagedChecklist = fs.readFileSync(${JSON.stringify(stagedRelativePath)}, 'utf8');
if (stagedChecklist !== ${JSON.stringify(expectedChecklist)}) {
  console.error('staged-skill-side-files-mismatch');
  process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: 'staged-skill-side-files-before-spawn' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`;

    await withFakeAgent(
      'opencode',
      fakeAgentScript,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            message: 'draft the release notes',
            skillIds: ['release-notes-one-pager'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('staged-skill-side-files-before-spawn');
      },
    );

    const stagedFileResponse = await fetch(
      `${baseUrl}/api/projects/${projectId}/raw/${stagedRelativePath}`,
    );
    const stagedFileBody = await stagedFileResponse.text();

    expect(stagedFileResponse.ok).toBe(true);
    expect(stagedFileBody).toBe(expectedChecklist);
  });

  it('stages side files for every composed skill into the project cwd', async () => {
    const projectId = `project-${randomUUID()}`;
    const stagedPaths = [
      `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager'))}/references/checklist.md`,
      `.od-skills/${skillCwdAliasSegment(resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template'))}/references/checklist.md`,
    ] as const;
    const expectedBodies = await Promise.all(
      [
        resolve(process.cwd(), '..', '..', 'skills', 'release-notes-one-pager', 'references', 'checklist.md'),
        resolve(process.cwd(), '..', '..', 'skills', 'swiss-creative-mode-template', 'references', 'checklist.md'),
      ].map((file) => fsp.readFile(file, 'utf8')),
    );

    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Multi staged skill project',
      }),
    });

    expect(createProjectResponse.ok).toBe(true);

    const fakeAgentScript = `
const fs = require('node:fs');
const stagedBodies = [
  fs.readFileSync(${JSON.stringify(stagedPaths[0])}, 'utf8'),
  fs.readFileSync(${JSON.stringify(stagedPaths[1])}, 'utf8'),
];
const expectedBodies = ${JSON.stringify(expectedBodies)};
if (JSON.stringify(stagedBodies) !== JSON.stringify(expectedBodies)) {
  console.error('multi-staged-skill-side-files-mismatch');
  process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: 'multi-staged-skill-side-files-before-spawn' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`;

    await withFakeAgent(
      'opencode',
      fakeAgentScript,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            message: 'compose multiple skills',
            skillIds: ['release-notes-one-pager', 'swiss-creative-mode-template'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('multi-staged-skill-side-files-before-spawn');
      },
    );
  });

  it('propagates the composed skill mode for ad-hoc-only deck skills', async () => {
    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-deck-skill-header' : 'missing-deck-skill-header',
    prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'has-deck-framework' : 'missing-deck-framework',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            message: 'build an editorial brand deck',
            skillIds: ['open-design-landing-deck'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('has-deck-skill-header');
        expect(body).toContain('has-deck-framework');
        expect(body).not.toContain('missing-deck-skill-header');
        expect(body).not.toContain('missing-deck-framework');
      },
    );
  });

  it('preserves a persisted media skill as the primary surface over a composed deck mention', async () => {
    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('# imagegen') ? 'has-base-image-skill-body' : 'missing-base-image-skill-body',
    prompt.includes('## Composed skill — open-design-landing-deck') ? 'has-composed-deck-skill-header' : 'missing-composed-deck-skill-header',
    prompt.includes('## Media generation contract (load-bearing — overrides softer wording above)') ? 'has-image-contract' : 'missing-image-contract',
    prompt.includes('# Slide deck — fixed framework (this is non-negotiable for deck mode)') ? 'unexpected-deck-framework' : 'kept-deck-framework-out',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            message: 'generate an image while also referencing a deck template',
            skillId: 'imagegen',
            skillIds: ['open-design-landing-deck'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('has-base-image-skill-body');
        expect(body).toContain('has-composed-deck-skill-header');
        expect(body).toContain('has-image-contract');
        expect(body).toContain('kept-deck-framework-out');
        expect(body).not.toContain('missing-base-image-skill-body');
        expect(body).not.toContain('missing-composed-deck-skill-header');
        expect(body).not.toContain('missing-image-contract');
        expect(body).not.toContain('unexpected-deck-framework');
      },
    );
  });

  it('honors mediaExecution on legacy chat requests', async () => {
    const conversationId = `conv-${randomUUID()}`;

    const response = await fetch(`${baseUrl}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        agentId: `missing-agent-${randomUUID()}`,
        conversationId,
        message: 'plan an image without using OD media',
        skillId: 'imagegen',
        mediaExecution: {
          mode: 'disabled',
          allowedSurfaces: ['image'],
        },
      }),
    });
    const body = await response.text();

    expect(response.ok).toBe(true);
    expect(body).toContain('unknown agent');

    const runsResponse = await fetch(
      `${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
    );
    const runsBody = await runsResponse.json() as {
      runs: Array<{ mediaExecution?: { mode?: string; allowedSurfaces?: string[] } }>;
    };
    expect(runsBody.runs).toHaveLength(1);
    expect(runsBody.runs[0]?.mediaExecution).toMatchObject({
      mode: 'disabled',
      allowedSurfaces: ['image'],
    });
  });

  it('rejects invalid mediaExecution on legacy chat requests', async () => {
    const conversationId = `conv-${randomUUID()}`;
    const response = await fetch(`${baseUrl}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        agentId: 'opencode',
        conversationId,
        message: 'generate an image',
        mediaExecution: { mode: 'provider-router' },
      }),
    });
    const body = await response.text();

    expect(response.status).toBe(400);
    expect(body).toContain('mediaExecution.mode');

    const runsResponse = await fetch(
      `${baseUrl}/api/runs?conversationId=${encodeURIComponent(conversationId)}`,
    );
    const runsBody = await runsResponse.json() as { runs: unknown[] };
    expect(runsBody.runs).toEqual([]);
  });

  it('propagates ad-hoc skill critique policy into the chat resolver', async () => {
    if (!process.env.OD_DATA_DIR) {
      throw new Error('OD_DATA_DIR is required for user skill critique-policy tests');
    }

    const skillId = `critique-opt-out-${randomUUID()}`;
    const skillDir = resolve(process.env.OD_DATA_DIR, 'skills', skillId);
    const originalCritiqueEnabled = process.env.OD_CRITIQUE_ENABLED;

    await fsp.mkdir(skillDir, { recursive: true });
    await fsp.writeFile(
      resolve(skillDir, 'SKILL.md'),
      `---
name: ${skillId}
description: Ad-hoc critique opt-out regression fixture.
od:
  critique:
    policy: opt-out
---

# Critique opt-out fixture

This skill should suppress critique when selected through skillIds.
`,
      'utf8',
    );

    process.env.OD_CRITIQUE_ENABLED = 'true';

    try {
      await withFakeAgent(
        'opencode',
        `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('## Composed skill — ${skillId}') ? 'has-opt-out-skill-header' : 'missing-opt-out-skill-header',
    prompt.includes('<CRITIQUE_RUN') ? 'unexpected-critique-panel' : 'critique-panel-disabled-by-skill-policy',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
        async () => {
          const response = await fetch(`${baseUrl}/api/chat`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              designSystemId: 'default',
              message: 'draft an opt-out skill artifact',
              skillIds: [skillId],
            }),
          });
          const body = await response.text();

          expect(response.ok).toBe(true);
          expect(body).toContain('has-opt-out-skill-header');
          expect(body).toContain('critique-panel-disabled-by-skill-policy');
          expect(body).not.toContain('missing-opt-out-skill-header');
          expect(body).not.toContain('unexpected-critique-panel');
        },
      );
    } finally {
      if (originalCritiqueEnabled == null) {
        delete process.env.OD_CRITIQUE_ENABLED;
      } else {
        process.env.OD_CRITIQUE_ENABLED = originalCritiqueEnabled;
      }
      await fsp.rm(skillDir, { recursive: true, force: true });
    }
  });

  it('preserves plugin-local and composed @-mention skills in plugin-bound runs', async () => {
    const pluginId = `plugin-local-${randomUUID()}`;
    const pluginFixtureDir = await createPluginFixture({
      pluginId,
      dirName: `plugin-local-${randomUUID()}`,
      localSkillPath: './SKILL.md',
    });
    const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
      body: JSON.stringify({ source: pluginFixtureDir }),
    });
    const installBody = await installResponse.text();

    expect(installResponse.status).toBe(200);
    expect(installBody).toContain(`"id":"${pluginId}"`);

    const projectId = `project-${randomUUID()}`;
    const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: projectId,
        name: 'Plugin-bound skill composition project',
        pluginId,
        pluginInputs: { topic: 'agentic design' },
      }),
    });
    const createProjectBody = await createProjectResponse.json() as {
      appliedPluginSnapshotId?: string;
    };

    expect(createProjectResponse.ok).toBe(true);
    expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();

    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const checks = [
    prompt.includes('# Sample Plugin') ? 'has-plugin-skill-body' : 'missing-plugin-skill-body',
    prompt.includes('## Composed skill — faq-page') ? 'has-composed-skill-header' : 'missing-composed-skill-header',
    prompt.includes('# FAQ Page Skill') ? 'has-composed-skill-body' : 'missing-composed-skill-body',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            projectId,
            message: 'build a plugin-backed faq page',
            appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
            skillIds: ['faq-page'],
          }),
        });
        const createRunBody = await createRunResponse.json() as { runId: string };

        expect(createRunResponse.status).toBe(202);

        const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
        const body = await readSseUntil(eventsResponse, 'event: final');

        expect(body).toContain('has-plugin-skill-body');
        expect(body).toContain('has-composed-skill-header');
        expect(body).toContain('has-composed-skill-body');
        expect(body).not.toContain('missing-plugin-skill-body');
        expect(body).not.toContain('missing-composed-skill-header');
        expect(body).not.toContain('missing-composed-skill-body');
      },
    );
  });

  it('stages colliding plugin and composed skill dirs under distinct aliases', async () => {
    if (!process.env.OD_DATA_DIR) {
      throw new Error('OD_DATA_DIR is required for colliding skill-dir staging tests');
    }

    const pluginId = `plugin-collision-${randomUUID()}`;
    const pluginFixtureDir = await createPluginFixture({
      pluginId,
      dirName: 'sample-plugin',
      localSkillPath: './SKILL.md',
    });
    const installResponse = await fetch(`${baseUrl}/api/plugins/install`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', accept: 'text/event-stream' },
      body: JSON.stringify({ source: pluginFixtureDir }),
    });
    const installBody = await installResponse.text();

    expect(installResponse.status).toBe(200);
    expect(installBody).toContain(`"id":"${pluginId}"`);

    const projectId = `project-${randomUUID()}`;
    const userSkillDir = resolve(process.env.OD_DATA_DIR, 'skills', 'sample-plugin');
    const userChecklist = 'user-skill-checklist';
    const userAlias = skillCwdAliasSegment(userSkillDir);

    await fsp.mkdir(resolve(userSkillDir, 'references'), { recursive: true });
    await fsp.writeFile(
      resolve(userSkillDir, 'SKILL.md'),
      '# Sample-plugin side-file fixture\n\nRead references/checklist.md before drafting.',
      'utf8',
    );
    await fsp.writeFile(resolve(userSkillDir, 'references', 'checklist.md'), userChecklist, 'utf8');

    try {
      const createProjectResponse = await fetch(`${baseUrl}/api/projects`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          id: projectId,
          name: 'Colliding skill-dir project',
          pluginId,
          pluginInputs: { topic: 'agentic design' },
        }),
      });
      const createProjectBody = await createProjectResponse.json() as {
        appliedPluginSnapshotId?: string;
      };
      const installedPluginResponse = await fetch(`${baseUrl}/api/plugins/${pluginId}`);
      const installedPluginBody = await installedPluginResponse.json() as { fsPath: string };
      const pluginAlias = skillCwdAliasSegment(installedPluginBody.fsPath);

      expect(createProjectResponse.ok).toBe(true);
      expect(installedPluginResponse.ok).toBe(true);
      expect(createProjectBody.appliedPluginSnapshotId).toBeTruthy();
      expect(pluginAlias).not.toBe(userAlias);

      await withFakeAgent(
        'opencode',
        `
const fs = require('node:fs');
const pluginSkill = fs.readFileSync(${JSON.stringify(`.od-skills/${pluginAlias}/SKILL.md`)}, 'utf8');
const userChecklist = fs.readFileSync(${JSON.stringify(`.od-skills/${userAlias}/references/checklist.md`)}, 'utf8');
if (!pluginSkill.includes('# Sample Plugin')) {
  console.error('plugin-skill-stage-missing');
  process.exit(1);
}
if (userChecklist !== ${JSON.stringify(userChecklist)}) {
  console.error('colliding-skill-stage-mismatch');
  process.exit(1);
}
process.stdin.resume();
process.stdin.on('end', () => {
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: 'colliding-skill-dirs-staged' } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
        async () => {
          const createRunResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              projectId,
              message: 'use both plugin and user skill side files',
              appliedPluginSnapshotId: createProjectBody.appliedPluginSnapshotId,
              skillIds: ['sample-plugin'],
            }),
          });
          const createRunBody = await createRunResponse.json() as { runId: string };

          expect(createRunResponse.status).toBe(202);

          const eventsResponse = await fetch(`${baseUrl}/api/runs/${createRunBody.runId}/events`);
          const body = await readSseUntil(eventsResponse, 'event: final');

          expect(body).toContain('colliding-skill-dirs-staged');
        },
      );
    } finally {
      await fsp.rm(userSkillDir, { recursive: true, force: true });
    }
  });

  it('canonicalizes aliased skill ids before deduping composed skills', async () => {
    await withFakeAgent(
      'opencode',
      `
let prompt = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
  prompt += chunk;
});
process.stdin.on('end', () => {
  const hasDuplicateComposedAlias = prompt.includes('## Composed skill — open-design-landing');
  const checks = [
    hasDuplicateComposedAlias ? 'duplicate-alias-composed-skill' : 'deduped-alias-composed-skill',
    prompt.includes('# open-design-landing') ? 'has-base-alias-skill-body' : 'missing-base-alias-skill-body',
  ];
  console.log(JSON.stringify({ type: 'step_start' }));
  console.log(JSON.stringify({ type: 'text', part: { text: checks.join('\\n') } }));
  console.log(JSON.stringify({ type: 'step_finish', part: { tokens: { input: 1, output: 1 } } }));
  process.exit(0);
});
`,
      async () => {
        const response = await fetch(`${baseUrl}/api/chat`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'opencode',
            message: 'build the Open Design landing page',
            skillId: 'editorial-collage',
            skillIds: ['open-design-landing'],
          }),
        });
        const body = await response.text();

        expect(response.ok).toBe(true);
        expect(body).toContain('deduped-alias-composed-skill');
        expect(body).toContain('has-base-alias-skill-body');
        expect(body).not.toContain('duplicate-alias-composed-skill');
        expect(body).not.toContain('missing-base-alias-skill-body');
      },
    );
  });

  it('classifies Cursor Agent authentication stderr as a typed run error', async () => {
    await withFakeAgent(
      'cursor-agent',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('2026.05.07-test');
  process.exit(0);
}
if (args[0] === 'models') {
  console.log('auto');
  process.exit(0);
}
console.error("Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.");
process.exit(1);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'cursor-agent',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
        expect(eventsBody).toContain('cursor-agent login');
        expect(eventsBody).toContain('cursor-agent status');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('classifies Cursor Agent Not logged in stderr as a typed run error', async () => {
    await withFakeAgent(
      'cursor-agent',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('2026.05.07-test');
  process.exit(0);
}
if (args[0] === 'models') {
  console.log('auto');
  process.exit(0);
}
console.error('Not logged in');
process.exit(1);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'cursor-agent',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
        expect(eventsBody).toContain('cursor-agent login');
        expect(eventsBody).toContain('cursor-agent status');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('classifies Cursor Agent stdout auth text as a typed run error', async () => {
    await withFakeAgent(
      'cursor-agent',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('2026.05.07-test');
  process.exit(0);
}
if (args[0] === 'models') {
  console.log('auto');
  process.exit(0);
}
console.log('ConnectError: [unauthenticated]');
process.exit(1);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'cursor-agent',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
        expect(eventsBody).toContain('cursor-agent login');
        expect(eventsBody).toContain('cursor-agent status');
        expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('classifies Cursor Agent stdout error payloads as typed auth failures', async () => {
    const cursorErrorLine = JSON.stringify({
      type: 'error',
      message: 'Error: [unauthenticated] Error',
    });
    await withFakeAgent(
      'cursor-agent',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('2026.05.07-test');
  process.exit(0);
}
if (args[0] === 'models') {
  console.log('auto');
  process.exit(0);
}
console.log(${JSON.stringify(cursorErrorLine)});
process.exit(1);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'cursor-agent',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
        expect(eventsBody).toContain('cursor-agent login');
        expect(eventsBody).toContain('cursor-agent status');
        expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('classifies DeepSeek TUI config guidance as typed auth failures', async () => {
    await withFakeAgent(
      'deepseek',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('deepseek 0.3.0-test');
  process.exit(0);
}
console.error('KEY=<your-key> deepseek --api-key <your-key>');
console.error('api_key = "<your-key>" in ~/.deepseek/config.toml');
process.exit(1);
`,
      async () => {
        const deepseek = getAgentDef('deepseek');
        expect(deepseek).toBeDefined();
        const originalBudget = deepseek?.maxPromptArgBytes;
        if (deepseek) deepseek.maxPromptArgBytes = 200_000;
        try {
          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'deepseek',
              message: 'hello',
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };

          const eventsController = new AbortController();
          const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
            signal: eventsController.signal,
          });
          const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
          eventsController.abort();
          const statusBody = await waitForRunStatus(baseUrl, runId);

          expect(eventsBody).toContain('event: error');
          expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
          expect(eventsBody).toContain('~/.deepseek/config.toml');
          expect(eventsBody).toContain('DEEPSEEK_API_KEY');
          expect(eventsBody).not.toContain('cursor-agent login');
          expect(eventsBody).not.toContain('AGENT_EXECUTION_FAILED');
          expect(statusBody.status).toBe('failed');
        } finally {
          if (deepseek) {
            if (originalBudget === undefined) {
              delete deepseek.maxPromptArgBytes;
            } else {
              deepseek.maxPromptArgBytes = originalBudget;
            }
          }
        }
      },
    );
  });

  it('suppresses Antigravity auth stdout and emits AGENT_AUTH_REQUIRED without an event: stdout delta', async () => {
    await withFakeAgent(
      'agy',
      `
const args = process.argv.slice(2);
if (args[0] === '--version') {
  console.log('1.107.0-test');
  process.exit(0);
}
// Simulate agy chat - printing the OAuth prompt and exiting 0
process.stdout.write('Authentication required. Please visit the URL to log in: https://accounts.google.com/o/oauth2/auth?client_id=12345&redirect_uri=antigravity-redirect\\n');
process.stdout.write('Waiting for authentication (timeout 30s)...\\n');
process.stdout.write('Error: authentication timed out.\\n');
process.exit(0);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'antigravity',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'AGENT_AUTH_REQUIRED');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('AGENT_AUTH_REQUIRED');
        expect(eventsBody).not.toContain('event: stdout');
        expect(eventsBody).not.toContain('accounts.google.com');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('surfaces Qoder assistant error records through the SSE error channel', async () => {
    const qoderErrorLine = JSON.stringify({
      type: 'assistant',
      message: { content: [] },
      error: { message: 'Qoder authentication expired' },
    });
    await withFakeAgent(
      'qodercli',
      `console.log(${JSON.stringify(qoderErrorLine)});\nprocess.exit(0);\n`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'qoder',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'event: error');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('Qoder authentication expired');
        expect(eventsBody).not.toContain('event: agent\\ndata: {"type":"error"');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('fails Qoder runs when the result reports is_error with exit code 0', async () => {
    const qoderResultLine = JSON.stringify({
      type: 'result',
      subtype: 'error',
      duration_ms: 17,
      is_error: true,
      stop_reason: 'tool_use_failed',
      total_cost_usd: 0,
      usage: {
        input_tokens: 3,
        output_tokens: 1,
      },
    });
    await withFakeAgent(
      'qodercli',
      `console.log(${JSON.stringify(qoderResultLine)});\nprocess.exit(0);\n`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'qoder',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'event: error');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: agent');
        expect(eventsBody).toContain('"type":"usage"');
        expect(eventsBody).toContain('"isError":true');
        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('Qoder run failed: tool_use_failed');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('fails stalled json-stream runs after the inactivity timeout elapses', async () => {
    const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
    process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '500';
    try {
      await withFakeAgent(
        'opencode',
        `
console.log(JSON.stringify({ type: 'step_start' }));
process.on('SIGTERM', () => process.exit(143));
setInterval(() => {}, 1000);
`,
        async () => {
          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              message: 'hello',
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };

          const eventsController = new AbortController();
          const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
            signal: eventsController.signal,
          });
          const eventsBody = await readSseUntil(eventsResponse, 'event: error');
          eventsController.abort();
          const statusBody = await waitForRunStatus(baseUrl, runId);

          expect(eventsBody).toContain('event: error');
          expect(eventsBody).toContain('Agent stalled without emitting any new output');
          expect(eventsBody).toContain('Phase details: spawned agent opencode;');
          expect(eventsBody).not.toContain('spawned agent binary');
          expect(eventsBody).toMatch(/stdout arrived: (yes|no)/);
          expect(statusBody.status).toBe('failed');
        },
      );
    } finally {
      if (previous == null) {
        delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
      } else {
        process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
      }
    }
  });

  it('keeps Claude stream runs alive while structured output is still flowing', async () => {
    const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
    process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '3000';
    try {
      await withFakeAgent(
        'claude',
        `
const lines = [
  JSON.stringify({ type: 'stream_event', event: { type: 'message_start', message: { id: 'msg-1' }, ttft_ms: 10 } }),
  JSON.stringify({ type: 'stream_event', event: { type: 'content_block_start', index: 0, content_block: { type: 'text' } } }),
  JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hello ' } } }),
  JSON.stringify({ type: 'stream_event', event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'world' } } }),
  JSON.stringify({ type: 'stream_event', event: { type: 'content_block_stop', index: 0 } }),
  JSON.stringify({ type: 'result', usage: { input_tokens: 1, output_tokens: 2 }, duration_ms: 700, stop_reason: 'end_turn' }),
];
let index = 0;
console.log(lines[index++]);
const timer = setInterval(() => {
  if (index >= lines.length) {
    clearInterval(timer);
    process.exit(0);
    return;
  }
  console.log(lines[index++]);
}, 750);
`,
        async () => {
          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'claude',
              message: 'hello',
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };

          const statusBody = await waitForRunStatus(baseUrl, runId);
          expect(statusBody.status).toBe('succeeded');
        },
      );
    } finally {
      if (previous == null) {
        delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
      } else {
        process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
      }
    }
  });

  it('surfaces Claude auth diagnostics through the SSE error channel', async () => {
    await withFakeAgent(
      'claude',
      `
console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 }));
process.exit(1);
`,
      async () => {
        const createResponse = await fetch(`${baseUrl}/api/runs`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            agentId: 'claude',
            message: 'hello',
          }),
        });
        expect(createResponse.status).toBe(202);
        const { runId } = await createResponse.json() as { runId: string };

        const eventsController = new AbortController();
        const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
          signal: eventsController.signal,
        });
        const eventsBody = await readSseUntil(eventsResponse, 'event: error');
        eventsController.abort();
        const statusBody = await waitForRunStatus(baseUrl, runId);

        expect(eventsBody).toContain('event: error');
        expect(eventsBody).toContain('/login');
        expect(eventsBody).toContain('CLAUDE_CONFIG_DIR');
        expect(statusBody.status).toBe('failed');
      },
    );
  });

  it('caps oversized inactivity overrides so Node does not fire the timer immediately', async () => {
    const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
    process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '10000000000';
    try {
      await withFakeAgent(
        'opencode',
        `
setTimeout(() => {
  console.log(JSON.stringify({ type: 'text', part: { text: 'done' } }));
  process.exit(0);
}, 50);
`,
        async () => {
          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              message: 'hello',
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };

          const statusBody = await waitForRunStatus(baseUrl, runId);
          expect(statusBody.status).toBe('succeeded');
        },
      );
    } finally {
      if (previous == null) {
        delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
      } else {
        process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
      }
    }
  });

  it('marks stalled runs failed even when the child ignores SIGTERM', async () => {
    const previous = process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
    process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = '500';
    try {
      await withFakeAgent(
        'opencode',
        `
console.log(JSON.stringify({ type: 'step_start' }));
process.on('SIGTERM', () => {});
setInterval(() => {}, 1000);
`,
        async () => {
          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              message: 'hello',
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };

          const eventsController = new AbortController();
          const eventsResponse = await fetch(`${baseUrl}/api/runs/${runId}/events`, {
            signal: eventsController.signal,
          });
          const eventsBody = await readSseUntil(eventsResponse, 'event: error');
          eventsController.abort();
          const statusBody = await waitForRunStatus(baseUrl, runId);

          expect(eventsBody).toContain('Agent stalled without emitting any new output');
          expect(statusBody.status).toBe('failed');
        },
      );
    } finally {
      if (previous == null) {
        delete process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS;
      } else {
        process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS = previous;
      }
    }
  });

  it('marks submitted discovery form answers as the active turn before the transcript', async () => {
    const captureDir = mkdtempSync(join(tmpdir(), 'od-form-answer-prompt-'));
    tempDirs.push(captureDir);
    const capturePath = join(captureDir, 'prompt.txt');
    const previousCapturePath = process.env.OD_CAPTURE_PROMPT_PATH;
    process.env.OD_CAPTURE_PROMPT_PATH = capturePath;
    try {
      await withFakeAgent(
        'opencode',
        `
const fs = require('node:fs');
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
  fs.writeFileSync(process.env.OD_CAPTURE_PROMPT_PATH, input, 'utf8');
  console.log(JSON.stringify({ type: 'text', part: { text: 'building now' } }));
});
`,
        async () => {
          const formAnswers = [
            '[form answers — discovery]',
            '- output: Dashboard / tool UI',
            '- brand: Pick a direction for me [value: pick_direction]',
          ].join('\n');
          const transcript = [
            '## user',
            'Design a metrics dashboard.',
            '',
            '## assistant',
            '<question-form id="discovery" title="Quick brief — 30 seconds"></question-form>',
            '',
            '## user',
            formAnswers,
          ].join('\n');

          const createResponse = await fetch(`${baseUrl}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              agentId: 'opencode',
              message: transcript,
              currentPrompt: formAnswers,
            }),
          });
          expect(createResponse.status).toBe(202);
          const { runId } = await createResponse.json() as { runId: string };
          const statusBody = await waitForRunStatus(baseUrl, runId);

          expect(statusBody.status).toBe('succeeded');
          expect(existsSync(capturePath)).toBe(true);
          const prompt = readFileSync(capturePath, 'utf8');
          const transitionIdx = prompt.indexOf('## Latest user turn - form answers submitted');
          const transcriptIdx = prompt.indexOf('## Full conversation transcript');
          expect(transitionIdx).toBeGreaterThan(-1);
          expect(transcriptIdx).toBeGreaterThan(transitionIdx);
          expect(prompt).toContain('The user has answered the discovery form. Do not emit another discovery form.');
          expect(prompt).toContain('Continue with RULE 2 / RULE 3 now.');
          expect(prompt).toContain(formAnswers);
        },
      );
    } finally {
      if (previousCapturePath == null) {
        delete process.env.OD_CAPTURE_PROMPT_PATH;
      } else {
        process.env.OD_CAPTURE_PROMPT_PATH = previousCapturePath;
      }
    }
  });
});

describe('daemon run creation during shutdown', () => {
  it('rejects new run creation while shutdown cleanup is still in flight', async () => {
    const previousGrace = process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
    process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = '100';
    const started = await startServer({ port: 0, returnServer: true }) as {
      url: string;
      server: http.Server;
      shutdown: () => Promise<void>;
    };
    try {
      await withFakeAgent(
        'opencode',
        `
process.on('SIGTERM', () => {});
setInterval(() => {}, 1000);
`,
        async () => {
          const activeResponse = await fetch(`${started.url}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ agentId: 'opencode', message: 'hello' }),
          });
          expect(activeResponse.status).toBe(202);
          const { runId } = await activeResponse.json() as { runId: string };
          await waitForRunStatus(started.url, runId, (status) => status === 'running');

          const shutdownPromise = started.shutdown();

          const runResponse = await fetch(`${started.url}/api/runs`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ agentId: 'opencode', message: 'late run' }),
          });
          const chatResponse = await fetch(`${started.url}/api/chat`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ agentId: 'opencode', message: 'late chat' }),
          });

          expect(runResponse.status).toBe(503);
          expect(chatResponse.status).toBe(503);
          await shutdownPromise;
        },
      );
    } finally {
      if (previousGrace == null) {
        delete process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS;
      } else {
        process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS = previousGrace;
      }
      await new Promise<void>((resolve) => started.server.close(() => resolve()));
    }
  });
});

async function readSseUntil(response: Response, marker: string): Promise<string> {
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let body = '';
  for (let attempt = 0; attempt < 20; attempt += 1) {
    const { done, value } = await reader.read();
    if (done) return body;
    body += decoder.decode(value, { stream: true });
    if (body.includes(marker)) return body;
  }
  return body;
}

async function waitForRunStatus(
  baseUrl: string,
  runId: string,
  done: (status: string) => boolean = (status) => status !== 'queued' && status !== 'running',
): Promise<{ status: string }> {
  let lastStatus = 'unknown';
  for (let attempt = 0; attempt < 500; attempt += 1) {
    const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
    const statusBody = await statusResponse.json() as { status: string };
    lastStatus = statusBody.status;
    if (done(statusBody.status)) return statusBody;
    await new Promise((resolve) => setTimeout(resolve, 25));
  }
  throw new Error(`run did not reach expected status; last status: ${lastStatus}`);
}

describe('chat prompt helpers', () => {
  it('appends the validated Codex override after the client system prompt and removes earlier duplicates', () => {
    const override = renderCodexImagegenOverride('codex', {
      kind: 'image',
      imageModel: 'gpt-image-2',
      imageAspect: '1:1',
    });
    const clientMediaContract =
      '## Media generation contract\nclient contract wins unless a later override says otherwise';

    const prompt = composeLiveInstructionPrompt({
      daemonSystemPrompt: `daemon prompt\n${override}`,
      runtimeToolPrompt: 'runtime tools',
      clientSystemPrompt: clientMediaContract,
      finalPromptOverride: override,
    });

    const clientIdx = prompt.indexOf(clientMediaContract);
    const overrideIdx = prompt.indexOf('## Codex built-in imagegen override');
    expect(clientIdx).toBeGreaterThan(-1);
    expect(overrideIdx).toBeGreaterThan(clientIdx);
    expect(prompt.match(/## Codex built-in imagegen override/g)).toHaveLength(1);
  });

  it('omits the Codex final imagegen override when run media policy blocks execution', () => {
    const metadata = {
      kind: 'image',
      imageModel: 'gpt-image-2',
      imageAspect: '1:1',
    };
    const mediaExecution = {
      mode: 'disabled',
      allowedSurfaces: ['image'],
    };
    const generatedImagesDir = resolveCodexGeneratedImagesDir(
      'codex',
      metadata,
      { CODEX_HOME: '/tmp/custom-codex-home' },
      '/home/tester',
      mediaExecution,
    );
    const otherwiseGrantedDir = resolve('/tmp/custom-codex-home/generated_images');
    const override = resolveGrantedCodexImagegenOverride({
      agentId: 'codex',
      metadata,
      codexGeneratedImagesDir: otherwiseGrantedDir,
      extraAllowedDirs: [otherwiseGrantedDir],
      mediaExecution,
    });
    const prompt = composeLiveInstructionPrompt({
      daemonSystemPrompt: 'daemon media policy prompt',
      runtimeToolPrompt: 'runtime tools',
      clientSystemPrompt: 'client instructions',
      finalPromptOverride: override,
    });

    expect(generatedImagesDir).toBeNull();
    expect(override).toBeNull();
    expect(prompt).not.toContain('## Codex built-in imagegen override');
  });

  it('defaults enabled research without an explicit query to the current message', () => {
    const prompt = resolveResearchCommandContract(
      { enabled: true },
      'EV market 2025 trends',
    );

    expect(prompt).toContain('Canonical query for this run:');
    expect(prompt).toContain('EV market 2025 trends');
    expect(prompt).toContain('the first tool action must be the research command');
  });

  it('resolves only the narrow Codex generated_images allowlist for known gpt-image image projects', () => {
    expect(
      resolveCodexGeneratedImagesDir(
        'codex',
        { kind: 'image', imageModel: 'gpt-image-2' },
        { CODEX_HOME: '/tmp/custom-codex-home' },
        '/home/tester',
      ),
    ).toBe(resolve('/tmp/custom-codex-home/generated_images'));

    expect(
      resolveCodexGeneratedImagesDir(
        'codex',
        { kind: 'image', imageModel: 'gpt-image-2-preview' },
        { CODEX_HOME: '/tmp/custom-codex-home' },
        '/home/tester',
      ),
    ).toBeNull();

    expect(
      resolveCodexGeneratedImagesDir(
        'claude',
        { kind: 'image', imageModel: 'gpt-image-2' },
        { CODEX_HOME: '/tmp/custom-codex-home' },
        '/home/tester',
      ),
    ).toBeNull();
  });

  it('rejects a generated_images final-component symlink', () => {
    const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-symlink-'));
    try {
      const codexHome = join(root, 'codex-home');
      const symlinkTarget = join(root, 'actual-generated-images');
      mkdirSync(codexHome, { recursive: true });
      mkdirSync(symlinkTarget, { recursive: true });
      symlinkDir(symlinkTarget, join(codexHome, 'generated_images'));

      const generatedImagesDir = resolveCodexGeneratedImagesDir(
        'codex',
        { kind: 'image', imageModel: 'gpt-image-2' },
        { CODEX_HOME: codexHome },
        '/home/tester',
      );

      expect(
        validateCodexGeneratedImagesDir(generatedImagesDir, {
          warn: () => undefined,
        }),
      ).toBeNull();
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('rejects a generated_images dir whose canonical path is inside a protected root', () => {
    const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-protected-'));
    try {
      const protectedRoot = join(root, 'skills');
      const protectedGeneratedImages = join(protectedRoot, 'generated_images');
      mkdirSync(protectedGeneratedImages, { recursive: true });
      const codexHome = join(root, 'codex-home');
      symlinkDir(protectedRoot, codexHome);

      const generatedImagesDir = resolveCodexGeneratedImagesDir(
        'codex',
        { kind: 'image', imageModel: 'gpt-image-2' },
        { CODEX_HOME: codexHome },
        '/home/tester',
      );

      expect(
        validateCodexGeneratedImagesDir(generatedImagesDir, {
          protectedDirs: [protectedRoot],
          warn: () => undefined,
        }),
      ).toBeNull();
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('grants Codex the canonical validated generated_images dir', () => {
    const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-canonical-'));
    try {
      const actualCodexHome = join(root, 'actual-codex-home');
      const symlinkCodexHome = join(root, 'codex-home-link');
      mkdirSync(actualCodexHome, { recursive: true });
      symlinkDir(actualCodexHome, symlinkCodexHome);

      const generatedImagesDir = resolveCodexGeneratedImagesDir(
        'codex',
        { kind: 'image', imageModel: 'gpt-image-2' },
        { CODEX_HOME: symlinkCodexHome },
        '/home/tester',
      );
      const validatedDir = validateCodexGeneratedImagesDir(
        generatedImagesDir,
        { warn: () => undefined },
      );
      const canonicalGeneratedImagesDir = join(
        realpathSync.native(actualCodexHome),
        'generated_images',
      );
      const extraAllowedDirs = resolveChatExtraAllowedDirs({
        agentId: 'codex',
        skillsDir: '/repo/skills',
        designSystemsDir: '/repo/design-systems',
        linkedDirs: ['/linked/reference'],
        codexGeneratedImagesDir: validatedDir,
        existsSync: () => true,
      });
      const codex = getAgentDef('codex');
      if (!codex) throw new Error('Codex agent definition missing');
      const args = codex.buildArgs('', [], extraAllowedDirs, {}, {
        cwd: '/tmp/od-project',
      });

      expect(generatedImagesDir).not.toBe(canonicalGeneratedImagesDir);
      expect(validatedDir).toBe(canonicalGeneratedImagesDir);
      expect(extraAllowedDirs).toEqual([canonicalGeneratedImagesDir]);
      expect(
        args.filter(
          (arg, index) =>
            arg === '--add-dir' || args[index - 1] === '--add-dir',
        ),
      ).toEqual(['--add-dir', canonicalGeneratedImagesDir]);
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });

  it('limits Codex extra allowed dirs to the generated_images output dir', () => {
    const generatedImagesDir = '/home/tester/.codex/generated_images';
    const dirs = resolveChatExtraAllowedDirs({
      agentId: '  CoDeX  ',
      skillsDir: '/repo/skills',
      designSystemsDir: '/repo/design-systems',
      linkedDirs: ['/linked/reference'],
      codexGeneratedImagesDir: generatedImagesDir,
      existsSync: () => true,
    });

    expect(dirs).toEqual([generatedImagesDir]);

    const codex = getAgentDef('codex');
    if (!codex) throw new Error('Codex agent definition missing');
    const args = codex.buildArgs('', [], dirs, {}, { cwd: '/tmp/od-project' });
    expect(
      args.filter(
        (arg, index) =>
          arg === '--add-dir' || args[index - 1] === '--add-dir',
      ),
    ).toEqual(['--add-dir', generatedImagesDir]);
    expect(args).not.toContain('/repo/skills');
    expect(args).not.toContain('/repo/design-systems');
    expect(args).not.toContain('/linked/reference');
  });

  it('keeps resource and linked dirs for non-Codex agents without the Codex output dir', () => {
    const existingDirs = new Set([
      '/repo/skills',
      '/repo/design-systems',
      '/linked/reference',
      '/home/tester/.codex/generated_images',
    ]);
    const dirs = resolveChatExtraAllowedDirs({
      agentId: 'claude',
      skillsDir: '/repo/skills',
      designSystemsDir: '/repo/design-systems',
      linkedDirs: ['/linked/reference'],
      codexGeneratedImagesDir: '/home/tester/.codex/generated_images',
      existsSync: (dir: string) => existingDirs.has(dir),
    });

    expect(dirs).toEqual([
      '/repo/skills',
      '/repo/design-systems',
      '/linked/reference',
    ]);
  });

  it('does not add resource dirs for Codex when imagegen is not whitelisted', () => {
    const dirs = resolveChatExtraAllowedDirs({
      agentId: 'codex',
      skillsDir: '/repo/skills',
      designSystemsDir: '/repo/design-systems',
      linkedDirs: ['/linked/reference'],
      codexGeneratedImagesDir: null,
      existsSync: () => true,
    });

    expect(dirs).toEqual([]);
  });

  it('omits the Codex override when validation fails or the dir is not granted', () => {
    const metadata = { kind: 'image', imageModel: 'gpt-image-2' };
    const root = mkdtempSync(join(tmpdir(), 'od-codex-generated-prompt-'));
    try {
      const codexHome = join(root, 'codex-home');
      const symlinkTarget = join(root, 'actual-generated-images');
      mkdirSync(codexHome, { recursive: true });
      mkdirSync(symlinkTarget, { recursive: true });
      symlinkDir(symlinkTarget, join(codexHome, 'generated_images'));

      const generatedImagesDir = resolveCodexGeneratedImagesDir(
        'codex',
        metadata,
        { CODEX_HOME: codexHome },
        '/home/tester',
      );
      const validatedDir = validateCodexGeneratedImagesDir(
        generatedImagesDir,
        { warn: () => undefined },
      );
      const extraAllowedDirs = resolveChatExtraAllowedDirs({
        agentId: 'codex',
        skillsDir: '/repo/skills',
        designSystemsDir: '/repo/design-systems',
        linkedDirs: ['/linked/reference'],
        codexGeneratedImagesDir: validatedDir,
        existsSync: () => true,
      });
      const validationFailedOverride = resolveGrantedCodexImagegenOverride({
        agentId: 'codex',
        metadata,
        codexGeneratedImagesDir: validatedDir,
        extraAllowedDirs,
      });
      const validationFailedPrompt = composeLiveInstructionPrompt({
        daemonSystemPrompt: 'daemon prompt',
        runtimeToolPrompt: 'runtime tools',
        clientSystemPrompt: 'client media contract',
        finalPromptOverride: validationFailedOverride,
      });

      expect(validatedDir).toBeNull();
      expect(extraAllowedDirs).toEqual([]);
      expect(validationFailedOverride).toBeNull();
      expect(validationFailedPrompt).not.toContain(
        '## Codex built-in imagegen override',
      );

      const validDir = join(root, 'safe-codex-home', 'generated_images');
      mkdirSync(validDir, { recursive: true });
      const notGrantedOverride = resolveGrantedCodexImagegenOverride({
        agentId: 'codex',
        metadata,
        codexGeneratedImagesDir: validDir,
        extraAllowedDirs: [],
      });

      expect(notGrantedOverride).toBeNull();
    } finally {
      rmSync(root, { recursive: true, force: true });
    }
  });
});
