import { access, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent';
import { ArtifactService } from '../../src/artifacts/service.js';
import { createServices } from '../../src/app/server.js';
import { FilesystemArtifactObjectStorage } from '../../src/artifacts/storage.js';
import { createArtifactFromSandbox } from '../../src/artifacts/tool.js';
import { EventService } from '../../src/events/service.js';
import type { NormalizedEvent } from '../../src/events/types.js';
import type { McpConnection } from '../../src/mcp/types.js';
import type { GitHubRepositoryAccess } from '../../src/repositories/setup.js';
import type { RunnerMessageInput } from '../../src/runner/types.js';
import { PiRunner } from '../../src/runner-pi/runner.js';
import { createSandboxPiToolDefinitions } from '../../src/runner-pi/sandbox-tools.js';
import { createPiSubagentToolDefinition, piSubagentSystemPrompt } from '../../src/runner-pi/subagent-tool.js';
import {
  PI_SESSION_DATA_VERSION,
  PostgresPiSessionStore,
  type PiSessionData,
} from '../../src/runner-pi/session-store.js';
import type { SandboxKeepaliveService } from '../../src/sandbox/service.js';
import type { SandboxHandle } from '../../src/sandbox/types.js';
import { MemoryStore } from '../../src/store/memory.js';
import { MemorySandboxFileSystem } from '../support/pi-skills.js';

const piMock = vi.hoisted(() => ({
  createAgentSession: vi.fn(),
  completeSimple: vi.fn(),
  modelRuntimeCreateOptions: [] as unknown[],
  modelRuntimeInstances: [] as unknown[],
  openSessionCalls: [] as Array<{ sessionFile: string; agentDir: string; cwd: string; jsonl: string }>,
  openSessionError: undefined as Error | undefined,
  resourceLoaderOptions: [] as Array<{
    noSkills?: boolean;
    systemPrompt?: string;
    skillsOverride?: (base: { skills: unknown[]; diagnostics: unknown[] }) => {
      skills: unknown[];
      diagnostics: unknown[];
    };
  }>,
}));

type SandboxPiTool = ReturnType<typeof createSandboxPiToolDefinitions>[number];
type SandboxPiToolResult = Awaited<ReturnType<SandboxPiTool['execute']>>;
type ExecCall = { command: string; cwd?: string; env?: Record<string, string>; timeoutMs?: number };

vi.mock('@earendil-works/pi-coding-agent', async (importOriginal) => {
  const { readFileSync } = await import('node:fs');
  const actual = await importOriginal<typeof import('@earendil-works/pi-coding-agent')>();

  class FakeModelRuntime {
    static async create(options: unknown) {
      const runtime = new FakeModelRuntime();
      piMock.modelRuntimeCreateOptions.push(options);
      piMock.modelRuntimeInstances.push(runtime);
      return runtime;
    }

    getModels() {
      return [];
    }

    registerProvider() {}

    async getAuth() {
      return { auth: { apiKey: 'test-key' } };
    }

    completeSimple(...args: unknown[]) {
      return piMock.completeSimple(...args);
    }

    getModel(provider: string, id: string) {
      const reasoningContent = [
        'reasoning-content-model',
        'explicit-thinking-model',
        'non-reasoning-content-model',
      ].includes(id);
      return {
        id,
        name: id,
        provider,
        api: reasoningContent ? 'openai-completions' : 'openai-codex-responses',
        baseUrl: 'https://example.test',
        reasoning: id !== 'non-reasoning' && id !== 'non-reasoning-content-model',
        ...(reasoningContent
          ? {
              compat: {
                maxTokensField: 'max_tokens',
                requiresReasoningContentOnAssistantMessages: true,
                ...(id === 'explicit-thinking-model' ? { thinkingFormat: 'openrouter' } : {}),
              },
            }
          : {}),
        ...(id === 'max-reasoning' ? { thinkingLevelMap: { max: 'max' } } : {}),
        ...(id === 'no-minimal-reasoning-model' ? { thinkingLevelMap: { minimal: null } } : {}),
        input: ['text'],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 100_000,
        maxTokens: id === 'small-reasoning-model' ? 1_024 : 16_000,
      };
    }
  }

  class FakeResourceLoader {
    constructor(options: (typeof piMock.resourceLoaderOptions)[number]) {
      piMock.resourceLoaderOptions.push(options);
    }

    async reload() {}
  }

  class FakeSessionManager {
    private id = 'pi-test-session';
    private header: unknown = { id: this.id };
    private entries: unknown[] = [];

    constructor(options: { header?: unknown; entries?: unknown[] } = {}) {
      if (options.header) this.header = options.header;
      if (options.entries) this.entries = options.entries;
    }

    static inMemory() {
      return new FakeSessionManager();
    }

    static open(sessionFile: string, agentDir: string, cwd: string) {
      const jsonl = readFileSync(sessionFile, 'utf8');
      piMock.openSessionCalls.push({ sessionFile, agentDir, cwd, jsonl });
      if (piMock.openSessionError) throw piMock.openSessionError;
      const [headerLine, ...entryLines] = jsonl.trim().split('\n');
      return new FakeSessionManager({
        header: headerLine ? JSON.parse(headerLine) : undefined,
        entries: entryLines.map((line) => JSON.parse(line)),
      });
    }

    newSession(options?: { id?: string }) {
      if (options?.id) this.id = options.id;
      this.header = { id: this.id };
    }

    getSessionId() {
      return this.id;
    }

    getHeader() {
      return this.header;
    }

    getEntries() {
      return this.entries;
    }
  }

  return {
    ...actual,
    ModelRuntime: FakeModelRuntime,
    DefaultResourceLoader: FakeResourceLoader,
    SessionManager: FakeSessionManager,
    getAgentDir: () => '/tmp/pi-agent',
    createAgentSession: piMock.createAgentSession,
  };
});

vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => ({
  ...(await importOriginal<typeof import('@earendil-works/pi-ai/compat')>()),
  completeSimple: piMock.completeSimple,
}));

