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

const { generateTextMock, streamTextMock } = vi.hoisted(() => ({
  generateTextMock: vi.fn(),
  streamTextMock: vi.fn(),
}));

vi.mock("ai", async () => {
  const actual = await vi.importActual<typeof import("ai")>("ai");
  return {
    ...actual,
    generateText: generateTextMock,
    streamText: streamTextMock,
    jsonSchema: (schema: unknown) => schema,
    tool: (definition: unknown) => definition,
  };
});

import { APICallError, generateText } from "ai";
import { BestAgentError } from "@best-agent/foundation";
import { PLAN_TODO_KINDS, type ProviderTurnActionPolicy } from "@best-agent/protocol";
import {
  PHASE_RESULT_TOOL_NAME,
  PLAN_UPDATE_TOOL_NAME,
  ProviderRepairableDecisionError,
  decideWithAiSdk,
  generateTextWithProviderResilience,
  isProviderContextOverflowError,
  isProviderNoActionError,
  parsePlanUpdatePayload,
  prepareToolsForFunctionCalling,
} from "./provider-core.js";
import { containsControlPayloadFinal } from "../shared/control-payload-classifier.js";
import { createProcessSessionTools } from "../tool/builtins/process-session-tools.js";

type AiSdkGenerateTextInput = Parameters<typeof generateText>[0];
type AiSdkModel = AiSdkGenerateTextInput["model"];

const fakeAiSdkModel = {} as AiSdkModel;

function getFakeModel(_modelId: string): AiSdkModel {
  return fakeAiSdkModel;
}

const DEFAULT_DECIDE_ACTION_POLICY: ProviderTurnActionPolicy = {
  allowedActions: ["respond", "tool"],
  directResponseAllowed: true,
};

