import { describe, it, expect, vi, beforeEach } from "vitest";

// Import pure functions that don't need mocks
import {
  shouldNudge,
  detectFabrication,
  buildRepeatedQuestionNudge,
  buildRepeatedToolStopMessage,
  detectToolRefusedDespiteAvailability,
  phaseRequiresToolCall,
  detectUnsavedEvidence,
  enrichToolDescriptions,
} from "./agentic-loop";

vi.mock("@dpf/db", () => ({
  prisma: {
    agentModelConfig: {
      findUnique: vi.fn(),
    },
    toolExecution: {
      create: vi.fn(),
    },
    user: {
      findUnique: vi.fn(),
    },
    platformIssueReport: {
      create: vi.fn(),
    },
  },
}));
vi.mock("@/lib/routed-inference", () => ({
  routeAndCall: vi.fn(),
}));
vi.mock("@/lib/mcp-tools", () => ({
  executeTool: vi.fn(),
  PLATFORM_TOOLS: [],
}));
vi.mock("@/lib/mcp-governed-execute", () => ({
  governedExecuteTool: vi.fn(),
}));

import { runAgenticLoop } from "./agentic-loop";
import { routeAndCall } from "@/lib/routed-inference";
import { executeTool } from "@/lib/mcp-tools";
import { governedExecuteTool } from "@/lib/mcp-governed-execute";
import { prisma } from "@dpf/db";

// Helper to build a mock RoutedInferenceResult
function mockResult(overrides: {
  content: string;
  toolCalls?: Array<{ id: string; name: string; arguments: Record<string, unknown> }>;
  inputTokens?: number;
  outputTokens?: number;
  providerId?: string;
  modelId?: string;
  toolsStripped?: boolean;
}) {
  return {
    content: overrides.content,
    providerId: overrides.providerId ?? "anthropic-sub",
    modelId: overrides.modelId ?? "claude-haiku-4-5-20251001",
    downgraded: false,
    downgradeMessage: null,
    toolsStripped: overrides.toolsStripped ?? false,
    inputTokens: overrides.inputTokens ?? 100,
    outputTokens: overrides.outputTokens ?? 50,
    toolCalls: overrides.toolCalls ?? [],
    routeDecision: {} as any,
    responseId: undefined,
  };
}

describe("shouldNudge", () => {
  it("nudges on first iteration when model returns text-only with tools available", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: true, executedToolCount: 0, responseLength: 44,
      responseText: "I can help with that.",
    })).toBe(true);
  });

  // Regression for the setup-tour /workspace COO contradiction: route persona
  // says "no tool calls, 2-3 sentences", coworker complies, but nudge fires
  // "use a tool now" → coworker surfaces the contradiction to the user.
  // Companion guard to c70a7db6 (which fixed the fabrication-detection arm
  // of the same problem). Narrowed to the setup-tour signal so non-setup
  // advise routes (e.g. /finance) still nudge + surface the local-model
  // diagnostic.
  it("does not nudge on a setup-tour turn (route persona says 'no tool calls')", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: true,             // tools delivered (read-only / context tools)
      isSetupTourTurn: true,      // SetupOverlay "[Setup step: …]" auto-message
      executedToolCount: 0, responseLength: 44,
      responseText: "Welcome to your workspace! Day-to-day work happens here.",
    })).toBe(false);
  });

  it("does not nudge when response is a short clarifying question", () => {
    // HR case: "add John as employee" → agent correctly asks for last name
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: true, executedToolCount: 0, responseLength: 35,
      responseText: "What is John's last name?",
    })).toBe(false);
  });

  it("does not nudge when response is a multi-field clarifying question", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: true, executedToolCount: 0, responseLength: 70,
      responseText: "To add this employee I need their last name and department — could you provide those?",
    })).toBe(false);
  });

  it("nudges when response is a long non-question text (model stalled)", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: true, executedToolCount: 0, responseLength: 120,
      responseText: "I can help you add an employee. The system has several tools available including create_employee and list_departments that you can use.",
    })).toBe(false);
  });

  it("does not nudge when no tools available", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 0, maxIterations: 40,
      hasTools: false, executedToolCount: 0, responseLength: 44,
    })).toBe(false);
  });

  it("does not nudge on first iteration when response is long and not narration", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 2, maxIterations: 40,
      hasTools: true, executedToolCount: 3, responseLength: 250,
      responseText: "The feature brief describes the notification system and acceptance criteria.",
    })).toBe(false);
  });

  it("nudges when response contains code narration patterns", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 3, maxIterations: 40,
      hasTools: true, executedToolCount: 5, responseLength: 500,
      responseText: "Here's the exact code to add to agent-routing.ts for each agent.",
    })).toBe(true);
  });

  it("does not nudge governed External Access permission requests", () => {
    expect(shouldNudge({
      continuationNudges: 0,
      iteration: 0,
      maxIterations: 40,
      hasTools: true,
      executedToolCount: 0,
      responseLength: 140,
      responseText: "External Access is off for this page. Would you like me to use External Access so I can verify the official public sources before I answer?",
    })).toBe(false);
  });

  it("nudges when tools were used and model stalls with short response", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 3, maxIterations: 40,
      hasTools: true, executedToolCount: 2, responseLength: 5,
    })).toBe(true);
  });

  it("does not nudge a short summary after an authoritative action tool", () => {
    expect(shouldNudge({
      continuationNudges: 0, iteration: 3, maxIterations: 40,
      hasTools: true, executedToolCount: 1, responseLength: 42,
      responseText: "Discovery triage skipped for today's cadence.",
      hasAuthoritativeToolExecution: true,
    })).toBe(false);
  });

  it("does not nudge if already nudged once", () => {
    expect(shouldNudge({
      continuationNudges: 1, iteration: 0, maxIterations: 40,
      hasTools: true, executedToolCount: 0, responseLength: 44,
    })).toBe(false);
  });
});