describe('PiRunner', () => {
  beforeEach(() => {
    piMock.createAgentSession.mockReset();
    piMock.completeSimple.mockReset();
    piMock.modelRuntimeCreateOptions.length = 0;
    piMock.modelRuntimeInstances.length = 0;
    piMock.openSessionCalls.length = 0;
    piMock.openSessionError = undefined;
    piMock.resourceLoaderOptions.length = 0;
  });

  it('describes available subagent profiles and asks subagents for concise results', () => {
    const profile = {
      id: 'managed:researcher',
      name: 'Tenant researcher',
      source: 'managed' as const,
      revision: 'revision-1',
      hash: 'hash-1',
      description: 'Explores code using tenant-specific conventions.',
      instructions: 'Investigate before reporting.',
    };
    const tool = createPiSubagentToolDefinition(
      {
        run: async () => ({
          profileId: profile.id,
          task: 'research',
          cwd: '/workspace',
          depth: 1,
          text: 'done',
        }),
      },
      [profile],
    );

    expect(
      (tool.parameters as { properties: { profileId: { description: string } } }).properties.profileId.description,
    ).toContain('managed:researcher — Tenant researcher: Explores code using tenant-specific conventions.');
    expect(tool.promptGuidelines).toEqual(
      expect.arrayContaining([expect.stringContaining('generally return a concise final result')]),
    );
    expect(piSubagentSystemPrompt('base', profile)).toContain('Generally return a concise final result');
  });

  it('registers steering only after the initial prompt starts and uses Pi steer options', async () => {
    let listener: ((event: AgentSessionEvent) => void) | undefined;
    let activeHandler: ((message: RunnerMessageInput) => Promise<void>) | undefined;
    let releasePrimary!: () => void;
    const primaryBlocked = new Promise<void>((resolve) => {
      releasePrimary = resolve;
    });
    const calls: Array<{ prompt: string; options: unknown }> = [];
    const prompt = vi.fn(async (text: string, options: unknown) => {
      calls.push({ prompt: text, options });
      if (text === 'initial') {
        expect(activeHandler).toBeUndefined();
        listener?.({ type: 'agent_start' });
        await primaryBlocked;
      }
    });
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }], stopReason: 'stop' }],
        prompt,
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe(callback: (event: AgentSessionEvent) => void) {
          listener = callback;
          return () => {
            listener = undefined;
          };
        },
      },
      extensionsResult: {},
    });
    const register = vi.fn((handler: NonNullable<typeof activeHandler>) => {
      activeHandler = handler;
      return async () => {};
    });
    const running = new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'initial',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
      activeMessageDelivery: register,
    });

    await vi.waitFor(() => expect(prompt).toHaveBeenCalledTimes(1));
    expect(calls[0]?.prompt).toBe('initial');
    expect(register).toHaveBeenCalledTimes(1);
    await activeHandler!({ messageId: 'message-2', prompt: 'steer now', sequence: 2 });
    expect(calls[1]).toEqual({
      prompt:
        'The user sent this while you were working. Address both the original request and this message unless this message explicitly redirects or cancels the original request.\n\nsteer now',
      options: { streamingBehavior: 'steer', expandPromptTemplates: false },
    });
    releasePrimary();
    await expect(running).resolves.toMatchObject({ text: 'done' });
  });

  it('does not prompt a prepared steering message after losing final run ownership', async () => {
    let activeHandler: ((message: RunnerMessageInput) => Promise<void>) | undefined;
    let releasePreparation!: () => void;
    const preparationBlocked = new Promise<void>((resolve) => {
      releasePreparation = resolve;
    });
    let releaseInitial!: () => void;
    const initialBlocked = new Promise<void>((resolve) => {
      releaseInitial = resolve;
    });
    const listForRun = vi.fn(async (input: { invokedNames: string[] }) => {
      if (input.invokedNames.includes('review')) await preparationBlocked;
      return [
        {
          id: 'skill-review',
          revisionId: 'revision-review-1',
          revisionNumber: 1,
          name: 'review',
          description: 'Review the implementation',
          body: 'Review carefully.',
          autoLoad: false,
          source: 'managed' as const,
          createdAt: new Date('2026-01-01T00:00:00Z'),
        },
      ];
    });
    let listener: ((event: AgentSessionEvent) => void) | undefined;
    const prompt = vi.fn(async () => {
      listener?.({ type: 'agent_start' });
      await initialBlocked;
    });
    const dispose = vi.fn();
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }], stopReason: 'stop' }],
        prompt,
        abort: vi.fn(),
        dispose,
        subscribe: vi.fn((callback: (event: AgentSessionEvent) => void) => {
          listener = callback;
          return () => {
            listener = undefined;
          };
        }),
      },
      extensionsResult: {},
    });
    let ownsRun = true;
    const running = new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      skills: { repoScanEnabled: false, listForRun },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'initial',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
      shouldPersist: async () => ownsRun,
      activeMessageDelivery: (handler) => {
        activeHandler = handler;
        return async () => {};
      },
    });

    await vi.waitFor(() => expect(activeHandler).toBeDefined());
    const steering = activeHandler!({
      messageId: 'message-2',
      prompt: 'steer with review',
      sequence: 2,
      skillInvocations: [{ name: 'review' }],
    });
    await vi.waitFor(() => expect(listForRun).toHaveBeenCalledTimes(3));
    ownsRun = false;
    releasePreparation();

    await expect(steering).rejects.toThrow('Run ownership lost before steering prompt');
    releaseInitial();
    await expect(running).resolves.toMatchObject({ text: 'done' });
    expect(prompt).toHaveBeenCalledTimes(1);
    expect(prompt).toHaveBeenCalledWith('initial', { expandPromptTemplates: false });
    expect(dispose).toHaveBeenCalledTimes(1);
  });

  it('uses minimal reasoning and a larger title budget without changing model compatibility', async () => {
    const credentials = {
      read: vi.fn(),
      list: vi.fn().mockResolvedValue([]),
      modify: vi.fn(),
      delete: vi.fn(),
    };
    piMock.completeSimple.mockResolvedValue({
      role: 'assistant',
      content: [{ type: 'text', text: 'Fix automatic titles' }],
      stopReason: 'stop',
    });

    const title = await new PiRunner({
      model: 'opencode/reasoning-content-model',
      credentials,
    }).generateTitle({ prompt: 'Please fix automatic title generation' });

    expect(title).toBe('Fix automatic titles');
    expect(piMock.completeSimple).toHaveBeenCalledWith(
      expect.objectContaining({
        provider: 'opencode',
        id: 'reasoning-content-model',
        compat: expect.objectContaining({ maxTokensField: 'max_tokens' }),
      }),
      expect.any(Object),
      expect.objectContaining({ maxTokens: 4_096, reasoning: 'minimal' }),
    );
    expect(piMock.modelRuntimeCreateOptions[0]).toEqual({
      credentials,
      modelsPath: '/tmp/pi-agent/models.json',
    });
    expect(piMock.completeSimple.mock.calls[0]![0].compat).not.toHaveProperty('thinkingFormat');
  });

  it('passes file auth directly and rejects contradictory credential sources', async () => {
    piMock.completeSimple.mockResolvedValue({
      role: 'assistant',
      content: [{ type: 'text', text: 'File auth title' }],
      stopReason: 'stop',
    });
    await new PiRunner({
      model: 'opencode/non-reasoning-content-model',
      authFile: '/tmp/custom-pi/auth.json',
    }).generateTitle({ prompt: 'Title with file auth' });
    expect(piMock.modelRuntimeCreateOptions[0]).toEqual({
      authPath: '/tmp/custom-pi/auth.json',
      modelsPath: '/tmp/custom-pi/models.json',
    });

    expect(
      () =>
        new PiRunner({
          model: 'opencode/non-reasoning-content-model',
          authFile: '/tmp/custom-pi/auth.json',
          authBase64: Buffer.from('{}').toString('base64'),
        }),
    ).toThrow('only one credential source');
    expect(
      () =>
        new PiRunner({
          model: 'opencode/non-reasoning-content-model',
          authBase64: 'not-json',
        }),
    ).toThrow();
  });

  it('diagnoses thinking-only title responses without logging their content or prompt', async () => {
    piMock.completeSimple.mockResolvedValue({
      role: 'assistant',
      content: [{ type: 'thinking', thinking: 'sensitive internal reasoning' }],
      stopReason: 'length',
      usage: {
        input: 20,
        output: 512,
        reasoning: 512,
        cacheRead: 0,
        cacheWrite: 0,
        totalTokens: 532,
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
      },
    });
    const prompt = 'sensitive user prompt';

    const result = new PiRunner({
      model: 'opencode/reasoning-content-model',
      authBase64: Buffer.from('{}').toString('base64'),
    }).generateTitle({ prompt });

    await expect(result).rejects.toThrow(
      'Title generation returned no text (model=opencode/reasoning-content-model, stopReason=length, outputTokens=512, reasoningTokens=512, contentTypes=thinking, tokenBudget=4096)',
    );
    await expect(result).rejects.not.toThrow(prompt);
    await expect(result).rejects.not.toThrow('sensitive internal reasoning');
    expect(piMock.completeSimple).toHaveBeenCalledTimes(1);
  });

  it('accepts a non-empty title when the provider reports a length stop', async () => {
    piMock.completeSimple.mockResolvedValue({
      role: 'assistant',
      content: [{ type: 'text', text: 'Usable partial title' }],
      stopReason: 'length',
    });

    await expect(
      new PiRunner({
        model: 'opencode/reasoning-content-model',
        authBase64: Buffer.from('{}').toString('base64'),
      }).generateTitle({ prompt: 'Title this session' }),
    ).resolves.toBe('Usable partial title');
    expect(piMock.completeSimple).toHaveBeenCalledTimes(1);
  });

  it.each([
    ['other reasoning provider', 'other/reasoning-content-model', 4_096, 'minimal'],
    ['non-reasoning model', 'opencode/non-reasoning-content-model', 64, undefined],
    ['explicit thinking format', 'opencode/explicit-thinking-model', 4_096, 'minimal'],
    ['ordinary OpenCode reasoning model', 'opencode/ordinary-model', 4_096, 'minimal'],
    ['reasoning model without minimal support', 'opencode/no-minimal-reasoning-model', 4_096, 'low'],
    ['reasoning model with a lower output limit', 'opencode/small-reasoning-model', 1_024, 'minimal'],
  ])('uses capability-based title settings for %s', async (_label, model, maxTokens, reasoning) => {
    piMock.completeSimple.mockResolvedValue({
      role: 'assistant',
      content: [{ type: 'text', text: 'Existing behavior' }],
      stopReason: 'stop',
    });

    await new PiRunner({ model, authBase64: Buffer.from('{}').toString('base64') }).generateTitle({
      prompt: 'Keep existing title behavior',
    });

    const [requestedModel, , options] = piMock.completeSimple.mock.calls[0]!;
    expect(options).toMatchObject({ maxTokens });
    if (reasoning) expect(options).toMatchObject({ reasoning });
    else expect(options).not.toHaveProperty('reasoning');
    if (model.includes('explicit-thinking-model')) {
      expect(requestedModel.compat).toMatchObject({ thinkingFormat: 'openrouter' });
    } else {
      expect(requestedModel.compat?.thinkingFormat).toBeUndefined();
    }
  });

  it('does not inject title compatibility into normal agent runs', async () => {
    const credentials = {
      read: vi.fn(),
      list: vi.fn().mockResolvedValue([]),
      modify: vi.fn(),
      delete: vi.fn(),
    };
    piMock.createAgentSession.mockImplementation(async (_options) => ({
      session: {
        sessionId: 'pi-session',
        messages: [{ role: 'assistant', content: [{ type: 'text', text: 'ok' }], stopReason: 'stop' }],
        prompt: vi.fn(),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: () => () => {},
      },
      extensionsResult: {},
    }));

    await new PiRunner({
      model: 'opencode/reasoning-content-model',
      credentials,
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(piMock.createAgentSession.mock.calls[0]![0].model).toMatchObject({
      id: 'reasoning-content-model',
      compat: expect.not.objectContaining({ thinkingFormat: expect.anything() }),
    });
    expect(piMock.modelRuntimeCreateOptions).toEqual([
      expect.objectContaining({ credentials, modelsPath: '/tmp/pi-agent/models.json' }),
    ]);
    expect(piMock.createAgentSession.mock.calls[0]![0].modelRuntime).toBe(piMock.modelRuntimeInstances[0]);
  });

  it('runs a Pi session and normalizes streamed text and tool events', async () => {
    const usage = {
      input: 10,
      output: 5,
      cacheRead: 0,
      cacheWrite: 0,
      totalTokens: 15,
      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
    };
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: 'hello from pi' }],
        model: 'gpt-5.5',
        usage,
        stopReason: 'stop',
      },
    ];
    let listener: ((event: AgentSessionEvent) => void) | undefined;
    const prompt = vi.fn(async () => {
      listener?.({
        type: 'message_update',
        message: messages[0] as never,
        assistantMessageEvent: { type: 'text_delta', delta: 'hello from pi' } as never,
      });
      listener?.({ type: 'tool_execution_start', toolName: 'bash', toolCallId: 'tool-1', args: { command: 'pwd' } });
      listener?.({
        type: 'tool_execution_end',
        toolName: 'bash',
        toolCallId: 'tool-1',
        isError: false,
        result: { content: [{ type: 'text', text: '/workspace' }] },
      });
    });
    const dispose = vi.fn();

    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages,
        prompt,
        abort: vi.fn(),
        dispose,
        subscribe(callback: (event: AgentSessionEvent) => void) {
          listener = callback;
          return () => {
            listener = undefined;
          };
        },
      },
      extensionsResult: {},
    });

    const events: NormalizedEvent[] = [];
    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async (event) => {
        events.push(event);
      },
    });

    expect(prompt).toHaveBeenCalledWith('hello', { expandPromptTemplates: false });
    expect(piMock.createAgentSession).toHaveBeenCalledWith(
      expect.objectContaining({
        noTools: 'builtin',
        customTools: expect.arrayContaining([
          expect.objectContaining({ name: 'read' }),
          expect.objectContaining({ name: 'bash' }),
          expect.objectContaining({ name: 'edit' }),
          expect.objectContaining({ name: 'write' }),
          expect.objectContaining({ name: 'grep' }),
          expect.objectContaining({ name: 'find' }),
          expect.objectContaining({ name: 'ls' }),
          expect.objectContaining({ name: 'subagent' }),
        ]),
      }),
    );
    expect(piMock.createAgentSession.mock.calls[0]![0].tools).toBeUndefined();
    expect(piMock.resourceLoaderOptions[0]?.systemPrompt).toContain(
      'The current durable Deputies session ID is "session-1".',
    );
    expect(events.map((event) => event.type)).toEqual([
      'run_started',
      'skills_loaded',
      'agent_text_delta',
      'tool_started',
      'tool_finished',
      'run_completed',
    ]);
    expect(events[1]?.payload).toEqual({ skills: [], shadowed: [], diagnostics: [] });
    expect(events[2]?.payload).toMatchObject({ text: 'hello from pi' });
    expect(events[3]?.payload).toMatchObject({ toolName: 'bash', toolCallId: 'tool-1' });
    expect(result).toEqual({ text: 'hello from pi', model: 'gpt-5.5', usage });
    expect(dispose).toHaveBeenCalled();
  });

  it('traces explicit and successful model skill invocations once per run', async () => {
    const skillPath = '/workspace/.deputies-skills/managed/skill-review/revision-review-1/review/SKILL.md';
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: 'reviewed' }],
        model: 'gpt-5.5',
        stopReason: 'stop',
      },
    ];
    let listener: ((event: AgentSessionEvent) => void) | undefined;
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages,
        async prompt() {
          listener?.({
            type: 'tool_execution_start',
            toolName: 'read',
            toolCallId: 'read-failed',
            args: { path: skillPath },
          });
          listener?.({
            type: 'tool_execution_end',
            toolName: 'read',
            toolCallId: 'read-failed',
            isError: true,
            result: { content: [{ type: 'text', text: 'failed' }] },
          });
          for (const toolCallId of ['read-success', 'read-repeated']) {
            listener?.({ type: 'tool_execution_start', toolName: 'read', toolCallId, args: { path: skillPath } });
            listener?.({
              type: 'tool_execution_end',
              toolName: 'read',
              toolCallId,
              isError: false,
              result: { content: [{ type: 'text', text: '# Review' }] },
            });
          }
        },
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe(callback: (event: AgentSessionEvent) => void) {
          listener = callback;
          return () => {
            listener = undefined;
          };
        },
      },
      extensionsResult: {},
    });

    const events: NormalizedEvent[] = [];
    await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      skills: {
        repoScanEnabled: false,
        listForRun: async () => [
          {
            id: 'skill-review',
            revisionId: 'revision-review-1',
            revisionNumber: 1,
            name: 'review',
            description: 'Review the implementation',
            body: 'Review carefully.',
            autoLoad: true,
            source: 'managed',
            createdAt: new Date('2026-01-01T00:00:00Z'),
          },
        ],
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      createdByUserId: 'user-1',
      prompt: 'review this',
      context: { skills: ['review'], skillRefs: [{ id: 'skill-review', name: 'review' }] },
      skillInvocations: [{ name: 'review', ref: 'skill-review' }],
      sandbox: createMemorySandbox(),
      emit: async (event) => {
        events.push(event);
      },
    });

    expect(events.filter((event) => event.type === 'skill_invoked').map((event) => event.payload)).toEqual([
      {
        name: 'review',
        source: 'managed',
        trigger: 'user',
        ref: 'skill-review',
        filePath: skillPath,
        skillId: 'skill-review',
        revisionId: 'revision-review-1',
        revisionNumber: 1,
      },
      {
        name: 'review',
        source: 'managed',
        trigger: 'model',
        ref: 'skill-review',
        filePath: skillPath,
        skillId: 'skill-review',
        revisionId: 'revision-review-1',
        revisionNumber: 1,
      },
    ]);
    expect(
      events.findIndex((event) => event.type === 'tool_finished' && event.payload.toolCallId === 'read-success'),
    ).toBeLessThan(events.findIndex((event) => event.type === 'skill_invoked' && event.payload.trigger === 'model'));
  });

  it('drains skill invocation events before surfacing a prompt failure', async () => {
    const skillPath = '/workspace/.deputies-skills/managed/skill-review/revision-review-1/review/SKILL.md';
    let listener: ((event: AgentSessionEvent) => void) | undefined;
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [],
        async prompt() {
          listener?.({
            type: 'tool_execution_start',
            toolName: 'read',
            toolCallId: 'read-1',
            args: { path: skillPath },
          });
          listener?.({
            type: 'tool_execution_end',
            toolName: 'read',
            toolCallId: 'read-1',
            isError: false,
            result: { content: [{ type: 'text', text: '# Review' }] },
          });
          throw new Error('prompt failed');
        },
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe(callback: (event: AgentSessionEvent) => void) {
          listener = callback;
          return () => {
            listener = undefined;
          };
        },
      },
      extensionsResult: {},
    });

    const events: NormalizedEvent[] = [];
    const run = new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      skills: {
        repoScanEnabled: false,
        listForRun: async () => [
          {
            id: 'skill-review',
            revisionId: 'revision-review-1',
            revisionNumber: 1,
            name: 'review',
            description: 'Review the implementation',
            body: 'Review carefully.',
            autoLoad: true,
            source: 'managed',
            createdAt: new Date('2026-01-01T00:00:00Z'),
          },
        ],
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'review this',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async (event) => {
        if (event.type === 'tool_started') await new Promise((resolve) => setTimeout(resolve, 5));
        events.push(event);
      },
    });

    await expect(run).rejects.toThrow('prompt failed');
    expect(events.map((event) => event.type)).toContain('skill_invoked');
    expect(events.findIndex((event) => event.type === 'tool_finished')).toBeLessThan(
      events.findIndex((event) => event.type === 'skill_invoked'),
    );
  });

  it('keeps Bedrock model version suffixes in Pi model IDs', async () => {
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'ok' }],
            model: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
          },
        ],
        prompt: vi.fn(),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: () => () => {},
      },
      extensionsResult: {},
    });

    await new PiRunner({
      model: 'amazon-bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0',
      authBase64: Buffer.from('{}').toString('base64'),
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(piMock.createAgentSession.mock.calls[0]![0]).toMatchObject({
      model: expect.objectContaining({
        provider: 'amazon-bedrock',
        id: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
      }),
    });
    expect(piMock.createAgentSession.mock.calls[0]![0].thinkingLevel).toBeUndefined();
  });

  it('uses explicit reasoning levels and safely falls back for unsupported models', async () => {
    piMock.createAgentSession.mockImplementation(async (options) => ({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'ok' }],
            model: options.model.id,
            stopReason: 'stop',
          },
        ],
        prompt: vi.fn(),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: () => () => {},
      },
      extensionsResult: {},
    }));

    for (const [modelId, reasoningLevel] of [
      ['max-reasoning', 'max'],
      ['limited-reasoning', 'max'],
      ['non-reasoning', 'high'],
    ] as const) {
      await new PiRunner({
        model: `openai-codex/${modelId}`,
        authBase64: Buffer.from('{}').toString('base64'),
      }).run({
        sessionId: `session-${modelId}`,
        runId: `run-${modelId}`,
        messageId: `message-${modelId}`,
        prompt: 'hello',
        reasoningLevel,
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      });
    }

    await new PiRunner({
      model: 'openai-codex/max-reasoning',
      reasoningLevelDefault: 'minimal',
      authBase64: Buffer.from('{}').toString('base64'),
    }).run({
      sessionId: 'session-default-reasoning',
      runId: 'run-default-reasoning',
      messageId: 'message-default-reasoning',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(piMock.createAgentSession.mock.calls.map(([options]) => options.thinkingLevel)).toEqual([
      'max',
      'high',
      'off',
      'minimal',
    ]);
  });

  it('runs subagents in-process and caps nested registration at depth 4', async () => {
    let createCount = 0;
    let profileResolutionCount = 0;
    const prompts: string[] = [];
    const resolveAgentProfile = vi.fn(async (id: string) => {
      const first = profileResolutionCount++ === 0;
      return {
        id,
        name: 'Managed reviewer',
        source: 'managed' as const,
        revision: 'profile-revision-7',
        hash: 'profile-hash-7',
        description: 'Managed profile',
        instructions: 'Follow the managed profile instructions.',
        ...(first ? { model: 'openai-codex/child-model', reasoningLevel: 'high' } : {}),
      };
    });
    const listForRun = vi.fn(async () => [
      {
        id: 'skill-review',
        revisionId: 'revision-review-1',
        revisionNumber: 1,
        name: 'review',
        description: 'Review the implementation',
        body: 'Review carefully.',
        autoLoad: false,
        source: 'managed' as const,
        createdAt: new Date('2026-01-01T00:00:00Z'),
      },
      {
        id: 'skill-autoload-one',
        revisionId: 'revision-autoload-one',
        revisionNumber: 1,
        name: 'autoload-one',
        description: 'First parent auto-loaded skill',
        body: 'Follow the first auto-loaded skill.',
        autoLoad: true,
        source: 'managed' as const,
        createdAt: new Date('2026-01-02T00:00:00Z'),
      },
      {
        id: 'different-skill',
        revisionId: 'different-revision',
        revisionNumber: 1,
        name: 'autoload-two',
        description: 'Second parent auto-loaded skill',
        body: 'Follow the second auto-loaded skill.',
        autoLoad: true,
        source: 'managed' as const,
        createdAt: new Date('2026-01-03T00:00:00Z'),
      },
      {
        id: 'parent-skill',
        revisionId: 'parent-revision',
        revisionNumber: 1,
        name: 'parent-skill',
        description: 'Inherited parent skill',
        body: 'This skill should be inherited by subagents.',
        autoLoad: true,
        source: 'managed' as const,
        createdAt: new Date('2026-01-04T00:00:00Z'),
      },
    ]);

    piMock.createAgentSession.mockImplementation(async (options) => {
      const index = createCount++;
      let listener: ((event: AgentSessionEvent) => void) | undefined;
      const messages = [
        {
          role: 'assistant',
          content: [{ type: 'text', text: '' }],
          model: 'gpt-5.5',
          stopReason: 'stop',
        },
      ];
      const subagentTool = options.customTools.find((candidate: { name: string }) => candidate.name === 'subagent');
      if (index < 4) expect(subagentTool).toBeTruthy();
      else expect(subagentTool).toBeUndefined();

      return {
        session: {
          sessionId: `pi-session-${index}`,
          messages,
          async prompt(prompt: string) {
            prompts.push(`${index}:${prompt}`);
            if (index < 4) {
              listener?.({
                type: 'tool_execution_start',
                toolName: 'subagent',
                toolCallId: 'tool-shared',
                args: { profileId: 'managed-profile-id', task: `child-${index}` },
              });
              const toolResult = await subagentTool!.execute(
                'tool-shared',
                { profileId: 'managed-profile-id', task: `child-${index}` },
                undefined,
                undefined,
                undefined,
              );
              listener?.({
                type: 'tool_execution_end',
                toolName: 'subagent',
                toolCallId: 'tool-shared',
                isError: false,
                result: toolResult,
              });
              const content = toolResult.content[0];
              messages[0]!.content[0]!.text = content?.type === 'text' ? content.text : '';
              return;
            }

            listener?.({
              type: 'message_update',
              message: messages[0] as never,
              assistantMessageEvent: { type: 'text_delta', delta: 'private child draft' } as never,
            });
            listener?.({
              type: 'tool_execution_start',
              toolName: 'read',
              toolCallId: 'tool-shared',
              args: {
                path: '/workspace/.deputies-skills/managed/skill-review/revision-review-1/review/SKILL.md',
                apiKey: 'child-secret-value',
              },
            });
            listener?.({
              type: 'tool_execution_end',
              toolName: 'read',
              toolCallId: 'tool-shared',
              isError: false,
              result: { content: [{ type: 'text', text: 'child-secret-value' }] },
            });
            messages[0]!.content[0]!.text = 'leaf result';
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn((callback: (event: AgentSessionEvent) => void) => {
            listener = callback;
            return () => {
              listener = undefined;
            };
          }),
        },
        extensionsResult: {},
      };
    });

    const events: NormalizedEvent[] = [];
    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      skills: { repoScanEnabled: false, listForRun },
      resolveAgentProfile,
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'parent task',
      context: { skills: ['review'] },
      skillInvocations: [{ name: 'review' }],
      sandbox: createMemorySandbox(),
      emit: async (event) => {
        events.push(event);
      },
    });

    expect(result.text).toBe('leaf result');
    expect(createCount).toBe(5);
    expect(piMock.modelRuntimeInstances).toHaveLength(1);
    expect(
      piMock.createAgentSession.mock.calls.every(
        ([options]) => options.modelRuntime === piMock.modelRuntimeInstances[0],
      ),
    ).toBe(true);
    expect(piMock.createAgentSession.mock.calls.slice(1).map(([options]) => options.model.id)).toEqual([
      'child-model',
      'child-model',
      'child-model',
      'child-model',
    ]);
    expect(piMock.createAgentSession.mock.calls.slice(1).map(([options]) => options.thinkingLevel)).toEqual([
      'high',
      'high',
      'high',
      'high',
    ]);
    expect(prompts[0]).toContain(
      '0:<skill name="review" location="/workspace/.deputies-skills/managed/skill-review/revision-review-1/review/SKILL.md">',
    );
    expect(prompts[0]).toContain(
      'References are relative to /workspace/.deputies-skills/managed/skill-review/revision-review-1/review.',
    );
    expect(prompts[0]).toContain('Review carefully.\n</skill>\n\nparent task');
    expect(prompts.slice(1)).toEqual(['1:child-0', '2:child-1', '3:child-2', '4:child-3']);
    expect(listForRun).toHaveBeenCalledTimes(2);
    expect(piMock.resourceLoaderOptions).toHaveLength(5);
    expect(resolveAgentProfile).toHaveBeenCalledWith('managed-profile-id');
    expect(piMock.resourceLoaderOptions[1]?.systemPrompt).toContain('Follow the managed profile instructions.');
    const topLevelSkills =
      piMock.resourceLoaderOptions[0]?.skillsOverride?.({ skills: [], diagnostics: [] }).skills ?? [];
    expect(topLevelSkills.map((skill) => (skill as { name: string }).name)).toEqual([
      'autoload-one',
      'autoload-two',
      'parent-skill',
    ]);
    for (const loaderOptions of piMock.resourceLoaderOptions.slice(1)) {
      expect(loaderOptions.noSkills).toBe(true);
      expect(loaderOptions.systemPrompt).toContain('The current durable Deputies session ID is "session-1".');
      const loadedSkills = loaderOptions.skillsOverride?.({ skills: [], diagnostics: [] }).skills ?? [];
      expect(loadedSkills.map((skill) => (skill as { name: string }).name)).toEqual([
        'autoload-one',
        'autoload-two',
        'parent-skill',
      ]);
    }
    expect(
      events
        .filter((event) => event.type === 'skill_invoked')
        .map((event) => [event.payload.trigger, event.payload.ref]),
    ).toEqual([['user', 'skill-review']]);
    const toolEvents = events.filter((event) => event.type === 'tool_started' || event.type === 'tool_finished');
    expect(toolEvents).toHaveLength(10);
    expect(toolEvents.every((event) => event.payload.toolCallId === 'tool-shared')).toBe(true);
    expect(JSON.stringify(toolEvents.filter((event) => 'activityId' in event.payload))).toContain('child-secret-value');
    const starts = toolEvents.filter((event) => event.type === 'tool_started');
    let parentActivityId = 'tool-shared';
    for (const start of starts.slice(1)) {
      expect(start.payload.parentActivityId).toBe(parentActivityId);
      expect(start.payload.activityId).toMatch(/^subagent-[^:]+:tool-shared$/);
      parentActivityId = start.payload.activityId!;
    }
    expect(new Set(starts.slice(1).map((event) => event.payload.activityId)).size).toBe(4);
    expect(starts.at(-1)?.payload).toEqual(
      expect.objectContaining({
        args: expect.objectContaining({
          path: '/workspace/.deputies-skills/managed/skill-review/revision-review-1/review/SKILL.md',
          apiKey: 'child-secret-value',
        }),
        subagentDepth: 4,
        subagentProfileId: 'managed-profile-id',
        subagentProfileSource: 'managed',
        subagentProfileRevision: 'profile-revision-7',
        subagentProfileHash: 'profile-hash-7',
      }),
    );
    expect(
      events.some((event) => event.type === 'agent_text_delta' && event.payload.text === 'private child draft'),
    ).toBe(false);
  });

  it('namespaces colliding tool call ids from sibling subagent sessions', async () => {
    let createCount = 0;

    piMock.createAgentSession.mockImplementation(async (options) => {
      const index = createCount++;
      let listener: ((event: AgentSessionEvent) => void) | undefined;
      const messages = [
        {
          role: 'assistant',
          content: [{ type: 'text', text: '' }],
          model: 'gpt-5.5',
          stopReason: 'stop',
        },
      ];

      return {
        session: {
          sessionId: `pi-sibling-session-${index}`,
          messages,
          async prompt() {
            if (index === 0) {
              const subagentTool = options.customTools.find(
                (candidate: { name: string }) => candidate.name === 'subagent',
              );
              for (const parentToolCallId of ['parent-a', 'parent-b']) {
                listener?.({
                  type: 'tool_execution_start',
                  toolName: 'subagent',
                  toolCallId: parentToolCallId,
                  args: { profileId: 'builtin:codebase-researcher', task: parentToolCallId },
                });
                const result = await subagentTool!.execute(
                  parentToolCallId,
                  { profileId: 'builtin:codebase-researcher', task: parentToolCallId },
                  undefined,
                  undefined,
                  undefined,
                );
                listener?.({
                  type: 'tool_execution_end',
                  toolName: 'subagent',
                  toolCallId: parentToolCallId,
                  isError: false,
                  result,
                });
              }
              messages[0]!.content[0]!.text = 'siblings complete';
              return;
            }

            listener?.({
              type: 'tool_execution_start',
              toolName: 'read',
              toolCallId: 'child-call-shared',
              args: { path: `/workspace/child-${index}.ts` },
            });
            listener?.({
              type: 'tool_execution_end',
              toolName: 'read',
              toolCallId: 'child-call-shared',
              isError: false,
              result: 'not forwarded',
            });
            messages[0]!.content[0]!.text = `child ${index}`;
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn((callback: (event: AgentSessionEvent) => void) => {
            listener = callback;
            return () => {
              listener = undefined;
            };
          }),
        },
        extensionsResult: {},
      };
    });

    const events: NormalizedEvent[] = [];
    await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      skills: { repoScanEnabled: false, listForRun: async () => [] },
    }).run({
      sessionId: 'session-siblings',
      runId: 'run-siblings',
      messageId: 'message-siblings',
      prompt: 'run siblings',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async (event) => {
        events.push(event);
      },
    });

    const childStarts = events.filter(
      (event): event is NormalizedEvent<'tool_started'> =>
        event.type === 'tool_started' && event.payload.toolCallId === 'child-call-shared',
    );
    const childFinishes = events.filter(
      (event): event is NormalizedEvent<'tool_finished'> =>
        event.type === 'tool_finished' && event.payload.toolCallId === 'child-call-shared',
    );
    expect(childStarts.map((event) => event.payload.parentActivityId)).toEqual(['parent-a', 'parent-b']);
    expect(new Set(childStarts.map((event) => event.payload.activityId)).size).toBe(2);
    expect(childFinishes.map((event) => event.payload.activityId)).toEqual(
      childStarts.map((event) => event.payload.activityId),
    );
  });

  it('registers MCP tools, shares them with subagents, and closes connections', async () => {
    const close = vi.fn(async () => {});
    const callTool = vi.fn(async (_toolName: string, args: Record<string, unknown>) => `mcp:${String(args.query)}`);
    const connection: McpConnection = {
      name: 'executor',
      tools: [
        {
          name: 'mcp__executor__execute',
          originalName: 'execute',
          description: 'Run Executor code',
          parameters: { type: 'object', properties: { query: { type: 'string' } } },
        },
      ],
      callTool,
      close,
    };
    const connect = vi.fn(async () => connection);
    let createCount = 0;

    piMock.createAgentSession.mockImplementation(async (options) => {
      const index = createCount++;
      const messages = [
        {
          role: 'assistant',
          content: [{ type: 'text', text: '' }],
          model: 'gpt-5.5',
          stopReason: 'stop',
        },
      ];
      const mcpTool = options.customTools.find(
        (candidate: { name: string }) => candidate.name === 'mcp__executor__execute',
      );
      expect(mcpTool).toBeTruthy();
      return {
        session: {
          sessionId: `pi-session-${index}`,
          messages,
          async prompt() {
            if (index === 0) {
              const parentResult = await mcpTool!.execute(
                'tool-1',
                { query: 'parent' },
                undefined,
                undefined,
                undefined,
              );
              const subagentTool = options.customTools.find(
                (candidate: { name: string }) => candidate.name === 'subagent',
              );
              const childResult = await subagentTool!.execute(
                'tool-2',
                { profileId: 'builtin:codebase-researcher', task: 'child task' },
                undefined,
                undefined,
                undefined,
              );
              messages[0]!.content[0]!.text = `${parentResult.content[0]!.text}\n${childResult.content[0]!.text}`;
              return;
            }
            const childResult = await mcpTool!.execute('tool-3', { query: 'child' }, undefined, undefined, undefined);
            messages[0]!.content[0]!.text = childResult.content[0]!.text;
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn(() => () => {}),
        },
        extensionsResult: {},
      };
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      mcp: {
        servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
        connectTimeoutMs: 10_000,
        toolTimeoutMs: 60_000,
        toolResultMaxChars: 100_000,
        responseMaxBytes: 5 * 1024 * 1024,
        connect,
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'use executor',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(result.text).toBe('mcp:parent\nmcp:child');
    expect(connect).toHaveBeenCalledTimes(1);
    expect(callTool).toHaveBeenCalledTimes(2);
    expect(createCount).toBe(2);
    expect(close).toHaveBeenCalledTimes(1);
  });

  it('keeps Pi runs alive when an MCP server is unavailable and prepends a prompt note', async () => {
    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const prompt = vi.fn();
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'continued without mcp' }],
            model: 'gpt-5.5',
            stopReason: 'stop',
          },
        ],
        prompt,
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });
    const connect = vi.fn(async () => {
      throw new Error('secret-token-from-transport');
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      mcp: {
        servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
        connectTimeoutMs: 10_000,
        toolTimeoutMs: 60_000,
        toolResultMaxChars: 100_000,
        responseMaxBytes: 5 * 1024 * 1024,
        connect,
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(result.text).toBe('continued without mcp');
    expect(prompt).toHaveBeenCalledWith(
      expect.stringContaining('Note: MCP tools from server "executor" are unavailable this run.'),
      { expandPromptTemplates: false },
    );
    expect(prompt.mock.calls[0]?.[0]).toContain('\n\nhello');
    expect(prompt.mock.calls[0]?.[0]).not.toContain('secret-token-from-transport');
    expect(warn).toHaveBeenCalledWith('MCP server "executor" is unavailable this run (unknown).');
  });

  it('continues archived environment sessions and prepends the saved-snapshot warning once', async () => {
    const services = createServices(new MemoryStore());
    const environment = await services.environments.create({
      name: 'Product surface',
      repositories: [{ provider: 'github', owner: 'manaflow-ai', repo: 'manaflow', primary: true }],
    });
    const snapshot = await services.environments.resolve({
      environmentId: environment.id,
    });
    await services.environments.archive(environment.id);
    const prompt = vi.fn();
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'continued from snapshot' }],
            model: 'gpt-5.5',
            stopReason: 'stop',
          },
        ],
        prompt,
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      environments: services.environments,
      repositoryAccess: {
        github: {
          async getRepositoryAccess() {
            return githubAccess;
          },
        },
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'continue',
      context: { environment: snapshot },
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(result.text).toBe('continued from snapshot');
    const prompted = prompt.mock.calls[0]?.[0] as string;
    expect(prompted.match(/saved environment snapshot/g)).toHaveLength(1);
    expect(prompted).toContain('\n\ncontinue');
  });

  it('closes MCP connections when Pi setup fails after connecting', async () => {
    const close = vi.fn(async () => {});
    const connection: McpConnection = {
      name: 'executor',
      tools: [],
      callTool: vi.fn(),
      close,
    };
    const connect = vi.fn(async () => connection);
    const sessionStore = {
      load: vi.fn(async () => {
        throw new Error('lease failed');
      }),
      save: vi.fn(async () => {}),
    };

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        sessionStore,
        mcp: {
          servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
          connectTimeoutMs: 10_000,
          toolTimeoutMs: 60_000,
          toolResultMaxChars: 100_000,
          responseMaxBytes: 5 * 1024 * 1024,
          connect,
        },
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'hello',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      }),
    ).rejects.toThrow('lease failed');

    expect(connect).toHaveBeenCalledTimes(1);
    expect(close).toHaveBeenCalledTimes(1);
    expect(piMock.createAgentSession).not.toHaveBeenCalled();
  });

  it('closes MCP connections when a Pi run fails', async () => {
    const close = vi.fn(async () => {});
    const connection: McpConnection = {
      name: 'executor',
      tools: [],
      callTool: vi.fn(),
      close,
    };
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          { role: 'assistant', content: [{ type: 'text', text: '' }], stopReason: 'error', errorMessage: 'Pi failed' },
        ],
        prompt: vi.fn(async () => {}),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        mcp: {
          servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
          connectTimeoutMs: 10_000,
          toolTimeoutMs: 60_000,
          toolResultMaxChars: 100_000,
          responseMaxBytes: 5 * 1024 * 1024,
          connect: vi.fn(async () => connection),
        },
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'hello',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      }),
    ).rejects.toThrow('Pi failed');

    expect(close).toHaveBeenCalledTimes(1);
  });

  it('closes MCP connections when a Pi run is aborted', async () => {
    const close = vi.fn(async () => {});
    const controller = new AbortController();
    const abort = vi.fn();
    const connection: McpConnection = {
      name: 'executor',
      tools: [],
      callTool: vi.fn(),
      close,
    };
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [{ role: 'assistant', content: [{ type: 'text', text: 'late' }], stopReason: 'stop' }],
        prompt: vi.fn(async () => {
          controller.abort();
        }),
        abort,
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        mcp: {
          servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
          connectTimeoutMs: 10_000,
          toolTimeoutMs: 60_000,
          toolResultMaxChars: 100_000,
          responseMaxBytes: 5 * 1024 * 1024,
          connect: vi.fn(async () => connection),
        },
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'hello',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
        signal: controller.signal,
      }),
    ).rejects.toThrow('Operation aborted');

    expect(abort).toHaveBeenCalledTimes(1);
    expect(close).toHaveBeenCalledTimes(1);
  });

  it('passes MCP tool-call abort signals through the Pi tool adapter', async () => {
    const controller = new AbortController();
    const callTool = vi.fn(async (_toolName: string, _args: Record<string, unknown>, signal?: AbortSignal) => {
      expect(signal).toBe(controller.signal);
      return 'mcp ok';
    });
    const connection: McpConnection = {
      name: 'executor',
      tools: [
        {
          name: 'mcp__executor__execute',
          originalName: 'execute',
          description: 'Run Executor code',
          parameters: { type: 'object', properties: {} },
        },
      ],
      callTool,
      close: vi.fn(async () => {}),
    };

    piMock.createAgentSession.mockImplementation(async (options) => {
      const mcpTool = options.customTools.find(
        (candidate: { name: string }) => candidate.name === 'mcp__executor__execute',
      );
      const messages = [{ role: 'assistant', content: [{ type: 'text', text: '' }], stopReason: 'stop' }];
      return {
        session: {
          sessionId: 'pi-session',
          messages,
          async prompt() {
            const result = await mcpTool!.execute(
              'tool-1',
              { query: 'hello' },
              controller.signal,
              undefined,
              undefined,
            );
            messages[0]!.content[0]!.text = result.content[0]!.type === 'text' ? result.content[0]!.text : '';
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn(() => () => {}),
        },
        extensionsResult: {},
      };
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      mcp: {
        servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
        connectTimeoutMs: 10_000,
        toolTimeoutMs: 60_000,
        toolResultMaxChars: 100_000,
        responseMaxBytes: 5 * 1024 * 1024,
        connect: vi.fn(async () => connection),
      },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'hello',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(result.text).toBe('mcp ok');
    expect(callTool).toHaveBeenCalledTimes(1);
  });

  it('surfaces redacted MCP tool errors at the Pi runner level', async () => {
    const connection: McpConnection = {
      name: 'executor',
      tools: [
        {
          name: 'mcp__executor__execute',
          originalName: 'execute',
          description: 'Run Executor code',
          parameters: { type: 'object', properties: {} },
        },
      ],
      callTool: vi.fn(async () => {
        throw new Error('MCP tool "execute" from server "executor" failed (unauthorized).');
      }),
      close: vi.fn(async () => {}),
    };
    piMock.createAgentSession.mockImplementation(async (options) => {
      const mcpTool = options.customTools.find(
        (candidate: { name: string }) => candidate.name === 'mcp__executor__execute',
      );
      return {
        session: {
          sessionId: 'pi-session',
          messages: [{ role: 'assistant', content: [{ type: 'text', text: '' }], stopReason: 'stop' }],
          async prompt() {
            await mcpTool!.execute('tool-1', { query: 'hello' }, undefined, undefined, undefined);
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn(() => () => {}),
        },
        extensionsResult: {},
      };
    });

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        mcp: {
          servers: [{ name: 'executor', url: 'https://executor.example/mcp', transport: 'streamable-http' }],
          connectTimeoutMs: 10_000,
          toolTimeoutMs: 60_000,
          toolResultMaxChars: 100_000,
          responseMaxBytes: 5 * 1024 * 1024,
          connect: vi.fn(async () => connection),
        },
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'hello',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      }),
    ).rejects.toThrow('MCP tool "execute" from server "executor" failed (unauthorized).');
  });

  it('rehydrates stored sessions and saves the updated Pi session data', async () => {
    const storedSession: PiSessionData = {
      version: PI_SESSION_DATA_VERSION,
      header: { id: 'session-1' } as never,
      entries: [{ type: 'message', role: 'user', content: 'previous prompt' } as never],
    };
    const sessionStore = {
      load: vi.fn(async () => storedSession),
      save: vi.fn(async () => {}),
    };
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: 'continued' }],
        model: 'gpt-5.5',
        stopReason: 'stop',
      },
    ];

    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages,
        prompt: vi.fn(async () => {}),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      sessionStore,
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'continue',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
    });

    expect(result).toEqual({ text: 'continued', model: 'gpt-5.5' });
    expect(sessionStore.load).toHaveBeenCalledWith('session-1');
    expect(piMock.resourceLoaderOptions[0]?.systemPrompt).toContain(
      'The current durable Deputies session ID is "session-1".',
    );
    expect(piMock.openSessionCalls).toHaveLength(1);
    expect(piMock.openSessionCalls[0]).toMatchObject({ agentDir: expect.any(String), cwd: '/workspace' });
    expect(
      piMock.openSessionCalls[0]?.jsonl
        .trim()
        .split('\n')
        .map((line) => JSON.parse(line)),
    ).toEqual([storedSession.header, ...storedSession.entries]);
    expect(sessionStore.save).toHaveBeenCalledWith('session-1', storedSession);
  });

  it('does not persist successful stored turns after losing run ownership', async () => {
    const storedSession: PiSessionData = {
      version: PI_SESSION_DATA_VERSION,
      header: { id: 'session-1' } as never,
      entries: [],
    };
    const sessionStore = {
      load: vi.fn(async () => storedSession),
      save: vi.fn(async () => {}),
    };

    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'completed after lease loss' }],
            model: 'gpt-5.5',
            stopReason: 'stop',
          },
        ],
        prompt: vi.fn(async () => {}),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      sessionStore,
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'continue',
      context: {},
      sandbox: createMemorySandbox(),
      emit: async () => {},
      shouldPersist: async () => false,
    });

    expect(result.text).toBe('completed after lease loss');
    expect(sessionStore.save).not.toHaveBeenCalled();
  });

  it('does not persist failed stored turns', async () => {
    const storedSession: PiSessionData = {
      version: PI_SESSION_DATA_VERSION,
      header: { id: 'session-1' } as never,
      entries: [],
    };
    const sessionStore = {
      load: vi.fn(async () => storedSession),
      save: vi.fn(async () => {}),
    };

    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages: [
          {
            role: 'assistant',
            content: [{ type: 'text', text: 'failed' }],
            stopReason: 'error',
            errorMessage: 'Pi failed',
          },
        ],
        prompt: vi.fn(async () => {}),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        sessionStore,
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'continue',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      }),
    ).rejects.toThrow('Pi failed');

    expect(sessionStore.save).not.toHaveBeenCalled();
  });

  it('cleans up temporary session files when rehydration fails', async () => {
    const storedSession: PiSessionData = {
      version: PI_SESSION_DATA_VERSION,
      header: { id: 'session-1' } as never,
      entries: [],
    };
    const sessionStore = {
      load: vi.fn(async () => storedSession),
      save: vi.fn(async () => {}),
    };
    piMock.openSessionError = new Error('open failed');

    await expect(
      new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        sessionStore,
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'continue',
        context: {},
        sandbox: createMemorySandbox(),
        emit: async () => {},
      }),
    ).rejects.toThrow('open failed');

    const tempDir = path.dirname(piMock.openSessionCalls[0]!.sessionFile);
    await expect(access(tempDir)).rejects.toThrow();
    expect(piMock.createAgentSession).not.toHaveBeenCalled();
  });

  it('registers artifact and stores sandbox files as product artifacts', async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), 'deputies-pi-artifact-tool-'));
    try {
      const store = new MemoryStore();
      const eventsService = new EventService(store);
      const artifacts = new ArtifactService(store, eventsService, new FilesystemArtifactObjectStorage(tempDir));
      await store.createSession({
        id: 'session-1',
        status: 'active',
        title: 'Pi artifact session',
        createdAt: new Date(),
        updatedAt: new Date(),
        context: {},
      });
      const sandbox = createMemorySandbox();
      await sandbox.fs!.writeFile('/workspace/report.txt', 'tool artifact');
      const messages = [
        {
          role: 'assistant',
          content: [{ type: 'text', text: '' }],
          model: 'gpt-5.5',
          stopReason: 'stop',
        },
      ];

      piMock.createAgentSession.mockImplementation(async (options) => {
        expect(options.noTools).toBe('builtin');
        expect(options.tools).toBeUndefined();
        const tool = options.customTools.find((candidate: { name: string }) => candidate.name === 'artifact');
        expect(tool).toBeTruthy();
        return {
          session: {
            sessionId: 'pi-session',
            messages,
            async prompt() {
              const toolResult = await tool!.execute(
                'tool-1',
                {
                  action: 'create',
                  path: '/workspace/report.txt',
                  type: 'report',
                  title: 'Report',
                  contentType: 'text/plain',
                },
                undefined,
                undefined,
                undefined,
              );
              const result = JSON.parse(toolResult.content[0]!.text) as { downloadUrl: string };
              messages[0]!.content[0]!.text = `Created ${result.downloadUrl}`;
            },
            abort: vi.fn(),
            dispose: vi.fn(),
            subscribe: vi.fn(() => () => {}),
          },
          extensionsResult: {},
        };
      });

      const result = await new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        artifacts,
        artifactToolMaxBytes: 1024,
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'publish report',
        context: {},
        sandbox,
        emit: async () => {},
      });

      const records = await store.getArtifacts('session-1');
      expect(records).toMatchObject([
        {
          type: 'report',
          title: 'Report',
          storageKey: expect.any(String),
          payload: {
            sourcePath: '/workspace/report.txt',
            storage: 'internal',
            contentType: 'text/plain',
            fileName: 'report.txt',
          },
        },
      ]);
      expect(result.text).toContain(`/sessions/session-1/artifacts/${records[0]!.id}/download`);
      await expect(store.getEvents('session-1')).resolves.toMatchObject([{ type: 'artifact_created' }]);
    } finally {
      await rm(tempDir, { recursive: true, force: true });
    }
  });

  it('registers service and updates published service context', async () => {
    const sandbox = createMemorySandbox();
    sandbox.metadata = { runtimeId: 'runtime-1' };
    let context: Record<string, unknown> = {};
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: '' }],
        model: 'gpt-5.5',
        stopReason: 'stop',
      },
    ];

    piMock.createAgentSession.mockImplementation(async (options) => {
      expect(options.noTools).toBe('builtin');
      expect(options.tools).toBeUndefined();
      const tool = options.customTools.find((candidate: { name: string }) => candidate.name === 'service');
      expect(tool).toBeTruthy();
      return {
        session: {
          sessionId: 'pi-session',
          messages,
          async prompt() {
            const toolResult = await tool!.execute(
              'tool-1',
              { action: 'publish', port: 5173, label: 'Web app', path: '/dashboard' },
              undefined,
              undefined,
              undefined,
            );
            const result = JSON.parse(toolResult.content[0]!.text) as { services: unknown[] };
            messages[0]!.content[0]!.text = `Published ${result.services.length} service`;
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn(() => () => {}),
        },
        extensionsResult: {},
      };
    });

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      sandboxKeepalive: createKeepalive(),
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'publish service',
      context,
      sandbox,
      async updateSessionContext(next) {
        context = { ...context, ...next };
        return context;
      },
      emit: async () => {},
    });

    expect(result.text).toBe('Published 1 service');
    expect(context.services).toEqual([
      {
        port: 5173,
        label: 'Web app',
        path: '/dashboard',
        providerSandboxId: 'sandbox-1',
        runtimeId: 'runtime-1',
      },
    ]);
  });

  it('registers repository, gh, and authenticated git tools', async () => {
    const sandbox = createMemorySandbox();
    const events: NormalizedEvent[] = [];
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: '' }],
        model: 'gpt-5.5',
        stopReason: 'stop',
      },
    ];
    const fetchImpl = vi.fn(async () => {
      return new Response(JSON.stringify({ html_url: 'https://github.com/manaflow-ai/manaflow/pull/7', number: 7 }), {
        status: 201,
      });
    });
    vi.stubGlobal('fetch', fetchImpl);

    piMock.createAgentSession.mockImplementation(async (options) => {
      expect(options.noTools).toBe('builtin');
      expect(options.tools).toBeUndefined();
      const repositoryTool = options.customTools.find((candidate: { name: string }) => candidate.name === 'repository');
      const gitTool = options.customTools.find((candidate: { name: string }) => candidate.name === 'git');
      const ghTool = options.customTools.find((candidate: { name: string }) => candidate.name === 'gh');
      expect(repositoryTool).toBeTruthy();
      expect(gitTool).toBeTruthy();
      expect(ghTool).toBeTruthy();
      return {
        session: {
          sessionId: 'pi-session',
          messages,
          async prompt() {
            await repositoryTool!.execute(
              'tool-1',
              { action: 'set', owner: 'manaflow-ai', repo: 'manaflow' },
              undefined,
              undefined,
              undefined,
            );
            const prepared = await repositoryTool!.execute(
              'tool-2',
              { action: 'prepare' },
              undefined,
              undefined,
              undefined,
            );
            const pushed = await gitTool!.execute(
              'tool-3',
              { args: ['push', 'origin', 'sp/test'] },
              undefined,
              undefined,
              undefined,
            );
            const pullRequest = await ghTool!.execute(
              'tool-4',
              {
                args: ['pr', 'create', '--title', 'Test PR', '--body', 'Body', '--head', 'sp/test', '--base', 'main'],
              },
              undefined,
              undefined,
              undefined,
            );
            messages[0]!.content[0]!.text = [
              prepared.content[0]!.type === 'text' ? prepared.content[0]!.text : '',
              pushed.content[0]!.type === 'text' ? pushed.content[0]!.text : '',
              pullRequest.content[0]!.type === 'text' ? pullRequest.content[0]!.text : '',
            ].join('\n');
          },
          abort: vi.fn(),
          dispose: vi.fn(),
          subscribe: vi.fn(() => () => {}),
        },
        extensionsResult: {},
      };
    });

    try {
      const result = await new PiRunner({
        model: 'openai-codex/gpt-5.5',
        authBase64: Buffer.from('{}').toString('base64'),
        repositoryAccess: {
          github: {
            async getRepositoryAccess() {
              return githubAccess;
            },
            listAllowedRepositories() {
              return ['manaflow-ai/manaflow'];
            },
          },
        },
      }).run({
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        prompt: 'work in repo',
        context: {},
        sandbox,
        emit: async (event) => {
          events.push(event);
        },
      });

      expect(result.text).toContain('Repository prepared: manaflow-ai/manaflow');
      expect(result.text).toContain('exitCode: 0');
      expect(result.text).toContain('https://github.com/manaflow-ai/manaflow/pull/7');
      expect(fetchImpl).toHaveBeenCalledWith(
        'https://api.github.com/repos/manaflow-ai/manaflow/pulls',
        expect.objectContaining({ method: 'POST' }),
      );
      expect(events.map((event) => event.type)).toContain('repository_ready');
    } finally {
      vi.unstubAllGlobals();
    }
  });

  it('runs repository setup scripts after repository_ready and sends failures to the prompt', async () => {
    const execCalls: ExecCall[] = [];
    const sandbox = createMemorySandbox();
    const execResponses = [
      { exitCode: 0, stdout: 'prepared\ndeputies-repo-setup:cloned=1\n', stderr: '' },
      { exitCode: 0, stdout: 'deputies-setup:run reason=cloned hash=abc123 exec=1\n', stderr: '' },
      { exitCode: 1, stdout: 'setup stdout', stderr: 'setup stderr' },
    ];
    sandbox.exec = async (input) => {
      execCalls.push({
        command: input.command,
        ...(input.cwd ? { cwd: input.cwd } : {}),
        ...(input.env ? { env: input.env } : {}),
        ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
      });
      const now = new Date();
      return {
        ...(execResponses.shift() ?? { exitCode: 0, stdout: '', stderr: '' }),
        startedAt: now,
        completedAt: now,
      };
    };
    const prompt = vi.fn();
    const messages = [
      {
        role: 'assistant',
        content: [{ type: 'text', text: 'handled setup failure' }],
        model: 'gpt-5.5',
        stopReason: 'stop',
      },
    ];
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages,
        prompt,
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });
    const events: NormalizedEvent[] = [];

    const result = await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      repositoryAccess: {
        github: {
          async getRepositoryAccess() {
            return githubAccess;
          },
        },
      },
      setupScript: { enabled: true, timeoutMs: 600_000 },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'work in repo',
      context: { repository: { provider: 'github', owner: 'manaflow-ai', repo: 'manaflow' } },
      sandbox,
      emit: async (event) => {
        events.push(event);
      },
    });

    expect(result.text).toBe('handled setup failure');
    expect(execCalls).toHaveLength(3);
    expect(execCalls[0]?.cwd).toBe('/workspace');
    expect(execCalls[1]).toMatchObject({ cwd: '/workspace/manaflow-ai/manaflow', timeoutMs: 30_000 });
    expect(execCalls[2]).toMatchObject({
      cwd: '/workspace/manaflow-ai/manaflow',
      env: { DEPUTIES: '1', DEPUTIES_SETUP: '1' },
      timeoutMs: 600_000,
    });
    expect(events.map((event) => event.type)).toEqual([
      'run_started',
      'skills_loaded',
      'repository_ready',
      'setup_script_started',
      'setup_script_finished',
      'run_completed',
    ]);
    expect(events[4]?.payload).toMatchObject({ isError: true, stdoutTail: 'setup stdout', stderrTail: 'setup stderr' });
    expect(prompt).toHaveBeenCalledWith(expect.stringContaining('.agents/setup failed'), {
      expandPromptTemplates: false,
    });
    expect(prompt.mock.calls[0]?.[0]).toContain('setup stdout\nsetup stderr');
  });

  it('does not probe for disabled repository setup scripts', async () => {
    const execCalls: ExecCall[] = [];
    const sandbox = createMemorySandbox();
    sandbox.exec = async (input) => {
      execCalls.push({ command: input.command, ...(input.cwd ? { cwd: input.cwd } : {}) });
      const now = new Date();
      return {
        exitCode: 0,
        stdout: 'prepared\ndeputies-repo-setup:cloned=1\n',
        stderr: '',
        startedAt: now,
        completedAt: now,
      };
    };
    const messages = [
      { role: 'assistant', content: [{ type: 'text', text: 'done' }], model: 'gpt-5.5', stopReason: 'stop' },
    ];
    piMock.createAgentSession.mockResolvedValue({
      session: {
        sessionId: 'pi-session',
        messages,
        prompt: vi.fn(),
        abort: vi.fn(),
        dispose: vi.fn(),
        subscribe: vi.fn(() => () => {}),
      },
      extensionsResult: {},
    });

    await new PiRunner({
      model: 'openai-codex/gpt-5.5',
      authBase64: Buffer.from('{}').toString('base64'),
      repositoryAccess: {
        github: {
          async getRepositoryAccess() {
            return githubAccess;
          },
        },
      },
      setupScript: { enabled: false, timeoutMs: 600_000 },
    }).run({
      sessionId: 'session-1',
      runId: 'run-1',
      messageId: 'message-1',
      prompt: 'work in repo',
      context: { repository: { provider: 'github', owner: 'manaflow-ai', repo: 'manaflow' } },
      sandbox,
      emit: async () => {},
    });

    expect(execCalls).toHaveLength(1);
    expect(execCalls[0]?.command).toContain(
      'git -c \'http.https://github.com/manaflow-ai/manaflow.git.extraHeader\'="$auth_header" -c core.hooksPath=/dev/null clone',
    );
    expect(execCalls[0]?.command).toContain('unset GITHUB_AUTH_HEADER');
    expect(execCalls[0]?.command).toContain('export GIT_CONFIG_GLOBAL=/dev/null');
    expect(execCalls[0]?.command).toContain('export GIT_CONFIG_SYSTEM=/dev/null');
  });

  it('rejects artifact paths outside the sandbox workspace and enforces post-read size', async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), 'deputies-pi-artifact-tool-'));
    try {
      const store = new MemoryStore();
      const eventsService = new EventService(store);
      const artifacts = new ArtifactService(store, eventsService, new FilesystemArtifactObjectStorage(tempDir));
      await store.createSession({
        id: 'session-1',
        status: 'active',
        title: 'Pi artifact session',
        createdAt: new Date(),
        updatedAt: new Date(),
        context: {},
      });
      const sandbox = createMemorySandbox();
      const services = {
        artifacts,
        sandbox,
        sessionId: 'session-1',
        runId: 'run-1',
        messageId: 'message-1',
        maxBytes: 4,
      };

      await sandbox.fs!.writeFile('/workspace/large.txt', '12345');
      const stat = sandbox.fs!.stat.bind(sandbox.fs!);
      sandbox.fs!.stat = async (filePath) => ({ ...(await stat(filePath)), size: 1 });
      await expect(
        createArtifactFromSandbox(services, { action: 'create', path: '/workspace/large.txt', type: 'file' }),
      ).rejects.toThrow('exceeds max size');
    } finally {
      await rm(tempDir, { recursive: true, force: true });
    }
  });
});