describe("provider stream resilience", () => {
  beforeEach(() => {
    generateTextMock.mockReset();
    streamTextMock.mockReset();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("aborts a streamed response that stops producing chunks before finishing", async () => {
    vi.useFakeTimers();
    const onTextDelta = vi.fn();

    streamTextMock.mockImplementation(({ abortSignal, onChunk }) => {
      setTimeout(() => {
        void onChunk?.({
          chunk: {
            id: "stream-1",
            text: "partial",
            type: "text-delta",
          },
        });
      }, 5);

      const stalledText = new Promise<string>((_resolve, reject) => {
        abortSignal?.addEventListener(
          "abort",
          () => {
            reject(
              abortSignal.reason instanceof Error
                ? abortSignal.reason
                : new Error(String(abortSignal.reason ?? "aborted")),
            );
          },
          { once: true },
        );
      });

      return {
        reasoningText: Promise.resolve(undefined),
        text: stalledText,
        totalUsage: Promise.resolve(undefined),
      };
    });

    const pending = generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
        timeoutMs: 20,
      },
      {
        messages: [],
        model: {} as never,
      },
      { onTextDelta },
    );
    const settled = pending.catch((error) => error);

    await vi.runAllTimersAsync();

    const error = await settled;
    expect(error).toBeInstanceOf(Error);
    expect((error as Error).message).toMatch(/idle timeout/i);
    expect(onTextDelta).toHaveBeenCalledTimes(3);
    expect(streamTextMock).toHaveBeenCalledTimes(3);
  });

  it("aborts a streamed response that produces no action before the no-action budget", async () => {
    vi.useFakeTimers();

    streamTextMock.mockImplementation(({ abortSignal, onChunk }) => {
      setTimeout(() => {
        void onChunk?.({
          chunk: {
            id: "reasoning-1",
            text: "thinking",
            type: "reasoning-delta",
          },
        });
      }, 5);

      const stalledText = new Promise<string>((_resolve, reject) => {
        abortSignal?.addEventListener(
          "abort",
          () => {
            reject(
              abortSignal.reason instanceof Error
                ? abortSignal.reason
                : new Error(String(abortSignal.reason ?? "aborted")),
            );
          },
          { once: true },
        );
      });

      return {
        reasoningText: Promise.resolve(undefined),
        text: stalledText,
        totalUsage: Promise.resolve(undefined),
      };
    });

    const pending = generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
        timeoutMs: 1_000,
      },
      {
        messages: [],
        model: {} as never,
      },
      { noActionTimeoutMs: 20 },
    );
    const settled = pending.catch((error) => error);

    await vi.runAllTimersAsync();

    const error = await settled;
    expect(isProviderNoActionError(error)).toBe(true);
    expect(streamTextMock).toHaveBeenCalledTimes(1);
  });

  it("treats streamed reasoning deltas as no-action progress", async () => {
    vi.useFakeTimers();
    const onReasoningDelta = vi.fn();
    const onTextDelta = vi.fn();

    streamTextMock.mockImplementation(({ abortSignal, onChunk }) => {
      const text = new Promise<string>((resolve, reject) => {
        abortSignal?.addEventListener(
          "abort",
          () => {
            reject(
              abortSignal.reason instanceof Error
                ? abortSignal.reason
                : new Error(String(abortSignal.reason ?? "aborted")),
            );
          },
          { once: true },
        );
        setTimeout(() => {
          void onChunk?.({
            chunk: {
              id: "reasoning-1",
              text: "still thinking",
              type: "reasoning-delta",
            },
          });
        }, 15);
        setTimeout(() => {
          void Promise.resolve(
            onChunk?.({
              chunk: {
                id: "text-1",
                text: "done",
                type: "text-delta",
              },
            }),
          ).then(() => resolve("done"), reject);
        }, 30);
      });

      return {
        reasoningText: Promise.resolve("still thinking"),
        text,
        toolCalls: text.then(() => []),
        totalUsage: Promise.resolve(undefined),
      };
    });

    const pending = generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
        timeoutMs: 1_000,
      },
      {
        messages: [],
        model: {} as never,
      },
      {
        noActionTimeoutMs: 20,
        onReasoningDelta,
        onTextDelta,
      },
    );
    let settled = false;
    void pending.finally(() => {
      settled = true;
    });

    await vi.advanceTimersByTimeAsync(21);
    expect(settled).toBe(false);

    await vi.advanceTimersByTimeAsync(20);

    const result = await pending;
    expect(result.text).toBe("done");
    expect(onReasoningDelta).toHaveBeenCalledWith({
      delta: "still thinking",
      streamId: "reasoning-1",
    });
    expect(onTextDelta).toHaveBeenCalledWith({ delta: "done", streamId: "text-1" });
    expect(streamTextMock).toHaveBeenCalledTimes(1);
  });

  it("recognizes provider no-action errors across package boundaries", () => {
    expect(
      isProviderNoActionError({
        code: "PROVIDER_NO_ACTION",
        message: "Provider returned neither text nor a tool call.",
        name: "BestAgentError",
      }),
    ).toBe(true);
    expect(
      isProviderNoActionError({
        code: "PROVIDER_REQUEST_FAILED",
        message: "No output generated. Check the stream for errors.",
        name: "BestAgentError",
      }),
    ).toBe(true);
  });

  it("ignores control payload examples inside fenced code blocks", () => {
    expect(
      containsControlPayloadFinal(
        'Example:\n```json\n{"kind":"tool","toolName":"shell"}\n```\nThis is documentation.',
        ["shell"],
      ),
    ).toBe(false);
    expect(
      containsControlPayloadFinal(
        'Example:\n```json\n{"kind":"tool","toolName":"shell"}\n```\n<tool_call>',
        ["shell"],
      ),
    ).toBe(true);
  });

  it("recognizes embedded XML tool-call leaks outside fenced code blocks", () => {
    expect(
      containsControlPayloadFinal(
        "好的，环境已经准备就绪。让我启动 benchmark：<tool_call>shell<arg_key>command</arg_key><arg_value>pnpm benchmark:terminal-bench -- --n-concurrent 5</arg_value></tool_call>",
        ["shell"],
      ),
    ).toBe(true);
  });

  it("recognizes standalone runtime decision JSON leaks outside fenced code blocks", () => {
    expect(
      containsControlPayloadFinal('I will call:\n{"kind":"tool","toolName":"shell","input":{}}', [
        "shell",
      ]),
    ).toBe(true);
    expect(
      containsControlPayloadFinal('An example object is {"kind":"tool","label":"Hammer"}.', [
        "shell",
      ]),
    ).toBe(false);
    expect(
      containsControlPayloadFinal('[{"kind":"tool","toolName":"shell","input":{}}]', ["shell"]),
    ).toBe(true);
    expect(containsControlPayloadFinal('[{"kind":"tool","label":"Hammer"}]', ["shell"])).toBe(
      false,
    );
    expect(
      containsControlPayloadFinal(
        '{"tool_calls":[{"name":"shell","arguments":{"command":"echo hidden"}}]}',
        ["shell"],
      ),
    ).toBe(true);
    expect(
      containsControlPayloadFinal(
        '{"toolCalls":[{"toolName":"shell","input":{"command":"echo hidden"}}]}',
        ["shell"],
      ),
    ).toBe(true);
    expect(
      containsControlPayloadFinal(
        '{"function_call":{"name":"shell","arguments":{"command":"echo hidden"}}}',
        ["shell"],
      ),
    ).toBe(true);
  });

  it("recognizes provider context overflow errors", () => {
    expect(
      isProviderContextOverflowError(
        new BestAgentError(
          "PROVIDER_REQUEST_FAILED",
          "context_length_exceeded: maximum context length is 8192 tokens",
        ),
      ),
    ).toBe(true);
    expect(
      isProviderContextOverflowError(
        new BestAgentError("PROVIDER_REQUEST_FAILED", "Gateway Time-out"),
      ),
    ).toBe(false);
  });

  it("retries gateway time-out errors for non-streamed responses", async () => {
    generateTextMock.mockRejectedValueOnce(new Error("Gateway Time-out")).mockResolvedValueOnce({
      reasoningText: undefined,
      text: "recovered response",
      totalUsage: undefined,
    });

    const result = await generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
    );

    expect(result.text).toBe("recovered response");
    expect(generateTextMock).toHaveBeenCalledTimes(2);
  });

  it("retries transient 502 upstream errors for non-streamed responses with backoff", async () => {
    vi.useFakeTimers();
    generateTextMock
      .mockRejectedValueOnce(new Error("bad response status code 502"))
      .mockRejectedValueOnce(new Error("bad response status code 502"))
      .mockResolvedValueOnce({
        reasoningText: undefined,
        text: "recovered after upstream retries",
        totalUsage: undefined,
      });

    const pending = generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
    );

    await vi.advanceTimersByTimeAsync(3_000);

    const result = await pending;
    expect(result.text).toBe("recovered after upstream retries");
    expect(generateTextMock).toHaveBeenCalledTimes(3);
  });

  it("surfaces AI SDK 400 diagnostics without retrying the request", async () => {
    generateTextMock.mockRejectedValueOnce(
      new APICallError({
        cause: new Error("forced function calling is incompatible with response_schema"),
        data: {
          error: {
            code: "invalid_request_error",
            type: "upstream_error",
          },
        },
        message: "upstream_error",
        requestBodyValues: {
          model: "glm-5",
          providerOptions: {
            openai: {
              response_format: {
                type: "json_schema",
              },
            },
          },
          toolChoice: "required",
          tools: [{ name: "shell" }],
        },
        responseBody: JSON.stringify({
          error: {
            message: "forced function calling is incompatible with response_schema",
            type: "invalid_request_error",
          },
        }),
        responseHeaders: {
          "x-request-id": "req_123",
        },
        statusCode: 400,
        url: "https://provider.test/v1/responses",
      }),
    );

    const settled = await generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
    ).catch((error) => error);

    expect(generateTextMock).toHaveBeenCalledTimes(1);
    expect(settled).toBeInstanceOf(BestAgentError);
    expect((settled as Error).message).toContain("AI SDK provider diagnostics:");
    expect((settled as Error).message).toContain("statusCode: 400");
    expect((settled as Error).message).toContain("url: https://provider.test/v1/responses");
    expect((settled as Error).message).toContain("responseHeaders:");
    expect((settled as Error).message).toContain("responseBody:");
    expect((settled as Error).message).toContain("requestBodyValues:");
    expect((settled as Error).message).toContain("toolChoice");
    expect((settled as Error).message).toContain("data:");
    expect((settled as Error).message).toContain("cause:");
  });

  it("uses streaming transport for response-api providers even without UI callbacks", async () => {
    streamTextMock.mockReturnValueOnce({
      reasoningText: Promise.resolve(undefined),
      text: Promise.resolve("streamed response"),
      toolCalls: Promise.resolve([]),
      totalUsage: Promise.resolve(undefined),
    });

    const result = await generateTextWithProviderResilience(
      {
        kind: "openai-responses",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
    );

    expect(result.text).toBe("streamed response");
    expect(generateTextMock).not.toHaveBeenCalled();
    expect(streamTextMock).toHaveBeenCalledTimes(1);
  });

  it("replays final reasoningText through stream callbacks when no reasoning deltas were emitted", async () => {
    const onReasoningStart = vi.fn();
    const onReasoningDelta = vi.fn();
    const onReasoningEnd = vi.fn();

    streamTextMock.mockReturnValueOnce({
      reasoningText: Promise.resolve("Actual thinking from the provider."),
      text: Promise.resolve("hello"),
      toolCalls: Promise.resolve([]),
      totalUsage: Promise.resolve(undefined),
    });

    const result = await generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
      {
        onReasoningDelta,
        onReasoningEnd,
        onReasoningStart,
      },
    );

    expect(result.reasoningText).toBe("Actual thinking from the provider.");
    expect(onReasoningStart).toHaveBeenCalledWith({ streamId: "reasoning-final" });
    expect(onReasoningDelta).toHaveBeenCalledWith({
      delta: "Actual thinking from the provider.",
      streamId: "reasoning-final",
    });
    expect(onReasoningEnd).toHaveBeenCalledWith({ streamId: "reasoning-final" });
  });

  it("coalesces streamed reasoning deltas before invoking callbacks", async () => {
    const onReasoningStart = vi.fn();
    const onReasoningDelta = vi.fn();
    const onReasoningEnd = vi.fn();

    streamTextMock.mockImplementation(({ onChunk }) => {
      const text = (async () => {
        for (let index = 0; index < 3_000; index += 1) {
          await onChunk?.({
            chunk: {
              id: "reasoning-1",
              text: "x",
              type: "reasoning-delta",
            },
          });
        }
        return "";
      })();

      return {
        reasoningText: Promise.resolve(""),
        text,
        toolCalls: text.then(() => []),
        totalUsage: Promise.resolve(undefined),
      };
    });

    await generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
      {
        onReasoningDelta,
        onReasoningEnd,
        onReasoningStart,
      },
    );

    expect(onReasoningStart).toHaveBeenCalledWith({ streamId: "reasoning-1" });
    expect(onReasoningDelta).toHaveBeenCalled();
    expect(onReasoningDelta.mock.calls.length).toBeLessThan(100);
    expect(onReasoningDelta.mock.calls.map(([event]) => event.delta).join("")).toBe(
      "x".repeat(3_000),
    );
    expect(onReasoningEnd).toHaveBeenCalledWith({ streamId: "reasoning-1" });
  });

  it("coalesces streamed tool-input deltas before invoking callbacks", async () => {
    const onToolCallStarted = vi.fn();
    const onToolCallDelta = vi.fn();

    streamTextMock.mockImplementation(({ onChunk }) => {
      const text = (async () => {
        await onChunk?.({
          chunk: {
            id: "call-shell",
            toolName: "shell",
            type: "tool-input-start",
          },
        });
        for (let index = 0; index < 3_000; index += 1) {
          await onChunk?.({
            chunk: {
              delta: "x",
              id: "call-shell",
              type: "tool-input-delta",
            },
          });
        }
        return "";
      })();

      return {
        reasoningText: Promise.resolve(undefined),
        text,
        toolCalls: text.then(() => []),
        totalUsage: Promise.resolve(undefined),
      };
    });

    await generateTextWithProviderResilience(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        messages: [],
        model: {} as never,
      },
      {
        onToolCallDelta,
        onToolCallStarted,
      },
    );

    expect(onToolCallStarted).toHaveBeenCalledWith({
      toolCallId: "call-shell",
      toolName: "shell",
    });
    expect(onToolCallDelta).toHaveBeenCalled();
    expect(onToolCallDelta.mock.calls.length).toBeLessThan(100);
    expect(onToolCallDelta.mock.calls.map(([event]) => event.inputTextDelta).join("")).toBe(
      "x".repeat(3_000),
    );
    for (const [event] of onToolCallDelta.mock.calls) {
      expect(event.toolCallId).toBe("call-shell");
      expect(event.toolName).toBe("shell");
    }
  });

  it("allows JSON-shaped decision text when direct responses are allowed", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: '{"kind":"tool","toolName":"read-file","input":{"path":"README.md"}}',
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Explain the README decision JSON", role: "user" }],
        tools: [{ description: "Read a workspace file.", name: "read-file" }],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining('"kind":"tool"'),
    });
  });

  it("classifies empty native tool decisions as provider no-action", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "   ",
      totalUsage: undefined,
      toolCalls: [],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Inspect the workspace", role: "user" }],
        tools: [{ description: "List workspace files.", name: "workspace-files" }],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(isProviderNoActionError(error)).toBe(true);
    expect(error).toBeInstanceOf(BestAgentError);
    expect((error as Error).message).toContain("neither text nor a tool call");
  });

  it("returns leaked tool marker payloads as respond decisions for runtime repair", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: '◁tool_calls_section_begin▷ ◁tool_call_begin▷ functions.ask-user:0 ◁tool_call_argument_begin▷ {"kind":"choice","id":"review-scope","question":"你想对哪个模块进行代码审查？","options":["Engine - Agent 核心运行时","Protocol - 协议与类型定义","Server - 服务端 RPC 处理","SDK - TypeScript SDK"]} ◁tool_call_end▷ ◁tool_calls_section_end▷',
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Review the codebase", role: "user" }],
        tools: [{ description: "Ask the user a question.", name: "ask-user" }],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining("tool_calls_section_begin"),
    });
  });

  it("allows JSON-shaped plan text when direct responses are allowed", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: JSON.stringify({
        kind: "plan_update",
        plan: {
          acceptanceCriteria: ["Tests pass."],
          affectedPaths: ["packages/engine/src/provider/provider-core.ts"],
          evidenceRefs: [],
          openQuestions: [],
          risks: ["Compatible providers may ignore native tools."],
          status: "review_ready",
          summary: "Accept plan updates returned as plain JSON text.",
          title: "Plan Update Fallback",
          todos: [
            {
              content: "Parse the text payload before falling back to prose.",
              kind: "implementation",
              status: "pending",
            },
          ],
        },
      }),
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: true,
        messages: [{ content: "Explain the plan update JSON", role: "user" }],
        tools: [],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining('"kind":"plan_update"'),
    });
  });

  it("uses the protocol plan todo kind enum for native plan updates", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "plan-update",
          toolName: PLAN_UPDATE_TOOL_NAME,
          input: {
            acceptanceCriteria: [],
            affectedPaths: [],
            evidenceRefs: [],
            openQuestions: [],
            risks: [],
            status: "draft",
            summary: "Plan summary.",
            title: "Plan",
            todos: [{ content: "Validate the fix.", kind: "validation" }],
          },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["plan_update"],
          directResponseAllowed: false,
        },
        allowPlanUpdate: true,
        messages: [{ content: "Plan the change", role: "user" }],
        tools: [],
      },
      getFakeModel,
    );
    const generateCall = generateTextMock.mock.calls[0]?.[0] as
      | {
          tools?: Record<
            string,
            {
              inputSchema?: {
                properties?: {
                  todos?: {
                    items?: { properties?: { kind?: { enum?: readonly string[] } } };
                  };
                };
              };
            }
          >;
        }
      | undefined;

    expect(
      generateCall?.tools?.[PLAN_UPDATE_TOOL_NAME]?.inputSchema?.properties?.todos?.items
        ?.properties?.kind?.enum,
    ).toEqual(PLAN_TODO_KINDS);
    expect(result).toMatchObject({
      kind: "plan_update",
      plan: {
        todos: [
          {
            kind: "validation",
          },
        ],
      },
    });
  });

  it("raises repairable errors for incompatible native structured control decisions", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "plan-update",
          toolName: PLAN_UPDATE_TOOL_NAME,
          input: {
            status: "draft",
            summary: "Plan summary.",
            title: "Plan",
            todos: [{ content: "Validate the fix." }],
          },
        },
        {
          type: "tool-call",
          toolCallId: "phase-result",
          toolName: PHASE_RESULT_TOOL_NAME,
          input: {
            phaseName: "verify",
            summary: "Done.",
            candidateVerdict: {
              status: "satisfied",
              summary: "Passed.",
            },
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["plan_update", "phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        allowPlanUpdate: true,
        messages: [{ content: "Finish the phase", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-structured-control-combination",
      selectedToolNames: [PLAN_UPDATE_TOOL_NAME, PHASE_RESULT_TOOL_NAME],
    });
  });

  it("raises repairable errors for native structured control plus runtime tool decisions", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "phase-result",
          toolName: PHASE_RESULT_TOOL_NAME,
          input: {
            phaseName: "verify",
            summary: "Done.",
            candidateVerdict: {
              status: "satisfied",
              summary: "Passed.",
            },
          },
        },
        {
          type: "tool-call",
          toolCallId: "shell",
          toolName: "shell",
          input: {
            command: "echo hi",
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool", "phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        messages: [{ content: "Finish the phase", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-structured-control-combination",
      selectedToolNames: [PHASE_RESULT_TOOL_NAME, "shell"],
    });
  });

  it("decodes native runtime tool calls from the AI SDK input field", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "shell-call",
          toolName: "shell",
          input: {
            command: "echo hi",
          },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        messages: [{ content: "Run a command", role: "user" }],
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "tool",
      toolCalls: [
        {
          input: {
            command: "echo hi",
          },
          providerToolCallId: "shell-call",
          toolName: "shell",
        },
      ],
    });
  });

  it("keeps legacy native runtime tool args compatibility", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "legacy-shell-call",
          toolName: "shell",
          args: {
            command: "echo legacy",
          },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        messages: [{ content: "Run a command", role: "user" }],
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "tool",
      toolCalls: [
        {
          input: {
            command: "echo legacy",
          },
          providerToolCallId: "legacy-shell-call",
          toolName: "shell",
        },
      ],
    });
  });

  it("treats native runtime tool input as authoritative over legacy args", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "shell-call",
          toolName: "shell",
          input: undefined,
          args: {
            command: "echo legacy",
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        messages: [{ content: "Run a command", role: "user" }],
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-native-tool-input",
      selectedToolNames: ["shell"],
    });
  });

  it("raises repairable errors for invalid native runtime tool input values", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "shell-call",
          toolName: "shell",
          input: undefined,
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        messages: [{ content: "Run a command", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-native-tool-input",
      selectedToolNames: ["shell"],
    });
  });

  it("raises repairable errors for unknown native runtime tool selections", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "unknown-call",
          toolName: "not-a-real-tool",
          input: {},
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        messages: [{ content: "Run a command", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-native-tool-selection",
      selectedToolNames: ["not-a-real-tool"],
    });
  });

  it("raises repairable errors for invalid native plan_update payloads", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "plan-update",
          toolName: PLAN_UPDATE_TOOL_NAME,
          input: {
            status: "draft",
            summary: "Plan summary.",
            title: "Plan",
            todos: "Validate the fix.",
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["plan_update"],
          directResponseAllowed: false,
        },
        allowPlanUpdate: true,
        messages: [{ content: "Plan the work", role: "user" }],
        tools: [],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-structured-control-input",
      selectedToolNames: [PLAN_UPDATE_TOOL_NAME],
    });
  });

  it("raises repairable errors for invalid native phase_result payloads", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "phase-result",
          toolName: PHASE_RESULT_TOOL_NAME,
          input: {
            phaseName: "verify",
            summary: "Verification passed.",
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        messages: [{ content: "Finish verification", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-structured-control-input",
      selectedToolNames: [PHASE_RESULT_TOOL_NAME],
    });
  });

  it("raises repairable errors for mismatched native phase_result phase names", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "phase-result",
          toolName: PHASE_RESULT_TOOL_NAME,
          input: {
            phaseName: "build",
            summary: "Build passed.",
          },
        },
      ],
    });

    const error = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        messages: [{ content: "Finish verification", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [],
      },
      getFakeModel,
    ).catch((caught) => caught);

    expect(error).toBeInstanceOf(ProviderRepairableDecisionError);
    expect(error).toMatchObject({
      reasonCode: "invalid-structured-control-input",
      selectedToolNames: [PHASE_RESULT_TOOL_NAME],
    });
  });

  it("rejects legacy plan todo kind aliases and non-array plan lists", () => {
    const basePlan = {
      status: "draft",
      summary: "Plan summary.",
      title: "Plan",
      todos: [{ content: "Validate the fix.", kind: "verification" }],
    };

    expect(() => parsePlanUpdatePayload(basePlan)).toThrow("invalid kind");
    expect(() =>
      parsePlanUpdatePayload({
        ...basePlan,
        status: "review ready",
        todos: [{ content: "Validate the fix.", kind: "validation" }],
      }),
    ).toThrow('status must be "draft" or "review_ready"');
    expect(() =>
      parsePlanUpdatePayload({
        ...basePlan,
        risks: "none",
        todos: [{ content: "Validate the fix.", kind: "validation" }],
      }),
    ).toThrow("risks must be an array of strings");
    expect(() =>
      parsePlanUpdatePayload({
        ...basePlan,
        risks: [42],
        todos: [{ content: "Validate the fix.", kind: "validation" }],
      }),
    ).toThrow("risks entry 1 must be a string");
  });

  it("returns leaked tool invocation syntax as respond decisions for runtime repair", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: 'files.shell{"argv":["ls","-d","packages/*/"],"timeoutMs":10000}',
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Inspect the packages directory", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining("files.shell"),
    });
  });

  it("allows descriptive tool invocation examples away from the start of the response", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: 'You can use `shell {"command":"ls"}` as an example, but this is just prose.',
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Explain the tool shape", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining("just prose"),
    });
  });

  it("resolves native tool calls using the AI SDK 'input' field (not 'args')", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      // AI SDK v6 tool-call objects use 'input', not 'args'
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "call-123",
          toolName: "workspace-files",
          input: { offset: 0, limit: 50 },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "List workspace files", role: "user" }],
        tools: [{ description: "List workspace files.", name: "workspace-files" }],
      },
      getFakeModel,
    );

    expect(result).toEqual({
      kind: "tool",
      model: "test-provider",
      provider: "openai",
      reasoning: undefined,
      toolCalls: [
        {
          input: { offset: 0, limit: 50 },
          providerToolCallId: "call-123",
          providerToolName: "workspace-files",
          toolName: "workspace-files",
        },
      ],
    });
  });

  it("maps provider-safe native tool names back to runtime tool names", async () => {
    let providerToolName = "";
    generateTextMock.mockImplementationOnce(async (call: { tools?: Record<string, unknown> }) => {
      providerToolName =
        Object.keys(call.tools ?? {}).find((name) => name.startsWith("mcp_docs_search_")) ?? "";
      expect(providerToolName).toMatch(/^[A-Za-z0-9_-]{1,64}$/);
      expect(providerToolName).not.toContain(":");
      return {
        reasoningText: undefined,
        text: "",
        totalUsage: undefined,
        toolCalls: [
          {
            type: "tool-call",
            toolCallId: "call-mcp",
            toolName: providerToolName,
            input: { input: "agent runtime" },
          },
        ],
      };
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Search docs", role: "user" }],
        systemPrompt: "The latest user message explicitly requires tool mcp:docs:search.",
        tools: [
          {
            description: "Search docs.",
            inputSchema: {
              additionalProperties: false,
              properties: { input: { type: "string" } },
              required: ["input"],
              type: "object",
            },
            name: "mcp:docs:search",
          },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(Object.keys(call?.tools ?? {})).toEqual([providerToolName]);
    expect(call?.providerOptions?.openai?.instructions ?? "").toContain(`- ${providerToolName}:`);
    expect(call?.providerOptions?.openai?.instructions ?? "").toContain(
      `- mcp:docs:search is exposed to the provider as ${providerToolName}.`,
    );
    expect(call?.providerOptions?.openai?.instructions ?? "").toContain(
      "requires tool mcp:docs:search",
    );
    expect(call?.providerOptions?.openai?.instructions ?? "").not.toContain(
      `requires tool ${providerToolName}`,
    );
    expect(result).toEqual({
      kind: "tool",
      model: "test-provider",
      provider: "openai",
      reasoning: undefined,
      toolCalls: [
        {
          input: { input: "agent runtime" },
          providerToolCallId: "call-mcp",
          providerToolName,
          toolName: "mcp:docs:search",
        },
      ],
    });
  });

  it("ignores leaked control text when a native structured tool call is present", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "<tool_call|>",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "call-shell",
          toolName: "shell",
          input: { command: "ls -la /app" },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Inspect the app directory", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    expect(result).toEqual({
      kind: "tool",
      model: "test-provider",
      provider: "openai",
      reasoning: undefined,
      toolCalls: [
        {
          input: { command: "ls -la /app" },
          providerToolCallId: "call-shell",
          providerToolName: "shell",
          toolName: "shell",
        },
      ],
    });
  });

  it("does not register internal repair tools in provider native tool set", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "done",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Inspect the app directory", role: "user" }],
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    expect(generateTextMock).toHaveBeenCalledWith(
      expect.objectContaining({
        activeTools: ["shell"],
        tools: expect.not.objectContaining({
          invalid: expect.anything(),
        }),
      }),
    );
  });

  it("returns structured multi-tool decisions without collapsing them", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: "Read the package and search for the matching scripts first.",
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "call-read",
          toolName: "read-file",
          input: { path: "package.json" },
        },
        {
          type: "tool-call",
          toolCallId: "call-search",
          toolName: "content-search",
          input: { pattern: "test:chat:real", path: "package.json" },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Inspect package.json and find the real chat script", role: "user" }],
        tools: [
          { description: "Read a file.", name: "read-file" },
          { description: "Search file contents.", name: "content-search" },
        ],
      },
      getFakeModel,
    );

    expect(result).toEqual({
      kind: "tool",
      model: "test-provider",
      provider: "openai",
      reasoning: "Read the package and search for the matching scripts first.",
      toolCalls: [
        {
          input: { path: "package.json" },
          providerToolCallId: "call-read",
          providerToolName: "read-file",
          toolName: "read-file",
        },
        {
          input: { path: "package.json", pattern: "test:chat:real" },
          providerToolCallId: "call-search",
          providerToolName: "content-search",
          toolName: "content-search",
        },
      ],
    });
  });

  it("wraps scalar tool schemas for native function calling and unwraps returned input", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "call-echo",
          toolName: "echo",
          input: { value: "hello from wrapped scalar schema" },
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Echo this", role: "user" }],
        tools: [{ description: "Echo input.", inputSchema: { type: "string" }, name: "echo" }],
      },
      getFakeModel,
    );

    const generateCall = generateTextMock.mock.calls[0]?.[0] as
      | {
          tools?: Record<string, { inputSchema?: unknown; strict?: boolean }>;
        }
      | undefined;

    expect(generateCall?.tools?.echo?.strict).toBe(true);
    expect(generateCall?.tools?.echo?.inputSchema).toEqual({
      additionalProperties: false,
      properties: {
        value: { type: "string" },
      },
      required: ["value"],
      type: "object",
    });
    expect(result).toEqual({
      kind: "tool",
      model: "test-provider",
      provider: "openai",
      reasoning: undefined,
      toolCalls: [
        {
          input: "hello from wrapped scalar schema",
          providerToolCallId: "call-echo",
          providerToolName: "echo",
          toolName: "echo",
        },
      ],
    });
  });

  it("exposes process-session-start as a command object without a value wrapper", () => {
    const tool = createProcessSessionTools().find(
      (candidate) => candidate.name === "process-session-start",
    );
    if (!tool) {
      throw new Error("process-session-start tool missing");
    }

    const [prepared] = prepareToolsForFunctionCalling([
      {
        description: tool.description,
        inputSchema: tool.inputSchema,
        name: tool.name,
      },
    ]);

    expect(prepared?.functionCallingParameters).toMatchObject({
      properties: {
        command: expect.any(Object),
      },
      type: "object",
    });
    expect(JSON.stringify(prepared?.functionCallingParameters)).not.toContain('"value"');
  });

  it("does not let runtime tools collide with provider control tools", () => {
    const [tool] = prepareToolsForFunctionCalling([
      {
        description: "User tool.",
        inputSchema: { additionalProperties: false, properties: {}, type: "object" },
        name: PLAN_UPDATE_TOOL_NAME,
      },
    ]);

    expect(tool?.name).not.toBe(PLAN_UPDATE_TOOL_NAME);
    expect(tool?.runtimeName).toBe(PLAN_UPDATE_TOOL_NAME);
  });

  it("normalizes nested union schema types for native function calling", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Read both files", role: "user" }],
        tools: [
          {
            description: "Read a file or file batch.",
            inputSchema: {
              properties: {
                path: {
                  items: { type: "string" },
                  type: ["string", "array"],
                },
              },
              required: ["path"],
              type: "object",
            },
            name: "read-file",
          },
        ],
      },
      getFakeModel,
    );

    const generateCall = generateTextMock.mock.calls[0]?.[0] as
      | {
          tools?: Record<
            string,
            { inputSchema?: { properties?: { path?: unknown } }; strict?: boolean }
          >;
        }
      | undefined;

    expect(generateCall?.tools?.["read-file"]?.strict).toBeUndefined();
    expect(generateCall?.tools?.["read-file"]?.inputSchema?.properties?.path).toEqual({
      anyOf: [
        { type: "string" },
        {
          items: { type: "string" },
          type: "array",
        },
      ],
    });
  });

  it("enables strict native function calling for fully required object schemas", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Write a file", role: "user" }],
        tools: [
          {
            description: "Write a file.",
            inputSchema: {
              additionalProperties: false,
              properties: {
                content: { type: "string" },
                path: { type: "string" },
              },
              required: ["content", "path"],
              type: "object",
            },
            name: "write-workspace",
          },
        ],
      },
      getFakeModel,
    );

    const generateCall = generateTextMock.mock.calls[0]?.[0] as
      | {
          tools?: Record<string, { strict?: boolean }>;
        }
      | undefined;

    expect(generateCall?.tools?.["write-workspace"]?.strict).toBe(true);
  });

  it("does not enable strict native function calling for optional object fields", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Fetch a URL", role: "user" }],
        tools: [
          {
            description: "Fetch a URL.",
            inputSchema: {
              additionalProperties: false,
              properties: {
                format: { enum: ["text", "rich"], type: "string" },
                url: { type: "string" },
              },
              required: ["url"],
              type: "object",
            },
            name: "fetch-url",
          },
        ],
      },
      getFakeModel,
    );

    const generateCall = generateTextMock.mock.calls[0]?.[0] as
      | {
          tools?: Record<string, { strict?: boolean }>;
        }
      | undefined;

    expect(generateCall?.tools?.["fetch-url"]?.strict).toBeUndefined();
  });

  it("includes tool names and descriptions in the native decision prompt without parameter guidance", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Run the benchmark", role: "user" }],
        tools: [
          { description: "Run a command.", name: "shell" },
          { description: "Read a file from the workspace.", name: "read-file" },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
        }
      | undefined;
    const instructions = call?.providerOptions?.openai?.instructions ?? "";
    expect(instructions).toContain("Available tools:");
    expect(instructions).toContain("- shell: Run a command.");
    expect(instructions).toContain("- read-file: Read a file from the workspace.");
    expect(instructions).not.toContain("once the required files exist");
    expect(instructions).not.toContain("timeoutMs");
    expect(instructions).not.toContain('Use the field name "path"');
    expect(instructions).not.toContain('["ls","-la","/app"]');
    expect(generateTextMock.mock.calls[0]?.[0]).not.toHaveProperty("system");
  });

  it("renders action policy instead of allowing direct responses unconditionally", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
          required: {
            kind: "tool",
            reason: "explicit-required-tool",
            toolNames: ["read-file"],
          },
        },
        allowPlanUpdate: false,
        messages: [{ content: "Use read-file", role: "user" }],
        toolUsePolicy: "required-tool-only",
        tools: [{ description: "Read a file from the workspace.", name: "read-file" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
        }
      | undefined;
    const instructions = call?.providerOptions?.openai?.instructions ?? "";
    expect(instructions).toContain("Allowed actions: tool.");
    expect(instructions).toContain("Direct prose is not an allowed runtime action for this turn.");
    expect(instructions).toContain(
      "Runtime tool action is available; the runtime does not require a specific tool name.",
    );
    expect(instructions).toContain("Direct prose is not an allowed action for this turn.");
    expect(instructions).toContain("Use only listed runtime actions and tools.");
    expect(instructions).not.toContain("If the latest user message explicitly requires");
    expect(instructions).not.toContain("once the required files exist");
    expect(instructions).not.toContain("You may either respond directly");
    expect(instructions).not.toContain("Otherwise respond directly");
  });

  it("renders a single required-any group as one-of alternatives", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
          required: {
            kind: "tool",
            reason: "explicit-required-tool",
            requiredAnyToolGroups: [["read-file", "workspace-search"]],
            requiredAnyToolNames: ["read-file", "workspace-search"],
            toolNames: ["read-file", "workspace-search"],
          },
        },
        allowPlanUpdate: false,
        messages: [{ content: "Use read-file or workspace-search", role: "user" }],
        toolUsePolicy: "required-tool-only",
        tools: [
          { description: "Read a file from the workspace.", name: "read-file" },
          { description: "Search workspace files.", name: "workspace-search" },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
        }
      | undefined;
    const instructions = call?.providerOptions?.openai?.instructions ?? "";
    expect(instructions).toContain(
      "Runtime tool action is available; the runtime does not require a specific tool name.",
    );
    expect(instructions).not.toContain("Required alternative tool groups:");
    expect(instructions).not.toContain("- one of: read-file or workspace-search");
    expect(instructions).not.toContain("Required tool action: read-file, workspace-search.");
  });

  it("filters native provider tools to required action-policy tools", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      toolCalls: [
        {
          input: { command: "echo hi" },
          toolCallId: "call_shell",
          toolName: "shell",
        },
      ],
      totalUsage: undefined,
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
          required: {
            kind: "tool",
            reason: "required-terminal-bench-execution",
            requiredAnyToolNames: ["shell"],
            toolNames: ["shell"],
          },
        },
        allowPlanUpdate: false,
        messages: [{ content: "Run terminal-bench", role: "user" }],
        tools: [
          {
            description: "Echo input.",
            inputSchema: { type: "string" },
            name: "echo",
          },
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          toolChoice?: unknown;
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(Object.keys(call?.tools ?? {})).toEqual(["shell"]);
    expect(call?.activeTools).toEqual(["shell"]);
    expect(call?.toolChoice).toEqual({ type: "tool", toolName: "shell" });
  });

  it("does not force a native tool choice for mixed tool and phase_result policy", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "phase can complete later",
      toolCalls: [],
      totalUsage: undefined,
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool", "phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        messages: [{ content: "Continue the build phase", role: "user" }],
        phaseResultPhaseName: "build",
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          toolChoice?: unknown;
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(call?.activeTools).toEqual(["shell", "__best_agent_phase_result"]);
    expect(call?.tools).toHaveProperty("shell");
    expect(call?.tools).toHaveProperty("__best_agent_phase_result");
    expect(call).not.toHaveProperty("toolChoice");
  });

  it("does not force a native tool choice for mixed tool and plan_update policy", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "plan can update later",
      toolCalls: [],
      totalUsage: undefined,
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool", "plan_update"],
          directResponseAllowed: false,
        },
        allowPlanUpdate: true,
        messages: [{ content: "Plan the change", role: "user" }],
        tools: [{ description: "Read a file.", name: "read-file" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          toolChoice?: unknown;
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(call?.activeTools).toEqual(["read-file", "__best_agent_plan_update"]);
    expect(call?.tools).toHaveProperty("read-file");
    expect(call?.tools).toHaveProperty("__best_agent_plan_update");
    expect(call).not.toHaveProperty("toolChoice");
  });

  it("forces native tool choice for pure tool-only policy with one visible tool", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      toolCalls: [
        {
          input: { command: "echo hi" },
          toolCallId: "call_shell",
          toolName: "shell",
        },
      ],
      totalUsage: undefined,
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        allowPlanUpdate: false,
        messages: [{ content: "Run the command", role: "user" }],
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          toolChoice?: unknown;
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(Object.keys(call?.tools ?? {})).toEqual(["shell"]);
    expect(call?.activeTools).toEqual(["shell"]);
    expect(call?.toolChoice).toEqual({ type: "tool", toolName: "shell" });
  });

  it("normalizes structured controls against the action policy before exposing native tools", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      toolCalls: [],
      totalUsage: undefined,
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        allowPlanUpdate: true,
        messages: [{ content: "Use the tool", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          providerOptions?: { openai?: { instructions?: string } };
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(Object.keys(call?.tools ?? {})).toEqual(["shell"]);
    expect(call?.activeTools).toEqual(["shell"]);
    expect(call?.providerOptions?.openai?.instructions ?? "").not.toContain(
      "__best_agent_phase_result",
    );
    expect(call?.providerOptions?.openai?.instructions ?? "").not.toContain(
      "__best_agent_plan_update",
    );
  });

  it("does not expose runtime tools when the action policy only allows phase_result", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          type: "tool-call",
          toolCallId: "phase-result",
          toolName: "__best_agent_phase_result",
          input: {
            phaseName: "verify",
            summary: "Verification passed.",
            candidateVerdict: {
              status: "satisfied",
              summary: "All checks passed.",
            },
          },
        },
      ],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["phase_result"],
          directResponseAllowed: false,
        },
        allowPhaseResult: true,
        messages: [{ content: "Verify the change", role: "user" }],
        phaseResultPhaseName: "verify",
        tools: [{ description: "Run a shell command.", name: "shell" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          activeTools?: string[];
          tools?: Record<string, unknown>;
        }
      | undefined;
    expect(Object.keys(call?.tools ?? {})).toEqual(["__best_agent_phase_result"]);
    expect(call?.activeTools).toEqual(["__best_agent_phase_result"]);
  });

  it("does not recover XML textual tool calls from provider text as native tool decisions", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "我来执行：<tool_call>shell<arg_key>command</arg_key><arg_value>echo hi</arg_value></tool_call>",
      totalUsage: undefined,
      toolCalls: [],
    });

    const result = await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: {
          allowedActions: ["tool"],
          directResponseAllowed: false,
          required: {
            kind: "tool",
            reason: "required-terminal-bench-execution",
            requiredAnyToolNames: ["shell"],
            toolNames: ["shell"],
          },
        },
        allowPlanUpdate: false,
        messages: [{ content: "Run terminal-bench", role: "user" }],
        tools: [
          {
            description: "Run a shell command.",
            inputSchema: {
              additionalProperties: false,
              properties: { command: { type: "string" } },
              required: ["command"],
              type: "object",
            },
            name: "shell",
          },
        ],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "respond",
      text: expect.stringContaining("<tool_call>shell"),
    });
  });

  it("does not add web-search preference guidance to the native decision prompt", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Find the docs", role: "user" }],
        tools: [
          { description: "Search the web.", name: "web-search" },
          { description: "Fetch a URL.", name: "fetch-url" },
        ],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
        }
      | undefined;
    expect(call?.providerOptions?.openai?.instructions ?? "").not.toContain(
      "prefer web-search before fetch-url",
    );
  });

  it("omits web-search guidance from the native decision prompt when web-search is unavailable", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: undefined,
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        kind: "openai",
        model: "test-provider",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "Find the docs", role: "user" }],
        tools: [{ description: "Fetch a URL.", name: "fetch-url" }],
      },
      getFakeModel,
    );

    const call = generateTextMock.mock.calls[0]?.[0] as
      | {
          providerOptions?: { openai?: { instructions?: string } };
        }
      | undefined;
    expect(call?.providerOptions?.openai?.instructions ?? "").not.toContain(
      "prefer web-search before fetch-url",
    );
  });

  it("requests visible reasoning summaries for native OpenAI transcript decisions", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: "Summarized provider reasoning.",
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        compatibilityMode: "native",
        kind: "openai",
        model: "gpt-5.4",
        reasoningEffort: "high",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "hi", role: "user" }],
        tools: [],
      },
      getFakeModel,
    );

    expect(generateTextMock).toHaveBeenCalledWith(
      expect.objectContaining({
        providerOptions: expect.objectContaining({
          openai: expect.objectContaining({
            instructions: expect.any(String),
            reasoningEffort: "high",
            reasoningSummary: "auto",
            systemMessageMode: "remove",
          }),
        }),
      }),
    );
    expect(generateTextMock.mock.calls[0]?.[0]).not.toHaveProperty("system");
  });

  it("defaults native OpenAI transcript decisions to medium reasoning effort when none is configured", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: "Summarized provider reasoning.",
      text: "ok",
      totalUsage: undefined,
      toolCalls: [],
    });

    await decideWithAiSdk(
      {
        compatibilityMode: "native",
        kind: "openai",
        model: "gpt-5.4",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "hi", role: "user" }],
        tools: [],
      },
      getFakeModel,
    );

    expect(generateTextMock).toHaveBeenCalledWith(
      expect.objectContaining({
        providerOptions: expect.objectContaining({
          openai: expect.objectContaining({
            instructions: expect.any(String),
            reasoningEffort: "medium",
            reasoningSummary: "auto",
            systemMessageMode: "remove",
          }),
        }),
      }),
    );
    expect(generateTextMock.mock.calls[0]?.[0]).not.toHaveProperty("system");
  });

  it("preserves provider reasoning on native tool decisions", async () => {
    generateTextMock.mockResolvedValueOnce({
      reasoningText: "Inspect the README before using the tool.",
      text: "",
      totalUsage: undefined,
      toolCalls: [
        {
          input: { path: "README.md" },
          toolCallId: "call_1",
          toolName: "read-file",
        },
      ],
    });

    const result = await decideWithAiSdk(
      {
        compatibilityMode: "native",
        kind: "openai",
        model: "gpt-5.4",
      },
      {
        decisionTransport: "native_tools",
        actionPolicy: DEFAULT_DECIDE_ACTION_POLICY,
        allowPlanUpdate: false,
        messages: [{ content: "inspect the README", role: "user" }],
        tools: [{ description: "Read a file.", name: "read-file" }],
      },
      getFakeModel,
    );

    expect(result).toMatchObject({
      kind: "tool",
      reasoning: "Inspect the README before using the tool.",
      toolCalls: [{ toolName: "read-file" }],
    });
  });
});