describe("buildRepeatedToolStopMessage", () => {
  it("points non-build repeated tool loops to the activity trail without naming tools", () => {
    const msg = buildRepeatedToolStopMessage({
      toolName: "suggest_campaign_ideas",
      count: 3,
      routeContext: "/customer/marketing",
      reasonHint: "",
    });

    // Rule #5: do not leak tool names, call counts, or "reasonHint" architecture
    // language into the user-facing message.
    expect(msg).not.toContain("suggest_campaign_ideas");
    expect(msg).not.toMatch(/\d times with the same arguments/);
    expect(msg).not.toContain("coworker execution issue");
    expect(msg).toMatch(/activity panel/i);
    expect(msg.toLowerCase()).toContain("got stuck");
  });

  it("points Build Studio routes at the build details panel without naming tools", () => {
    const msg = buildRepeatedToolStopMessage({
      toolName: "saveBuildEvidence",
      count: 3,
      routeContext: "/build",
      reasonHint: "",
    });

    expect(msg).not.toContain("saveBuildEvidence");
    expect(msg).not.toMatch(/\d times with the same arguments/);
    expect(msg).toMatch(/build's details panel|build details/i);
    expect(msg.toLowerCase()).toContain("got stuck");
  });
});

describe("enrichToolDescriptions — review-fail veto on write tool", () => {
  const baseTools = [
    { type: "function", name: "saveBuildEvidence", description: "Save evidence", inputSchema: {} },
    { type: "function", name: "reviewDesignDoc", description: "Review design doc", inputSchema: {} },
  ];

  it("annotates saveBuildEvidence when reviewDesignDoc returned decision:fail", () => {
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1" }, result: { success: true, message: "Evidence saved." } },
      { name: "reviewDesignDoc", args: {}, result: { success: true, message: "Design review: fail.", data: { review: { decision: "fail", rationale: "Missing codebase research section." } } } },
    ]);
    const save = enriched.find((t) => t.name === "saveBuildEvidence")!;
    expect(save.description).toContain("REVIEW REJECTION");
    expect(save.description).toContain("Missing codebase research section.");
    expect(save.description).toContain("submitting identical arguments will be rejected");
  });

  it("clears the veto on a later passing review", () => {
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1" }, result: { success: true, message: "Evidence saved." } },
      { name: "reviewDesignDoc", args: {}, result: { success: true, data: { review: { decision: "fail", rationale: "Missing research." } } } },
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v2" }, result: { success: true, message: "Evidence saved." } },
      { name: "reviewDesignDoc", args: {}, result: { success: true, data: { review: { decision: "pass" } } } },
    ]);
    const save = enriched.find((t) => t.name === "saveBuildEvidence")!;
    expect(save.description).toBe("Save evidence");
  });

  it("keeps the veto sticky across a subsequent saveBuildEvidence success (intermediate save still rejected)", () => {
    // This is the FB-C26D5B50 scenario: the next saveBuildEvidence succeeds at
    // the protocol level but the review veto from the prior failed review
    // must remain visible until a passing review clears it.
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1" }, result: { success: true, message: "Evidence saved." } },
      { name: "reviewDesignDoc", args: {}, result: { success: true, data: { review: { decision: "fail", rationale: "Missing codebase research." } } } },
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1-again" }, result: { success: true, message: "Evidence saved." } },
    ]);
    const save = enriched.find((t) => t.name === "saveBuildEvidence")!;
    expect(save.description).toContain("REVIEW REJECTION");
    expect(save.description).toContain("Missing codebase research.");
  });

  it("treats data.blocked=true as a veto even without decision:fail", () => {
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "reviewDesignDoc", args: {}, result: { success: true, data: { review: { decision: "pass" }, blocked: true } } },
    ]);
    const save = enriched.find((t) => t.name === "saveBuildEvidence")!;
    expect(save.description).toContain("REVIEW REJECTION");
  });

  it("does nothing when there are no review failures and no tool errors", () => {
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1" }, result: { success: true, message: "Evidence saved." } },
      { name: "reviewDesignDoc", args: {}, result: { success: true, data: { review: { decision: "pass" } } } },
    ]);
    expect(enriched.find((t) => t.name === "saveBuildEvidence")!.description).toBe("Save evidence");
    expect(enriched.find((t) => t.name === "reviewDesignDoc")!.description).toBe("Review design doc");
  });

  it("preserves the existing error-warning path when a tool itself failed", () => {
    const enriched = enrichToolDescriptions(baseTools, [
      { name: "saveBuildEvidence", args: { field: "designDoc", value: "v1" }, result: { success: false, error: "Network timeout on persistence layer" } },
    ]);
    const save = enriched.find((t) => t.name === "saveBuildEvidence")!;
    expect(save.description).toContain("WARNING");
    expect(save.description).toContain("Network timeout on persistence layer");
  });
});

describe("buildRepeatedQuestionNudge", () => {
  it("uses marketing tools on the marketing route instead of Build Studio tools", () => {
    const message = buildRepeatedQuestionNudge({
      routeContext: "/customer/marketing",
      tools: [
        { name: "get_marketing_summary", description: "", inputSchema: {}, requiredCapability: null, sideEffect: false },
        { name: "suggest_campaign_ideas", description: "", inputSchema: {}, requiredCapability: null, sideEffect: false },
        { name: "save_marketing_review", description: "", inputSchema: {}, requiredCapability: null, sideEffect: true },
      ],
    });

    expect(message).toContain("Your marketing tools are active");
    expect(message).toContain("save_marketing_review");
    expect(message).not.toContain("saveBuildEvidence");
  });

  it("keeps Build Studio recovery guidance on build routes", () => {
    const message = buildRepeatedQuestionNudge({
      routeContext: "/build",
      tools: [
        { name: "saveBuildEvidence", description: "", inputSchema: {}, requiredCapability: null, sideEffect: true },
        { name: "search_project_files", description: "", inputSchema: {}, requiredCapability: null, sideEffect: false },
      ],
    });

    expect(message).toContain("saveBuildEvidence");
    expect(message).toContain("search_project_files");
  });
});

describe("detectFabrication", () => {
  it("detects completion claim with zero tools executed", () => {
    expect(detectFabrication("I've built the feature and deployed it.", 0, false)).toBe(true);
  });

  it("does not flag when build tools were executed", () => {
    expect(detectFabrication("I've built the feature.", 3, false, ["saveBuildEvidence", "generate_code"])).toBe(false);
  });

  it("does not flag when proposal was returned", () => {
    expect(detectFabrication("I've created the deployment.", 0, true)).toBe(false);
  });

  it("does not flag informational responses", () => {
    expect(detectFabrication("The feature brief describes a notification system.", 0, false)).toBe(false);
  });

  it("detects 'TESTS PASS' with no tools", () => {
    expect(detectFabrication("TESTS PASS\n✅ All 4 criteria met", 0, false)).toBe(true);
  });

  it("detects 'SHIPPED TO STAGING'", () => {
    expect(detectFabrication("SHIPPED TO STAGING. Feature live at /build.", 0, false)).toBe(true);
  });

  it("detects plan-ready claims with zero tools executed", () => {
    expect(
      detectFabrication(
        "Planning is done; the next required action is approving Start Implementation for FB-9B19098C in the product UI.",
        0,
        false,
      ),
    ).toBe(true);
  });

  it("detects plan-summary narration with zero tools executed", () => {
    expect(
      detectFabrication(
        "I refined the plan to 5 small UI-only tasks across 4 existing files and the next approval in the product UI is Start Implementation for FB-9B19098C.",
        0,
        false,
      ),
    ).toBe(true);
  });

  it("detects narration with only read tools (no build tools)", () => {
    expect(detectFabrication(
      "Here's the exact code to add to agent-routing.ts:\n```{ label: 'Analyze' }```",
      5, false, ["read_project_file", "search_project_files"],
    )).toBe(true);
  });

  it("detects plan-ready claims when only read tools were executed", () => {
    expect(detectFabrication(
      "Plan ready — 5 tasks across 4 files, and Start Implementation is the correct next approval in the product UI.",
      2,
      false,
      ["list_project_directory", "search_project_files"],
    )).toBe(true);
  });

  it("does not flag narration when build tools were used", () => {
    expect(detectFabrication(
      "Here's what I added to the code.",
      3, false, ["saveBuildEvidence", "propose_file_change"],
    )).toBe(false);
  });

  it("does not flag completion after a side-effect action tool was used", () => {
    expect(detectFabrication(
      "Discovery triage completed for today's cadence.",
      1,
      false,
      ["run_discovery_triage"],
      new Set(["run_discovery_triage"]),
    )).toBe(false);
  });

  it("does not flag advise-mode guidance when no authoritative tool is available", () => {
    // Setup-tour case: the route persona is asked to "guide me through this
    // step" with only read-only tools (wiki_query, search_knowledge). Its
    // guidance naturally says "configured"/"set up", but with no action/build
    // tool to call there is nothing it could have fabricated.
    expect(detectFabrication(
      "Once your operating hours are configured, bookings and availability stay aligned.",
      0,
      false,
      [],
      new Set(),
      false, // hasAuthoritativeToolAvailable
    )).toBe(false);
  });

  it("still flags completion claims when an authoritative tool was available but unused", () => {
    expect(detectFabrication(
      "I've configured your operating hours.",
      0,
      false,
      [],
      new Set(["create_backlog_item"]),
      true, // hasAuthoritativeToolAvailable
    )).toBe(true);
  });
});