describe('createSandboxPiToolDefinitions', () => {
  it('backs read, write, edit, bash, grep, find, and ls with the sandbox handle', async () => {
    const execCalls: ExecCall[] = [];
    const sandbox = createMemorySandbox({ execCalls });
    const tools = createSandboxPiToolDefinitions(sandbox, sandbox.workspacePath);

    await executeTool(tools, 'write', { path: 'hello.txt', content: 'hello world' });
    await executeTool(tools, 'write', { path: 'src/app.ts', content: 'const greeting = "hello";\n' });
    await executeTool(tools, 'write', { path: 'src/notes.md', content: 'hello notes\n' });

    const readResult = await executeTool(tools, 'read', { path: 'hello.txt' });
    expect(textResult(readResult)).toBe('hello world');

    await executeTool(tools, 'edit', {
      path: 'hello.txt',
      edits: [{ oldText: 'world', newText: 'pi' }],
    });
    expect(await sandbox.fs!.readFile('/workspace/hello.txt')).toBe('hello pi');

    const bashResult = await executeTool(tools, 'bash', { command: 'pwd' });
    expect(textResult(bashResult)).toContain('ran: pwd');
    expect(execCalls).toHaveLength(1);
    expect(execCalls[0]).toMatchObject({ command: 'pwd', cwd: '/workspace' });
    expect(execCalls[0]?.env).toBeUndefined();

    const grepResult = await executeTool(tools, 'grep', { pattern: 'greeting', glob: '*.ts' });
    expect(textResult(grepResult)).toContain('src/app.ts:1: const greeting = "hello";');
    expect(textResult(grepResult)).not.toContain('notes.md');

    const cappedGrepResult = await executeTool(tools, 'grep', { pattern: 'greeting', context: 99, limit: 999 });
    expect(cappedGrepResult.details).toMatchObject({ contextCapped: 10, limitCapped: 200 });

    const findResult = await executeTool(tools, 'find', { pattern: '*.ts' });
    expect(textResult(findResult)).toContain('src/app.ts');
    expect(textResult(findResult)).not.toContain('src/notes.md');

    const highLimitFindResult = await executeTool(tools, 'find', { pattern: '*.ts', limit: 999_999 });
    expect(textResult(highLimitFindResult)).toContain('src/app.ts');

    const lsResult = await executeTool(tools, 'ls', { path: 'src' });
    expect(textResult(lsResult)).toContain('app.ts');
    expect(textResult(lsResult)).toContain('notes.md');
    expect(execCalls).toHaveLength(5);
    expect(execCalls[0]).toMatchObject({ command: 'pwd', cwd: '/workspace' });
    expect(execCalls[0]?.env).toBeUndefined();
    expect(execCalls[1]).toMatchObject({
      command: expect.stringContaining('rg --json'),
      cwd: '/workspace',
      timeoutMs: 30_000,
    });
    expect(execCalls[2]).toMatchObject({
      command: expect.stringContaining('--max-count 200'),
      cwd: '/workspace',
      timeoutMs: 30_000,
    });
    expect(execCalls[3]).toMatchObject({
      command: expect.stringContaining('fd'),
      cwd: '/workspace',
      timeoutMs: 30_000,
    });
    expect(execCalls[4]).toMatchObject({
      command: expect.stringContaining('--max-results 5000'),
      cwd: '/workspace',
      timeoutMs: 30_000,
    });
  });

  it('passes find cancellation to the sandbox exec call', async () => {
    const sandbox = createMemorySandbox();
    const controller = new AbortController();
    const exec = vi.fn(async () => {
      const now = new Date();
      return { exitCode: 0, stdout: '/workspace/src/app.ts\n', stderr: '', startedAt: now, completedAt: now };
    });
    sandbox.exec = exec;

    await executeTool(
      createSandboxPiToolDefinitions(sandbox, sandbox.workspacePath),
      'find',
      { pattern: '*.ts' },
      controller.signal,
    );

    expect(exec).toHaveBeenCalledWith(expect.objectContaining({ signal: controller.signal }));
  });

  it('reports sandbox find truncation when the requested limit exceeds the remote cap', async () => {
    const sandbox = createMemorySandbox();
    const now = new Date();
    sandbox.exec = vi.fn(async () => ({
      exitCode: 0,
      stdout: Array.from({ length: 5000 }, (_, index) => `/workspace/f${index}.ts`).join('\n'),
      stderr: '',
      startedAt: now,
      completedAt: now,
    }));

    const result = await executeTool(createSandboxPiToolDefinitions(sandbox, sandbox.workspacePath), 'find', {
      pattern: '*.ts',
      limit: 999_999,
    });

    expect(result.details).toMatchObject({ resultLimitReached: 5000 });
    expect(textResult(result)).toContain('[5000 results limit reached]');
    expect(sandbox.exec).toHaveBeenCalledWith(
      expect.objectContaining({ command: expect.stringContaining('--max-results 5000') }),
    );
  });

  it('does not pass Pi worker environment to sandbox bash commands', async () => {
    const originalSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET;
    process.env.GITHUB_OAUTH_CLIENT_SECRET = 'worker-secret';
    try {
      const execCalls: ExecCall[] = [];
      const sandbox = createMemorySandbox({ execCalls });

      await executeTool(createSandboxPiToolDefinitions(sandbox, sandbox.workspacePath), 'bash', {
        command: 'printenv',
      });

      expect(execCalls).toHaveLength(1);
      expect(execCalls[0]?.env).toBeUndefined();
    } finally {
      if (originalSecret === undefined) delete process.env.GITHUB_OAUTH_CLIENT_SECRET;
      else process.env.GITHUB_OAUTH_CLIENT_SECRET = originalSecret;
    }
  });

  it('skips grep context reads for large files', async () => {
    const sandbox = createMemorySandbox();
    await sandbox.fs!.writeFile('/workspace/large.txt', Buffer.alloc(1024 * 1024 + 1, 65));
    const now = new Date();
    sandbox.exec = async () => ({
      exitCode: 0,
      stdout: `${JSON.stringify({
        type: 'match',
        data: {
          path: { text: '/workspace/large.txt' },
          line_number: 1,
          lines: { text: 'A\n' },
        },
      })}\n`,
      stderr: '',
      startedAt: now,
      completedAt: now,
    });

    const result = await executeTool(createSandboxPiToolDefinitions(sandbox, sandbox.workspacePath), 'grep', {
      pattern: 'A',
      path: 'large.txt',
      context: 1,
    });

    expect(textResult(result)).toContain('context skipped; file exceeds 1048576 bytes');
    expect(result.details).toMatchObject({ linesTruncated: true });
  });
});

describe('PostgresPiSessionStore', () => {
  it('uses two-int advisory lock keys instead of hashtext', async () => {
    const queries: Array<{ text: string; values?: unknown[] }> = [];
    const client = {
      query: vi.fn(async (text: string, values?: unknown[]) => {
        queries.push({ text, ...(values ? { values } : {}) });
        return { rows: [] };
      }),
      release: vi.fn(),
    };
    const pool = { connect: vi.fn(async () => client) };
    const store = new PostgresPiSessionStore(pool as never);

    await expect(store.withLock('00000000-0000-4000-8000-000000000701', async () => 'locked')).resolves.toBe('locked');

    expect(queries).toEqual([
      { text: 'SELECT pg_advisory_lock($1::int, $2::int)', values: [0, 1793] },
      { text: 'SELECT pg_advisory_unlock($1::int, $2::int)', values: [0, 1793] },
    ]);
    expect(client.release).toHaveBeenCalledOnce();
  });
});

async function executeTool(
  tools: ReturnType<typeof createSandboxPiToolDefinitions>,
  name: string,
  params: unknown,
  signal?: AbortSignal,
): Promise<SandboxPiToolResult> {
  const tool = tools.find((candidate) => candidate.name === name);
  if (!tool) throw new Error(`Missing tool: ${name}`);
  return tool.execute('tool-call', params, signal, undefined, undefined as never);
}