describe("runAgenticLoop", () => {
  beforeEach(() => {
    vi.resetAllMocks();
    vi.mocked(prisma.agentModelConfig.findUnique).mockResolvedValue(null as never);
    vi.mocked(prisma.toolExecution.create).mockResolvedValue({} as never);
    vi.mocked(prisma.user.findUnique).mockResolvedValue({
      isSuperuser: true,
      groups: [{ platformRole: { roleId: "ceo" } }],
    } as never);
    vi.mocked(governedExecuteTool).mockImplementation(async (args: any) =>
      executeTool(args.toolName, args.rawParams, args.userId, args.context as any) as any,
    );
  });
  const baseParams = {
    chatHistory: [{ role: "user" as const, content: "search for agent code" }],
    systemPrompt: "You are a helpful assistant.",
    sensitivity: "internal" as const,
    tools: [{ name: "search_project_files", description: "Search", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false }],
    toolsForProvider: [{ type: "function", function: { name: "search_project_files", description: "Search", parameters: {} } }],
    userId: "user-1",
    routeContext: "/build",
    agentId: "software-engineer",
    threadId: "thread-1",
  };

  it("explains missing tool-use capacity instead of masking it as a temporary outage", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    mockRoute.mockRejectedValueOnce(new Error(
      "No eligible endpoints for task 'unknown': No endpoint satisfies agent capability floor (EP-AGENT-CAP-002). Missing: toolUse.",
    ));

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/admin/issue-reports",
      agentId: "admin-assistant",
    });

    expect(result.content).toContain("No tool-use-capable AI provider is available");
    expect(result.content).toContain("Platform > AI > Model Assignment");
    expect(result.providerId).toBe("unknown");
    expect(result.modelId).toBe("unknown");
  });

  it("explains exhausted tool-using endpoint failures instead of masking them as temporary", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    mockRoute.mockRejectedValueOnce(new Error(
      'All endpoints failed for onboarding. Attempts: [{"endpointId":"local","error":"Network error calling local: fetch failed"},{"endpointId":"local","error":"skipped local fallback: 58 tools exceeds threshold for small local models"}]',
    ));

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/admin/issue-reports",
      agentId: "admin-assistant",
    });

    expect(result.content).toContain("No tool-use-capable AI provider is available");
    expect(result.content).toContain("Platform > AI > Model Assignment");
    expect(result.providerId).toBe("unknown");
    expect(result.modelId).toBe("unknown");
  });

  it("executes tools through the governed lifecycle path", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Searching.",
        toolCalls: [{ id: "toolu_01A", name: "search_project_files", arguments: { query: "agent" } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "I found the relevant files and summarized the implementation path with enough detail for the next step to continue cleanly.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "The search output lists the relevant files and the likely implementation path. It gives enough context for a follow-up step and keeps the answer limited to investigation notes.",
      }));

    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "Found files",
    });

    await runAgenticLoop(baseParams);

    expect(governedExecuteTool).toHaveBeenCalledWith(
      expect.objectContaining({
        toolName: "search_project_files",
        rawParams: { query: "agent" },
        userId: "user-1",
        source: "agentic-loop",
        context: expect.objectContaining({
          agentId: "software-engineer",
          threadId: "thread-1",
          routeContext: "/build",
        }),
      }),
    );
  });

  it("returns a visible diagnostic instead of issuing a second local-model nudge on non-build tool turns", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I can check that for you.",
      providerId: "local",
      modelId: "docker.io/ai/gemma4:latest",
      toolCalls: [],
    }));

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/finance",
      agentId: "finance-agent",
      tools: [{ name: "get_finance_summary", description: "Get finance summary", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false }],
      toolsForProvider: [{ type: "function", function: { name: "get_finance_summary", description: "Get finance summary", parameters: {} } }],
    });

    expect(mockRoute).toHaveBeenCalledTimes(1);
    expect(result.providerId).toBe("local");
    // User-facing message must NOT leak infrastructure names / model IDs (rule #5).
    expect(result.content).not.toContain("Docker Model Runner");
    expect(result.content).not.toContain("gemma");
    expect(result.content).not.toContain("tool-capable provider");
    expect(result.content).not.toMatch(/required tool call/);
    // G2 (2026-05-23): must NOT promise re-routing the loop never performs.
    expect(result.content.toLowerCase()).not.toContain("route through a different");
    // Must explain the situation (running on a local model) and point at
    // the actual fix (connect a stronger provider).
    expect(result.content.toLowerCase()).toMatch(/local ai|local model/);
    expect(result.content).toMatch(/Platform > AI > Providers/);
  });

  it("returns the same local-model diagnostic on Build Studio routes (FB-71FB3A53)", async () => {
    // Regression guard: the prior carve-out at agentic-loop.ts:1308 excluded
    // /build routes from the local-model early-exit guard, which led to
    // 200-iteration spins when the preferred provider fell back to local on
    // a Build Studio thread. Dropping the carve-out means /build routes get
    // the same diagnostic exit any other route would.
    const mockRoute = vi.mocked(routeAndCall);

    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I can check that for you.",
      providerId: "local",
      modelId: "docker.io/ai/gemma4:latest",
      toolCalls: [],
    }));

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/build",
      agentId: "build-specialist",
    });

    expect(mockRoute).toHaveBeenCalledTimes(1);
    expect(result.providerId).toBe("local");
    // G2 (2026-05-23): /build routes get the same honest local-model diagnostic.
    expect(result.content.toLowerCase()).not.toContain("route through a different");
    expect(result.content.toLowerCase()).toMatch(/local ai|local model/);
    expect(result.content).toMatch(/Platform > AI > Providers/);
  });

  it("does not surface raw tool_use JSON when a non-build tool loop hits the runtime limit", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);
    const dateNow = vi.spyOn(Date, "now");

    dateNow
      .mockReturnValueOnce(0)
      .mockReturnValueOnce(1)
      .mockReturnValueOnce(2)
      .mockReturnValueOnce(3)
      .mockReturnValue(121_000);
    mockRoute.mockResolvedValueOnce(mockResult({
      content: '{"type":"tool_use","id":"toolu_12","name":"doc_search","input":{"query":"month-to-date P&L"}}',
      providerId: "codex",
      modelId: "gpt-5.3-codex",
      toolCalls: [
        {
          id: "toolu_12",
          name: "doc_search",
          arguments: { query: "month-to-date P&L" },
        },
      ],
    }));
    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "No matching documents found.",
    });

    try {
      const result = await runAgenticLoop({
        ...baseParams,
        routeContext: "/finance",
        agentId: "finance-agent",
        tools: [{ name: "doc_search", description: "Search docs", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false }],
        toolsForProvider: [{ type: "function", function: { name: "doc_search", description: "Search docs", parameters: {} } }],
      });

      expect(result.executedTools).toHaveLength(1);
      expect(result.content).not.toContain('"type":"tool_use"');
      expect(result.content).not.toContain("toolu_12");
      expect(result.content).toContain("runtime limit");
      expect(result.content).toContain("doc_search");
    } finally {
      dateNow.mockRestore();
    }
  });

  it("forwards taskRunId into governedExecuteTool context on every tool call", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Searching.",
        toolCalls: [{ id: "toolu_01A", name: "search_project_files", arguments: { query: "agent" } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "I found the relevant files and summarized the implementation path with enough detail for the next step to continue cleanly.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "The search output lists the relevant files and the likely implementation path. It gives enough context for a follow-up step and keeps the answer limited to investigation notes.",
      }));

    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "Found files",
    });

    await runAgenticLoop({
      ...baseParams,
      taskRunId: "TR-SCHED-TESTRUN",
    });

    expect(governedExecuteTool).toHaveBeenCalledWith(
      expect.objectContaining({
        context: expect.objectContaining({
          taskRunId: "TR-SCHED-TESTRUN",
          agentId: "software-engineer",
          threadId: "thread-1",
          routeContext: "/build",
        }),
      }),
    );
  });

  it("forwards apiTokenId into governedExecuteTool context on external coworker runs", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Searching.",
        toolCalls: [{ id: "toolu_01A", name: "search_project_files", arguments: { query: "agent" } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "I found the relevant files and summarized the implementation path with enough detail for the next step to continue cleanly.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "The search output lists the relevant files and the likely implementation path. It gives enough context for a follow-up step and keeps the answer limited to investigation notes.",
      }));

    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "Found files",
    });

    await runAgenticLoop({
      ...baseParams,
      taskRunId: "TR-MCP-TESTRUN",
      apiTokenId: "tok_remote",
    });

    expect(governedExecuteTool).toHaveBeenCalledWith(
      expect.objectContaining({
        context: expect.objectContaining({
          taskRunId: "TR-MCP-TESTRUN",
          apiTokenId: "tok_remote",
        }),
      }),
    );
  });

  it("creates structured messages with tool call IDs after tool execution", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    // Iteration 0: model calls a tool
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Searching for agent code.",
      inputTokens: 100,
      outputTokens: 50,
      toolCalls: [{ id: "toolu_01A", name: "search_project_files", arguments: { query: "agent" } }],
    }));

    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "Found 3 files",
      data: { files: ["a.ts", "b.ts", "c.ts"] },
    });

    // Iteration 1: model responds with text only (short → nudge fires)
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I found 3 agent-related files: a.ts, b.ts, c.ts.",
      inputTokens: 200,
      outputTokens: 80,
    }));

    // Iteration 2: after nudge, model gives longer final answer → exits loop
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I found 3 agent-related files: a.ts, b.ts, c.ts. These contain the component structure, routing logic, and message state management you'll need for the alert feature. The AgentFAB component already has a status indicator.",
      inputTokens: 300,
      outputTokens: 100,
    }));

    const result = await runAgenticLoop(baseParams);

    expect(result.content).toContain("I found 3 agent-related files");
    expect(result.executedTools).toHaveLength(1);

    // Verify the messages passed to the second routeAndCall call
    const secondCallMessages = mockRoute.mock.calls[1]![0]; // first arg is messages

    // Should have: user msg, assistant with toolCalls, tool result
    const assistantMsg = secondCallMessages.find((m: any) => m.role === "assistant" && m.toolCalls);
    expect(assistantMsg).toBeDefined();
    expect(assistantMsg!.toolCalls![0]!.id).toBe("toolu_01A");
    expect(assistantMsg!.toolCalls![0]!.name).toBe("search_project_files");

    const toolMsg = secondCallMessages.find((m: any) => m.role === "tool");
    expect(toolMsg).toBeDefined();
    expect(toolMsg!.toolCallId).toBe("toolu_01A");
    expect(toolMsg!.content).toContain("Found 3 files");
  });

  it("keeps route-level provider preferences when agent config does not pin a provider", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    vi.mocked(prisma.agentModelConfig.findUnique).mockResolvedValue({
      agentId: "licensing-specialist",
      minimumTier: "strong",
      budgetClass: "balanced",
      pinnedProviderId: null,
      pinnedModelId: null,
      minimumCapabilities: { toolUse: true },
      minimumContextTokens: 32000,
    } as never);

    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Licensing investigation ready.",
      inputTokens: 60,
      outputTokens: 30,
    }));

    await runAgenticLoop({
      ...baseParams,
      routeContext: "/compliance/licensing",
      agentId: "licensing-specialist",
      taskType: "conversation",
      modelRequirements: {
        defaultMinimumTier: "strong",
        defaultBudgetClass: "balanced",
        preferredProviderId: "anthropic",
      },
      tools: [],
      toolsForProvider: undefined,
    });

    expect(mockRoute).toHaveBeenCalledWith(
      expect.any(Array),
      expect.any(String),
      "internal",
      expect.objectContaining({
        preferredProviderId: "anthropic",
        budgetClass: "balanced",
        minimumCapabilities: { toolUse: true },
        agentMinimumContextTokens: 32000,
      }),
    );
  });

  it("returns direct conversational provider-status answers without forcing diagnostics", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Yes - I'm active on the anthropic-sub provider and ready to go.",
        providerId: "anthropic-sub",
        modelId: "claude-haiku-4-5-20251001",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Checking endpoint probes.",
        providerId: "anthropic-sub",
        modelId: "claude-haiku-4-5-20251001",
        toolCalls: [
          {
            id: "toolu_diagnostics_1",
            name: "run_endpoint_tests",
            arguments: { endpointId: "gemini", probesOnly: true },
          },
        ],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Gemini diagnostics are currently failing.",
        providerId: "anthropic-sub",
        modelId: "claude-haiku-4-5-20251001",
      }));

    mockExecuteTool.mockResolvedValueOnce({
      success: false,
      message: "Probes 0/8 passed",
    });

    const result = await runAgenticLoop({
      ...baseParams,
      chatHistory: [{ role: "user", content: "do you work?" }],
      routeContext: "/platform/ai/providers/anthropic-sub",
      agentId: "ai-ops-engineer",
      taskType: "unknown",
      tools: [
        {
          name: "run_endpoint_tests",
          description: "Run endpoint diagnostics",
          inputSchema: {},
          requiredCapability: null,
          executionMode: "immediate" as const,
          sideEffect: false,
        },
      ],
      toolsForProvider: [
        {
          type: "function",
          function: {
            name: "run_endpoint_tests",
            description: "Run endpoint diagnostics",
            parameters: {},
          },
        },
      ],
    });

    expect(result.content).toBe("Yes - I'm active on the anthropic-sub provider and ready to go.");
    expect(result.executedTools).toHaveLength(0);
    expect(mockExecuteTool).not.toHaveBeenCalled();
    expect(mockRoute).toHaveBeenCalledTimes(1);
  });

  it("returns text-only response when no tool calls (after nudge)", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    // First response is a generic question, which the loop now treats as a
    // legitimate conversational reply rather than force-nudging into tool use.
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Hello! How can I help?",
      inputTokens: 50,
      outputTokens: 20,
    }));

    // Second response after nudge: still text-only → exits loop
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I can help you build features. What would you like to create?",
      inputTokens: 80,
      outputTokens: 30,
    }));

    const result = await runAgenticLoop(baseParams);
    expect(result.content).toBe("Hello! How can I help?");
    expect(result.executedTools).toHaveLength(0);
    expect(result.proposal).toBeNull();
  });

  it("handles multiple tool calls in one iteration", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    // Model calls two tools at once
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Searching and reading.",
      inputTokens: 100,
      outputTokens: 50,
      toolCalls: [
        { id: "toolu_01A", name: "search_project_files", arguments: { query: "agent" } },
        { id: "toolu_01B", name: "search_project_files", arguments: { query: "coworker" } },
      ],
    }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: true, message: "Found 3 files" })
      .mockResolvedValueOnce({ success: true, message: "Found 2 files" });

    // Iteration 1: model responds with text only (short → nudge fires)
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Found agent and coworker files.",
      inputTokens: 200,
      outputTokens: 80,
    }));

    // Iteration 2: after nudge, model gives longer response → exits loop
    mockRoute.mockResolvedValueOnce(mockResult({
      content: "I found agent-related files in the project. The main coworker panel is in AgentCoworkerPanel.tsx and the agent routing is in agent-routing.ts. Both files contain the patterns you need for your feature.",
      inputTokens: 300,
      outputTokens: 100,
    }));

    const result = await runAgenticLoop(baseParams);
    expect(result.executedTools).toHaveLength(2);

    // Should have ONE assistant message and TWO tool result messages in second call
    const secondCallMessages = mockRoute.mock.calls[1]![0];
    const toolMsgs = secondCallMessages.filter((m: any) => m.role === "tool");
    expect(toolMsgs).toHaveLength(2);
    expect(toolMsgs[0]!.toolCallId).toBe("toolu_01A");
    expect(toolMsgs[1]!.toolCallId).toBe("toolu_01B");
  });

  it("compacts oversized tool history before the next routing call", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Reading files.",
        toolCalls: [{ id: "toolu_01A", name: "read_project_file", arguments: { path: "big-file.ts" } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Finished reading the file and condensed the key findings into a short summary so the next step can continue without replaying the entire raw payload back into the model context window. The important pieces are the exported handler, the request validation branch, and the persistence logic, which is enough context for the agent to move forward without carrying the whole file contents.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "The condensed summary is ready and the next routing call has the shortened tool payload instead of the full file dump.",
      }));

    mockExecuteTool.mockResolvedValueOnce({
      success: true,
      message: "Large file contents",
      data: {
        file: "x".repeat(20_000),
      },
    });

    await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "read_project_file", description: "Read", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "read_project_file", description: "Read", parameters: {} } },
      ],
    });

    const secondCallMessages = mockRoute.mock.calls[1]![0];
    const toolMsg = secondCallMessages.find((m: any) => m.role === "tool");
    expect(toolMsg).toBeDefined();
    expect(toolMsg!.content.length).toBeLessThanOrEqual(1500);
    expect(toolMsg!.content).toContain("[truncated");
  });

  it("caps long agentic history before routing", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    mockRoute.mockResolvedValueOnce(mockResult({
      content: "Done.",
    }));

    const longHistory = Array.from({ length: 40 }, (_, idx) => ({
      role: idx % 2 === 0 ? "user" as const : "assistant" as const,
      content: `message-${idx}`,
    }));

    await runAgenticLoop({
      ...baseParams,
      chatHistory: longHistory,
      tools: [],
      toolsForProvider: undefined,
    });

    const firstCallMessages = mockRoute.mock.calls[0]![0];
    expect(firstCallMessages.length).toBeLessThanOrEqual(24);
    expect(firstCallMessages[0]!.content).toBe("message-0");
    expect(firstCallMessages[firstCallMessages.length - 1]!.content).toBe("message-39");
  });

  it("drops orphaned tool outputs when compaction removes the matching tool call", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    mockRoute.mockResolvedValueOnce(mockResult({
      content:
        "I finished reviewing the existing complaint flow patterns and can continue with the design without replaying stale tool output into the next model call.",
      inputTokens: 120,
      outputTokens: 80,
    }));

    const historyWithTrimmedToolPair = [
      { role: "user" as const, content: "message-0" },
      { role: "assistant" as const, content: "message-1" },
      {
        role: "assistant" as const,
        content: "",
        toolCalls: [{ id: "call_abc", name: "search_project_files", arguments: { query: "complaint" } }],
      },
      {
        role: "tool" as const,
        content: "old tool result",
        toolCallId: "call_abc",
      },
      ...Array.from({ length: 22 }, (_, idx) => ({
        role: idx % 2 === 0 ? "user" as const : "assistant" as const,
        content: `filler-${idx}`,
      })),
    ];

    await runAgenticLoop({
      ...baseParams,
      chatHistory: historyWithTrimmedToolPair,
      tools: [],
      toolsForProvider: undefined,
    });

    const firstCallMessages = mockRoute.mock.calls[0]![0];
    const orphanedTool = firstCallMessages.find((m: any) => m.role === "tool" && m.toolCallId === "call_abc");

    expect(orphanedTool).toBeUndefined();
  });

  it("allows revised build plans after failed review instead of treating them as repetition", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    const buildPlanV1 = {
      fileStructure: [{ path: "packages/db/prisma/schema.prisma", action: "modify", purpose: "Add complaint model" }],
      tasks: [
        { title: "Add complaint model", testFirst: "schema test", implement: "edit schema", verify: "prisma validate" },
      ],
    };

    const buildPlanV2 = {
      fileStructure: [{ path: "packages/db/prisma/schema.prisma", action: "modify", purpose: "Add complaint model" }],
      tasks: [
        { title: "Add complaint model", testFirst: "schema test", implement: "edit schema", verify: "prisma validate" },
        { title: "Add complaint indexes", testFirst: "index test", implement: "add indexes", verify: "prisma validate" },
      ],
    };

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Saving the first plan draft.",
        toolCalls: [{ id: "toolu_01A", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: buildPlanV1 } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing the first plan draft.",
        toolCalls: [{ id: "toolu_01B", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Splitting the oversized task and saving the revised plan.",
        toolCalls: [{ id: "toolu_01C", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: buildPlanV2 } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing the revised plan.",
        toolCalls: [{ id: "toolu_01D", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Implementation plan ready — 1 file, 2 tasks. I split the oversized complaint work into separate schema and indexing tasks, reran the plan review, and the revised plan is now properly scoped for the build phase.",
      }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: fail. Task 1 is too large and needs to be broken down into smaller efforts.", data: { review: { decision: "fail", summary: "Task 1 is too large and needs to be broken down into smaller efforts." } } })
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: pass. The tasks are now properly scoped.", data: { review: { decision: "pass", summary: "The tasks are now properly scoped." } } });

    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "saveBuildEvidence", description: "Save evidence", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "reviewBuildPlan", description: "Review build plan", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "saveBuildEvidence", description: "Save evidence", parameters: {} } },
        { type: "function", function: { name: "reviewBuildPlan", description: "Review build plan", parameters: {} } },
      ],
    });

    expect(result.content).toContain("Implementation plan ready — 1 file, 2 tasks.");
    expect(mockExecuteTool).toHaveBeenCalledTimes(4);
    expect(mockExecuteTool.mock.calls[2]?.[1]).toMatchObject({ field: "buildPlan", value: buildPlanV2 });
  });

  it("allows 3 review cycles when each review follows a plan revision", { timeout: 15_000 }, async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    // Pattern: save → review(fail) → save → review(fail) → save → review(pass) → done
    // 3 reviews, but each is preceded by a saveBuildEvidence, so it's progress.
    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Saving plan v1.",
        toolCalls: [{ id: "t1", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: { tasks: [{ title: "v1" }] } } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing plan v1.",
        toolCalls: [{ id: "t2", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Saving plan v2.",
        toolCalls: [{ id: "t3", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: { tasks: [{ title: "v2" }] } } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing plan v2.",
        toolCalls: [{ id: "t4", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Saving plan v3.",
        toolCalls: [{ id: "t5", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: { tasks: [{ title: "v3" }] } } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing plan v3.",
        toolCalls: [{ id: "t6", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValue(mockResult({
        content: "Plan passed after 3 revisions. The tasks are now properly scoped and include data seeding, schema changes, and API implementation with proper test coverage.",
      }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: fail.", data: { review: { decision: "fail" } } })
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: fail.", data: { review: { decision: "fail" } } })
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: pass.", data: { review: { decision: "pass" } } });

    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "saveBuildEvidence", description: "Save evidence", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "reviewBuildPlan", description: "Review build plan", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "saveBuildEvidence", description: "Save evidence", parameters: {} } },
        { type: "function", function: { name: "reviewBuildPlan", description: "Review build plan", parameters: {} } },
      ],
    });

    // User-facing message must NOT leak tool names or counts (rule #5).
    expect(result.content).not.toContain("saveBuildEvidence");
    expect(result.content).not.toMatch(/\d times with the same arguments/);
    // It must still tell the user a stop happened and point them at the build details.
    expect(result.content.toLowerCase()).toContain("got stuck");
    expect(result.content).toMatch(/build's details panel|build details/i);
    expect(mockExecuteTool).toHaveBeenCalledTimes(6);
  });

  it("does not treat scoped search_sandbox calls as repetition when glob changes", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    for (let idx = 0; idx < 7; idx++) {
      mockRoute.mockResolvedValueOnce(mockResult({
        content: `Searching complaint scope ${idx}.`,
        toolCalls: [
          {
            id: `toolu_search_${idx}`,
            name: "search_sandbox",
            arguments: { pattern: "complaint", glob: `apps/web/scope-${idx}/**/*`, maxResults: 20 },
          },
        ],
      }));
    }

    mockRoute.mockResolvedValueOnce(mockResult({
      content:
        "Completed complaint schema research across multiple codebase scopes and confirmed the implementation boundaries for schema, API, and UI wiring without repeating the same discovery calls. I mapped where Prisma models belong, identified the API route conventions for intake and triage, and captured the UI handoff points needed to replace placeholder state with persisted complaint workflow data end to end.",
    }));

    for (let idx = 0; idx < 7; idx++) {
      mockExecuteTool.mockResolvedValueOnce({ success: true, message: `Search results scope ${idx}` });
    }

    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "search_sandbox", description: "Search sandbox", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "search_sandbox", description: "Search sandbox", parameters: {} } },
      ],
    });

    expect(result.content).toContain("Completed complaint schema research across multiple codebase scopes");
    expect(result.executedTools).toHaveLength(7);
    expect(mockRoute).toHaveBeenCalledTimes(8);
  });

  it("nudges status-only build updates to continue implementation", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Inspecting schema and searching complaint references.",
        toolCalls: [
          {
            id: "toolu_01A",
            name: "search_sandbox",
            arguments: { pattern: "complaint", glob: "packages/db/prisma/schema.prisma", maxResults: 20 },
          },
        ],
      }))
      .mockResolvedValueOnce(mockResult({
        content:
          "I confirmed there is no complaint model yet and the next step is defining enums and relations. Ready to proceed when you confirm.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Creating schema changes now.",
        toolCalls: [
          {
            id: "toolu_01B",
            name: "edit_sandbox_file",
            arguments: { path: "packages/db/prisma/schema.prisma", old_text: "model User {", new_text: "enum ComplaintStatus {\\n  open\\n}\\n\\nmodel User {" },
          },
        ],
      }))
      .mockResolvedValueOnce(mockResult({
        content:
          "Implemented the complaint schema enum scaffolding and started wiring relations. Next I can continue with API routes and verification.",
      }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: true, message: "Search results for complaint" })
      .mockResolvedValueOnce({ success: true, message: "Updated schema.prisma" });

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/build",
      tools: [
        { name: "search_sandbox", description: "Search sandbox", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "edit_sandbox_file", description: "Edit sandbox file", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "search_sandbox", description: "Search sandbox", parameters: {} } },
        { type: "function", function: { name: "edit_sandbox_file", description: "Edit sandbox file", parameters: {} } },
      ],
    });

    expect(result.executedTools).toHaveLength(2);
    expect(result.content).toContain("Implemented the complaint schema enum scaffolding");
    const thirdCallMessages = mockRoute.mock.calls[2]?.[0] ?? [];
    const lastUserMessage = [...thirdCallMessages].reverse().find((m: any) => m.role === "user");
    expect(lastUserMessage?.content).toContain("Do not pause with status-only updates");
  });

  it("does not allow plan-ready claims after read-only tool use without build-plan persistence", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Checking the existing Build Studio workflow files.",
        toolCalls: [{ id: "toolu_read_1", name: "search_project_files", arguments: { query: "BuildStudio workflow actions" } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 5 tasks across 4 files, and Start Implementation is the correct next approval in the product UI.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 5 tasks across 4 files, and Start Implementation is the correct next approval in the product UI.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "",
      }));

    mockExecuteTool.mockResolvedValueOnce({ success: true, message: "Found Build Studio workflow files." });

    // A real /build session exposes the authoritative build tools — the
    // scenario is "the plan wasn't persisted via saveBuildEvidence", so those
    // tools must be present for the read-only-claim to count as fabrication.
    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        ...baseParams.tools,
        { name: "saveBuildEvidence", description: "Save evidence", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false, buildPhases: ["plan"] as const },
        { name: "reviewBuildPlan", description: "Review plan", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false, buildPhases: ["plan"] as const },
      ],
    });

    expect(result.content).not.toContain("Plan ready");
    expect(mockExecuteTool).toHaveBeenCalledTimes(1);
  });

  it("uses a plan-specific recovery nudge when the model claims Start Implementation is next without saving build evidence", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    const buildPlan = {
      fileStructure: [
        { path: "apps/web/components/build/BuildStudio.tsx", action: "modify", purpose: "Fix header overlap" },
      ],
      tasks: [
        { title: "Stabilize build studio header layout", testFirst: "render workflow at constrained height", implement: "adjust layout containers", verify: "pnpm --filter web typecheck" },
      ],
    };

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 5 tasks across 4 files. Building now.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Saving the implementation plan now.",
        toolCalls: [{ id: "toolu_plan_1", name: "saveBuildEvidence", arguments: { field: "buildPlan", value: buildPlan } }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Reviewing the implementation plan now.",
        toolCalls: [{ id: "toolu_plan_2", name: "reviewBuildPlan", arguments: {} }],
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 1 task across 1 file, and Start Implementation is the correct next approval in the product UI. I saved the implementation plan, completed the review, and confirmed the scoped header-overlap fix is ready for sandbox execution.",
      }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: true, message: 'Evidence "buildPlan" saved.' })
      .mockResolvedValueOnce({ success: true, message: "Plan review: pass.", data: { review: { decision: "pass" } } });

    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "saveBuildEvidence", description: "Save evidence", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "reviewBuildPlan", description: "Review build plan", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "saveBuildEvidence", description: "Save evidence", parameters: {} } },
        { type: "function", function: { name: "reviewBuildPlan", description: "Review build plan", parameters: {} } },
      ],
    });

    expect(result.content).toContain("Start Implementation is the correct next approval");
    expect(mockExecuteTool).toHaveBeenCalledTimes(2);
    const secondCallMessages = mockRoute.mock.calls[1]?.[0] ?? [];
    const lastUserMessage = [...secondCallMessages].reverse().find((m: any) => m.role === "user");
    expect(lastUserMessage?.content).toContain('saveBuildEvidence with field "buildPlan"');
    expect(lastUserMessage?.content).toContain("reviewBuildPlan");
  });

  it("blocks a repeated fabricated plan-ready reply instead of surfacing it to the user", async () => {
    const mockRoute = vi.mocked(routeAndCall);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 5 tasks across 4 files. Building now.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Plan ready — 5 tasks across 4 files. Building now.",
      }));

    const result = await runAgenticLoop({
      ...baseParams,
      tools: [
        { name: "saveBuildEvidence", description: "Save evidence", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "reviewBuildPlan", description: "Review build plan", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "saveBuildEvidence", description: "Save evidence", parameters: {} } },
        { type: "function", function: { name: "reviewBuildPlan", description: "Review build plan", parameters: {} } },
      ],
    });

    expect(result.content).not.toContain("Plan ready");
    // User-facing message must NOT leak tool / schema names (rule #5).
    expect(result.content).not.toContain("saveBuildEvidence");
    expect(result.content).not.toContain("reviewBuildPlan");
    expect(result.content).not.toContain("buildPlan");
    // G2 (2026-05-23): must NOT use meta-self-talk language ("I caught myself")
    // and must NOT promise re-routing that the loop never actually performs.
    expect(result.content.toLowerCase()).not.toContain("caught myself");
    expect(result.content.toLowerCase()).not.toContain("route through a different");
    // It must explain what happened in plain language and offer a next move.
    expect(result.content.toLowerCase()).toMatch(/plan.*(wasn't|was not).*recorded|plan.*saved/);
    expect(result.content.toLowerCase()).toContain("build details");
  });

  it("nudges build agent to use fallback steps after failed read stalls", async () => {
    const mockRoute = vi.mocked(routeAndCall);
    const mockExecuteTool = vi.mocked(executeTool);

    mockRoute
      .mockResolvedValueOnce(mockResult({
        content: "Trying to read Prisma schema.",
        toolCalls: [
          {
            id: "toolu_02A",
            name: "run_sandbox_command",
            arguments: { command: "cat /workspace/packages/db/prisma/schema.prisma" },
          },
        ],
      }))
      .mockResolvedValueOnce(mockResult({
        content:
          "I inspected the API folder layout and tried reading the Prisma schema to confirm complaint model updates, but the file read command kept failing, so I'll pause there. Next I'll reattempt schema access.",
      }))
      .mockResolvedValueOnce(mockResult({
        content: "Applying schema scaffolding now.",
        toolCalls: [
          {
            id: "toolu_02B",
            name: "edit_sandbox_file",
            arguments: {
              path: "packages/db/prisma/schema.prisma",
              old_text: "model User {",
              new_text: "enum ComplaintSeverity {\\n  low\\n  medium\\n  high\\n}\\n\\nmodel User {",
            },
          },
        ],
      }))
      .mockResolvedValueOnce(mockResult({
        content:
          "Added initial complaint severity enum scaffolding and resumed implementation with concrete schema updates instead of pausing on read retries.",
      }));

    mockExecuteTool
      .mockResolvedValueOnce({ success: false, message: "Could not read schema.prisma", error: "File not found: packages/db/prisma/schema.prisma" })
      .mockResolvedValueOnce({ success: true, message: "Updated schema.prisma" });

    const result = await runAgenticLoop({
      ...baseParams,
      routeContext: "/build",
      tools: [
        { name: "run_sandbox_command", description: "Run sandbox command", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
        { name: "edit_sandbox_file", description: "Edit sandbox file", inputSchema: {}, requiredCapability: null, executionMode: "immediate" as const, sideEffect: false },
      ],
      toolsForProvider: [
        { type: "function", function: { name: "run_sandbox_command", description: "Run sandbox command", parameters: {} } },
        { type: "function", function: { name: "edit_sandbox_file", description: "Edit sandbox file", parameters: {} } },
      ],
    });

    expect(result.executedTools).toHaveLength(2);
    expect(result.content).toContain("Added initial complaint severity enum scaffolding");
    const thirdCallMessages = mockRoute.mock.calls[2]?.[0] ?? [];
    const lastUserMessage = [...thirdCallMessages].reverse().find((m: any) => m.role === "user");
    expect(lastUserMessage?.content).toContain("Do not pause after a failed read");
  });
});