function textResult(result: SandboxPiToolResult): string {
  return result.content
    .filter((item) => item.type === 'text')
    .map((item) => item.text)
    .join('\n');
}

function createKeepalive(): SandboxKeepaliveService {
  return {
    async extend() {
      return {
        keepaliveUntil: new Date('2026-05-15T00:00:00.000Z'),
        providerSync: 'not_supported' as const,
        record: {} as never,
      };
    },
  } as unknown as SandboxKeepaliveService;
}

const githubAccess: GitHubRepositoryAccess = {
  provider: 'github',
  owner: 'manaflow-ai',
  repo: 'manaflow',
  cloneUrl: 'https://github.com/manaflow-ai/manaflow.git',
  expiresAt: new Date('2026-05-06T01:00:00.000Z'),
  auth: { type: 'bearer', token: 'ghs_secret_token' },
};

function createMemorySandbox(options: { execCalls?: ExecCall[] } = {}): SandboxHandle {
  const fs = new MemorySandboxFileSystem('/workspace');
  return {
    provider: 'memory',
    providerSandboxId: 'sandbox-1',
    sessionId: 'session-1',
    workspacePath: '/workspace',
    metadata: {},
    capabilities: {
      persistentFilesystem: true,
      snapshots: false,
      stopStart: false,
      exec: true,
      filesystem: true,
      streamingLogs: false,
      portForwarding: false,
      serviceEndpoints: false,
    },
    fs,
    async exec(input) {
      options.execCalls?.push({
        command: input.command,
        ...(input.cwd ? { cwd: input.cwd } : {}),
        ...(input.env ? { env: input.env } : {}),
        ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
      });
      const now = new Date();
      if (input.command.startsWith('rg ')) {
        return {
          exitCode: 0,
          stdout: `${JSON.stringify({
            type: 'match',
            data: {
              path: { text: '/workspace/src/app.ts' },
              line_number: 1,
              lines: { text: 'const greeting = "hello";\n' },
            },
          })}\n`,
          stderr: '',
          startedAt: now,
          completedAt: now,
        };
      }
      if (input.command.includes('exec fd') || input.command.includes('exec fdfind')) {
        return { exitCode: 0, stdout: '/workspace/src/app.ts\n', stderr: '', startedAt: now, completedAt: now };
      }
      return { exitCode: 0, stdout: `ran: ${input.command}`, stderr: '', startedAt: now, completedAt: now };
    },
  };
}