// ─── Build-specialist Operator Contract platform guards ────────────────────
// Spec: docs/superpowers/specs/2026-04-30-build-specialist-operator-contract.md §2.6

describe("detectToolRefusedDespiteAvailability (clause 2.6)", () => {
  const tools = [{ name: "start_ideate_research" }, { name: "saveBuildEvidence" }];

  it("returns the tool name when response asserts unavailability of a delivered tool", () => {
    const out = detectToolRefusedDespiteAvailability(
      "Blocker: start_ideate_research is not available in the current runtime.",
      tools,
    );
    expect(out).toBe("start_ideate_research");
  });

  it("returns null when response does not assert unavailability", () => {
    const out = detectToolRefusedDespiteAvailability("I'll call start_ideate_research now.", tools);
    expect(out).toBeNull();
  });

  it("returns null when the named tool is not in the delivered list", () => {
    const out = detectToolRefusedDespiteAvailability(
      "I cannot call do_something_else because it's not available.",
      tools,
    );
    expect(out).toBeNull();
  });

  it("matches alternate phrasings", () => {
    expect(
      detectToolRefusedDespiteAvailability("saveBuildEvidence isn't enabled yet — pending grants.", tools),
    ).toBe("saveBuildEvidence");
  });

  it("returns the unspecified sentinel for generic 'currently []' refusals", () => {
    const out = detectToolRefusedDespiteAvailability(
      "The tool grants are currently `[]` for this persona.",
      tools,
    );
    expect(out).not.toBeNull();
    expect(out).toContain("unspecified");
  });
});

describe("phaseRequiresToolCall (clause 2.2)", () => {
  it("returns true for ideate/plan/build/review", () => {
    expect(phaseRequiresToolCall("ideate")).toBe(true);
    expect(phaseRequiresToolCall("plan")).toBe(true);
    expect(phaseRequiresToolCall("build")).toBe(true);
    expect(phaseRequiresToolCall("review")).toBe(true);
  });

  it("returns false for ship/complete/null/undefined", () => {
    expect(phaseRequiresToolCall("ship")).toBe(false);
    expect(phaseRequiresToolCall("complete")).toBe(false);
    expect(phaseRequiresToolCall(null)).toBe(false);
    expect(phaseRequiresToolCall(undefined)).toBe(false);
  });
});

describe("detectUnsavedEvidence (clause 2.4)", () => {
  it("flags ideate response that contains a design-doc structure but no saveBuildEvidence call", () => {
    const out = detectUnsavedEvidence(
      "Here's the design doc:\n\n## Approach\nReplace gray classes with var(--dpf-*) tokens.",
      [],
      "ideate",
    );
    expect(out).toBe("designDoc");
  });

  it("returns null when saveBuildEvidence(designDoc) was called", () => {
    const out = detectUnsavedEvidence(
      "Saved the design doc.",
      [{ name: "saveBuildEvidence", args: { field: "designDoc", value: {} } }],
      "ideate",
    );
    expect(out).toBeNull();
  });

  it("flags plan response with task list but no saved buildPlan", () => {
    const out = detectUnsavedEvidence(
      "Here is the implementation plan with tasks: 1) ... 2) ...",
      [],
      "plan",
    );
    expect(out).toBe("buildPlan");
  });

  it("flags review response with verification verdict but no saved verificationOut", () => {
    const out = detectUnsavedEvidence(
      "Typecheck passed and tests passed; verification complete.",
      [],
      "review",
    );
    expect(out).toBe("verificationOut");
  });

  it("returns null when phase is not ideate/plan/review", () => {
    expect(detectUnsavedEvidence("design doc", [], "build")).toBeNull();
    expect(detectUnsavedEvidence("design doc", [], null)).toBeNull();
  });
});

// ── interactionMode gate over Operator Contract platform guards ──
//
// Phase J finding: chat-mode coworker replies that mention plan-phase
// keywords ("yes do the truck list first") were triggering the unsaved-
// evidence guard and writing phantom PlatformIssueReport rows. The
// guards belong on autonomous phase execution (build-orchestrator,
// pipeline) but not on a real user asking the build coworker a
// conversational question.
//
// These tests pin the contract:
//   - interactionMode default "autonomous" preserves prior behavior
//     (guard fires on conversational-shaped text during plan phase)
//   - interactionMode "chat" suppresses ALL three guards so the chat
//     surface stops generating false-positive issue reports.
describe("runAgenticLoop interactionMode gate (Phase J)", () => {
  beforeEach(() => {
    vi.resetAllMocks();
    vi.mocked(prisma.agentModelConfig.findUnique).mockResolvedValue(null as never);
    vi.mocked(prisma.toolExecution.create).mockResolvedValue({} as never);
    vi.mocked(prisma.platformIssueReport.create).mockResolvedValue({} as never);
    vi.mocked(prisma.user.findUnique).mockResolvedValue({
      isSuperuser: true,
      groups: [{ platformRole: { roleId: "ceo" } }],
    } as never);
  });

  const planPhaseConversationalParams = {
    chatHistory: [
      { role: "user" as const, content: "yes do the truck list first" },
    ],
    systemPrompt: "You are a helpful assistant.",
    sensitivity: "internal" as const,
    tools: [
      {
        name: "saveBuildEvidence",
        description: "Save",
        inputSchema: {},
        requiredCapability: null,
        executionMode: "immediate" as const,
        sideEffect: true,
      },
    ],
    toolsForProvider: [
      {
        type: "function",
        function: { name: "saveBuildEvidence", description: "Save", parameters: {} },
      },
    ],
    userId: "user-1",
    routeContext: "/build",
    agentId: "software-engineer",
    threadId: "thread-1",
    buildPhase: "plan" as const,
    featureBuildId: "fb-internal-id",
  };

  // The agent's reply is a conversational Dale-answer that mentions
  // "tasks" and "build plan" — enough to trip detectUnsavedEvidence's
  // plan-phase regex. The first call returns the reply; the second/third
  // return short status responses so the loop terminates without nudging
  // forever.
  function setupConversationalReply() {
    const mockRoute = vi.mocked(routeAndCall);
    mockRoute
      .mockResolvedValueOnce(
        mockResult({
          content:
            "Got it. I'd recommend a smaller first slice: just the truck list with tasks: 1) show trucks, 2) attach techs. Parts can come in a follow-on build plan.",
        }),
      )
      .mockResolvedValueOnce(
        mockResult({
          content:
            "Confirmed: the smaller scope is captured and the follow-on is documented.",
        }),
      )
      .mockResolvedValueOnce(
        mockResult({
          content:
            "Confirmed: the smaller scope is captured and the follow-on is documented for the next pass.",
        }),
      );
  }

  it("writes a PlatformIssueReport in autonomous mode (default) for plan-phase conversational reply", async () => {
    setupConversationalReply();
    await runAgenticLoop(planPhaseConversationalParams);

    const createCalls = vi.mocked(prisma.platformIssueReport.create).mock.calls;
    const unsavedEvidenceCall = createCalls.find((c) =>
      String((c[0] as any)?.data?.title ?? "").includes("unsaved-evidence"),
    );
    expect(unsavedEvidenceCall).toBeDefined();
  });

  it('skips the unsaved-evidence guard when interactionMode is "chat"', async () => {
    setupConversationalReply();
    await runAgenticLoop({
      ...planPhaseConversationalParams,
      interactionMode: "chat",
    });

    const createCalls = vi.mocked(prisma.platformIssueReport.create).mock.calls;
    const unsavedEvidenceCall = createCalls.find((c) =>
      String((c[0] as any)?.data?.title ?? "").includes("unsaved-evidence"),
    );
    expect(unsavedEvidenceCall).toBeUndefined();
  });

  it('also skips the zero-tool-call guard when interactionMode is "chat"', async () => {
    setupConversationalReply();
    await runAgenticLoop({
      ...planPhaseConversationalParams,
      interactionMode: "chat",
    });

    const createCalls = vi.mocked(prisma.platformIssueReport.create).mock.calls;
    const zeroToolCall = createCalls.find((c) =>
      String((c[0] as any)?.data?.title ?? "").includes("zero-tool-call"),
    );
    expect(zeroToolCall).toBeUndefined();
  });
});
