Files
windmill/frontend/src/lib/components/copilot/chat/chatLoop.test.ts
T
AlexRV12andClaude Opus 5 6cd70a8e08 feat: filter ai session tools to the user's workspace capabilities (#10719)
* feat: filter ai session tools to the user's workspace capabilities

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct and tighten comments on the session capability filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate session deploy tools on DisableDirectDeployment and the pipeline prompt

* fix: gate create_folder on the deploy capability

* refactor: assemble session prompt and tools through one seam

* docs: state the capability filter as best-effort, not a guarantee

* refactor: take the whole deploy gate from the shared preflight

`checkDeployPermission` now evaluates `DisableDirectDeployment` and folds superadmin
into the admin bypass itself, so the resolver's local composition of those two terms
is redundant. Delegate outright and drop the protection-rule fetch it needed, along
with the two tests that restated rule semantics the preflight's own suite now pins.

The preflight's per-kind narrowing stays unused: the filter runs on tool names, before
the model has named a kind, so a direct-deployment lock withholds the deploy tools for
schedules and triggers too.

* fix: address review findings on the session capability filter

Six findings from the Claude and Codex review rounds.

- `discard_local_draft` is ungated. The backend exempts discarding your OWN draft
  from `require_can_write_path` precisely so drafts stay cleanable after a role
  change; gating it stranded that cleanup.
- `deploy` splits into `deploy` and `deploy_gated_kinds`, mirroring
  `deployPermissionForKind`. A direct-deployment lock stops only the kinds that
  reach `check_deploy_rules`, so schedules and triggers stay deployable and the
  two kind-taking deploy tools survive the lock; `create_folder` does not, folder
  being a gated kind. The prompt now names the lock and what it leaves deployable,
  instead of implying nothing can be deployed.
- `COVERED_ENDPOINTS` keyed `createApp` / `updateApp`, which the MCP catalog does
  not expose; the app-authoring endpoints it does expose, `createAppRawSource` and
  `updateAppRawSource`, were uncovered and reachable through `call_api_endpoint`.
- The YOLO tooltip listed tools a restricted session never ships. Both it and the
  token estimate now read one `shippedTools`, and `sessionAccess` is reactive so
  the UI follows the resolution.

* fix: restore the covered API-catalog names for the raw-app endpoints

`COVERED_ENDPOINTS` is matched against `EndpointTool.name`, which openapi.yaml
overrides with `x-mcp-tool-name` for these two operations: `createAppRawSource`
and `updateAppRawSource` are served as `createApp` and `updateApp`
(`mcp/auto_generated_endpoints.rs`). Keying them by operationId left both raw-app
POST endpoints discoverable and callable through the API catalog tools. Restore
the exposed names and record why they differ from the operationIds.

* docs: state each capability invariant once, and document the draft discard

The asymmetric admin/operator precedence was restated three times in
sessionAccess.ts and again in its test, the fail-open rationale twice, and the
deploy split across four sites. Each now lives at the one place someone would
break it, within the four-line budget, with the other sites pointing at it.

Ungating discard_local_draft left it undocumented for the read-only profile,
which is the profile the backend exemption exists for: the only bullet naming it
sits under the draft-writing gate, beneath an opener saying no change is
possible. Add the one line that profile needs.

* docs: record why the session tool filter runs unconditionally

The filter would strip everything from a non-GLOBAL toolset, whose names carry no
policy entries. That cannot happen — `changeMode` refuses to move a session chat
out of GLOBAL, and `sessionAccess` is only ever set for session chats — but the
dependency was not visible at the filter itself.

* fix: match the server's deploy gate exactly, never exceed it

The filter must be as strict as the server and no stricter. Schedules and triggers
reach no deploy rule — `check_deploy_rules` runs only from the gated kinds' handlers
— so no workspace refuses `deploy_workspace_item` or `delete_workspace_item`
outright, whatever refusal `checkDeployPermission` reports. Gating them on a deploy
capability withheld operations the server performs.

Neither tool now requires a capability. Deploying still needs a draft to deploy, so
it keeps the authoring relevance; deleting a deployed item does not, so it is
ungated. `deploy` returns to one capability, covering the kinds the rules gate, and
`create_folder` — whose kind is one of them — is the only tool that names it. The
session-state note now states which kinds a refusing workspace still accepts.

* feat: gate the new app-runnable preview tool on run_preview

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: fail open when whoami resolves without a role

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: count plan-mode tools in the shipped toolset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* docs: drop the dead capability assertion and the repeated deploy rationale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: drop dead code and a duplicated invariant from the session filter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: reduce SessionAccess to the capability set it is read for

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: gate session tools on permission alone, never on relevance

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* test: pin the filter to the outbound request and widen the description sweep

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: collapse SessionAccess to a capability set and merge adjacent gates

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: gate get_db_schema on run_preview, it runs a query script

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: reuse the cached workspace role and derive the exhaustiveness list from assembly

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* revert: keep tool names in descriptions that ship with them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: let an admin who is also an operator deploy, as the server does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: derive the deploy capability from the protection rules alone

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: stop the datatable instructions naming a tool a session may not have

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the prompt and tool results honest for a profile that cannot draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: move tool policies onto the tools and gate kinds per handler

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* chore: tighten stale comments and name deploy in the operator prompt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the assembled tool list raw so narrowing can clone its defs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: point an operator at a workspace admin for code the role refuses

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: resolve session permissions when the assistant settings modal opens

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: return per-chat tool schemas instead of writing them to shared tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: forward this through the eval tool wrapper so tools see their sent def

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: pin identity and contents in the session filter and schema tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: note why the overhead estimate skips per-chat tool schemas

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:59:54 +02:00

818 lines
29 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from '$lib/utils/uuid'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import {
closeInterruptedToolBatch,
runChatLoop,
truncateToToolPairedPrefix,
type ChatLoopConfig
} from './chatLoop'
import type { ReasoningProviderModel } from '../reasoningRegistry'
import { OutputTokenLimitError } from './outputTokenLimit'
const mocks = vi.hoisted(() => ({
getCompletion: vi.fn(),
parseOpenAICompletion: vi.fn(),
providerSupportsWebSearch: vi.fn(),
getOpenAIResponsesCompletion: vi.fn(),
parseOpenAIResponsesCompletion: vi.fn(),
buildPromptCacheKey: vi.fn(),
getAnthropicCompletion: vi.fn(),
parseAnthropicCompletion: vi.fn(),
resolveRequestReasoning: vi.fn(),
resolveEffectiveReasoning: vi.fn()
}))
vi.mock('../lib', () => ({
getCompletion: mocks.getCompletion,
parseOpenAICompletion: mocks.parseOpenAICompletion,
providerSupportsWebSearch: mocks.providerSupportsWebSearch
}))
vi.mock('../reasoningRegistry', () => ({
resolveRequestReasoning: mocks.resolveRequestReasoning,
resolveEffectiveReasoning: mocks.resolveEffectiveReasoning
}))
vi.mock('./openai-responses', () => ({
getOpenAIResponsesCompletion: mocks.getOpenAIResponsesCompletion,
parseOpenAIResponsesCompletion: mocks.parseOpenAIResponsesCompletion,
buildPromptCacheKey: mocks.buildPromptCacheKey
}))
vi.mock('./anthropic', () => ({
getAnthropicCompletion: mocks.getAnthropicCompletion,
parseAnthropicCompletion: mocks.parseAnthropicCompletion
}))
const tokenUsage = { prompt: 0, completion: 0, total: 0 }
function createCallbacks(): ChatLoopConfig['callbacks'] {
return {
onNewToken: vi.fn(),
onMessageEnd: vi.fn(),
setToolStatus: vi.fn(),
removeToolStatus: vi.fn()
}
}
function createConfig({
workspace,
modelProvider = { provider: 'openai', model: 'gpt-4.1' },
callbacks = createCallbacks(),
onWebSearchUnavailable,
onReasoningSummaryUnavailable
}: {
workspace: string
modelProvider?: ReasoningProviderModel
callbacks?: ChatLoopConfig['callbacks']
onWebSearchUnavailable?: ChatLoopConfig['onWebSearchUnavailable']
onReasoningSummaryUnavailable?: ChatLoopConfig['onReasoningSummaryUnavailable']
}): ChatLoopConfig {
const messages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'search this' }]
return {
messages,
systemMessage: { role: 'system', content: '' },
tools: [],
helpers: undefined,
abortController: new AbortController(),
callbacks,
modelProvider,
clients: {
openai: {} as ChatLoopConfig['clients']['openai'],
anthropic: {} as ChatLoopConfig['clients']['anthropic']
},
workspace,
maxIterations: 1,
onWebSearchUnavailable,
onReasoningSummaryUnavailable
}
}
describe('runChatLoop web search fallback', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockImplementation(
(provider) => provider === 'openai' || provider === 'anthropic'
)
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.parseOpenAICompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
mocks.parseAnthropicCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
})
it('retries once without OpenAI web search and caches unsupported provider models', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error("Hosted tool 'web_search' is not supported with this model"), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks, onWebSearchUnavailable }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
})
it('does not treat unrelated OpenAI tool errors as web search failures', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
new Error('Unknown tool call: lookup_customer')
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('treats OpenAI hosted-tool incompatibility as web search unavailable', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error('Hosted tools are not supported with this model'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks, onWebSearchUnavailable }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
it('does not cache malformed web search requests as unavailable', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
Object.assign(new Error('Invalid value for web_search.search_context_size'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('does not cache web-search rate limits as unavailable', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
Object.assign(new Error('Rate limit exceeded for web_search'), {
status: 429,
error: { type: 'rate_limit_error' }
})
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('retries once without Anthropic web search when the model rejects it', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = {
provider: 'anthropic',
model: 'claude-3-5-sonnet-latest'
}
mocks.getAnthropicCompletion
.mockRejectedValueOnce(
Object.assign(new Error('Your organization must enable web search in the Claude Console'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable }))
expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getAnthropicCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getAnthropicCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
it('does not retry Anthropic request errors that only mention web search input', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = {
provider: 'anthropic',
model: 'claude-sonnet-4-6'
}
const error = Object.assign(new Error('Invalid web_search query parameter'), {
status: 400,
error: { type: 'invalid_request_error' }
})
mocks.getAnthropicCompletion.mockRejectedValueOnce(error)
await expect(runChatLoop(createConfig({ workspace, callbacks, modelProvider }))).rejects.toBe(
error
)
expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
})
describe('runChatLoop reasoning summary fallback', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockReturnValue(false)
mocks.resolveRequestReasoning.mockReturnValue('high')
mocks.resolveEffectiveReasoning.mockReturnValue('high')
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
})
it('retries once without the summary on the unverified-org error and caches per workspace/provider', async () => {
const onReasoningSummaryUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.1' }
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(
new Error('Your organization must be verified to generate reasoning summaries.'),
{
status: 400,
param: 'reasoning.summary',
code: 'unsupported_value',
error: { type: 'invalid_request_error' }
}
)
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, modelProvider, onReasoningSummaryUnavailable }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ reasoningSummary: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ reasoningSummary: false })
)
expect(onReasoningSummaryUnavailable).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).not.toHaveBeenCalled()
// Cached: a later run in the same workspace skips the summary outright,
// but a different model on the same provider shares the cache entry.
await runChatLoop(
createConfig({
workspace,
modelProvider: { provider: 'openai', model: 'o3' }
})
)
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual(
expect.objectContaining({ reasoningSummary: false })
)
})
it('composes with the web-search fallback when the errors arrive web-search first', async () => {
mocks.providerSupportsWebSearch.mockReturnValue(true)
const onReasoningSummaryUnavailable = vi.fn()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.1' }
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error("Hosted tool 'web_search' is not supported with this model"), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockRejectedValueOnce(
Object.assign(
new Error('Your organization must be verified to generate reasoning summaries.'),
{
status: 400,
param: 'reasoning.summary',
code: 'unsupported_value',
error: { type: 'invalid_request_error' }
}
)
)
.mockResolvedValue({})
await runChatLoop(
createConfig({
workspace,
modelProvider,
onReasoningSummaryUnavailable,
onWebSearchUnavailable
})
)
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true, reasoningSummary: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false, reasoningSummary: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual(
expect.objectContaining({ webSearch: false, reasoningSummary: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(onReasoningSummaryUnavailable).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).not.toHaveBeenCalled()
})
it('does not request a summary when reasoning is explicitly off via a disable token', async () => {
// gpt-5.1+ reasoning is turned off with the explicit 'none' effort on the
// wire, while the effective reasoning resolves to undefined.
mocks.resolveRequestReasoning.mockReturnValue('none')
mocks.resolveEffectiveReasoning.mockReturnValue(undefined)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
const workspace = `workspace-${randomUUID()}`
await runChatLoop(
createConfig({ workspace, modelProvider: { provider: 'openai', model: 'gpt-5.1' } })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ reasoningEffort: 'none', reasoningSummary: false })
)
})
})
describe('runChatLoop prompt cache key fallback', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockReturnValue(false)
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.resolveEffectiveReasoning.mockReturnValue(undefined)
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
mocks.parseOpenAICompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
})
it('retries once without the key when the endpoint rejects it, and caches that', async () => {
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.6' }
const promptCacheKey = `${workspace}:openai:gpt-5.6:chat`
mocks.buildPromptCacheKey.mockReturnValue(promptCacheKey)
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error('Unrecognized request argument supplied: prompt_cache_key'), {
status: 400,
param: 'prompt_cache_key'
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, modelProvider }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ promptCacheKey })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ promptCacheKey: undefined })
)
// The rejection belongs to the endpoint, so a later run skips the key outright
// rather than paying the failed round-trip again.
expect(mocks.getCompletion).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace, modelProvider }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual(
expect.objectContaining({ promptCacheKey: undefined })
)
})
it('does not treat an unrelated 400 as a prompt cache key rejection', async () => {
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = { provider: 'openai', model: 'gpt-5.6' }
mocks.buildPromptCacheKey.mockReturnValue(`${workspace}:openai:gpt-5.6:chat`)
mocks.getOpenAIResponsesCompletion.mockRejectedValue(
Object.assign(new Error('context_length_exceeded'), { status: 400 })
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, modelProvider }))
// Falls through to the Completions API instead of burning a retry on the key.
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
})
})
describe('runChatLoop lastIterationUsage', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveRequestReasoning.mockReturnValue(undefined)
})
it('keeps the usage of the last completion that reported it', async () => {
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion
.mockResolvedValueOnce({
shouldContinue: true,
tokenUsage: { prompt: 1000, completion: 50, total: 1050 }
})
.mockResolvedValueOnce({
shouldContinue: false,
tokenUsage: { prompt: 1200, completion: 80, total: 1280 }
})
const result = await runChatLoop({ ...createConfig({ workspace }), maxIterations: 2 })
expect(result.lastIterationUsage).toEqual({ prompt: 1200, completion: 80, total: 1280 })
// the aggregate keeps summing across iterations
expect(result.tokenUsage).toMatchObject({ prompt: 2200, completion: 130, total: 2330 })
})
it('ignores empty usage reports and returns null when none are real', async () => {
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage: { prompt: 0, completion: 0, total: 0 }
})
const result = await runChatLoop(createConfig({ workspace }))
expect(result.lastIterationUsage).toBeNull()
})
})
describe('runChatLoop output token limit', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveRequestReasoning.mockReturnValue(undefined)
})
it('fails a truncated Responses iteration instead of replaying it on the Completions API', async () => {
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockRejectedValue(new OutputTokenLimitError())
await expect(
runChatLoop(createConfig({ workspace: `workspace-${randomUUID()}` }))
).rejects.toBeInstanceOf(OutputTokenLimitError)
expect(mocks.getCompletion).not.toHaveBeenCalled()
})
})
// Builders for the message shapes the chat loop accumulates.
const assistant = (content: string): ChatCompletionMessageParam => ({ role: 'assistant', content })
const assistantTools = (...ids: string[]): ChatCompletionMessageParam => ({
role: 'assistant',
content: '',
tool_calls: ids.map((id) => ({
id,
type: 'function',
function: { name: 'do_thing', arguments: '{}' }
}))
})
describe('runChatLoop per-chat tool schemas', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
})
// The def `schemaFor` returns is the chat's own: it must be what the provider is sent and
// what the tool is dispatched with, while the shared tool object keeps its own.
it('sends and dispatches the def schemaFor returns, leaving the shared tool alone', async () => {
const base = { type: 'function', function: { name: 'dyn', parameters: {} } } as any
const perChat = {
type: 'function',
function: { name: 'dyn', parameters: { marked: 1 } }
} as any
const shared = { def: base, fn: async () => '', schemaFor: async () => perChat }
await runChatLoop({ ...createConfig({ workspace: `ws-${randomUUID()}` }), tools: [shared] })
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][2]).toEqual([perChat])
expect(mocks.parseOpenAIResponsesCompletion.mock.calls[0][4][0].def).toBe(perChat)
expect(shared.def).toBe(base)
})
})
const tool = (id: string): ChatCompletionMessageParam => ({
role: 'tool',
tool_call_id: id,
content: 'result'
})
const user = (content: string): ChatCompletionMessageParam => ({ role: 'user', content })
describe('closeInterruptedToolBatch', () => {
it('keeps the answered half of a batch still executing, pairing the rest', () => {
// The model asked for two tools in one message; the first wrote a script
// (a real side effect) and the second is still going. Truncating would drop
// both, so the restored chat would not know the script exists.
const msgs = [assistantTools('a', 'b'), tool('a')]
expect(closeInterruptedToolBatch(msgs, 'stopped')).toEqual([
assistantTools('a', 'b'),
tool('a'),
{ role: 'tool', tool_call_id: 'b', content: 'stopped' }
])
})
it('leaves an already-paired transcript alone', () => {
const msgs = [assistantTools('a'), tool('a'), assistant('done')]
expect(closeInterruptedToolBatch(msgs, 'stopped')).toEqual(msgs)
})
})
describe('truncateToToolPairedPrefix', () => {
it('returns an empty array unchanged', () => {
expect(truncateToToolPairedPrefix([])).toEqual([])
})
it('drops a trailing dangling tool_call (aborted before the result)', () => {
const msgs = [assistantTools('a')]
expect(truncateToToolPairedPrefix(msgs)).toEqual([])
})
it('drops a partially-answered batch entirely (A answered, B missing)', () => {
const msgs = [assistantTools('a', 'b'), tool('a')]
expect(truncateToToolPairedPrefix(msgs)).toEqual([])
})
it('keeps text + a completed round-trip, then drops the dangling tail', () => {
const msgs = [
assistant('let me check'),
assistantTools('a'),
tool('a'),
assistant('more'),
assistantTools('b') // dangling
]
expect(truncateToToolPairedPrefix(msgs)).toEqual([
assistant('let me check'),
assistantTools('a'),
tool('a'),
assistant('more')
])
})
it('treats a user message as a valid boundary only when no tool calls are pending', () => {
const ok = [assistantTools('a'), tool('a'), user('next')]
expect(truncateToToolPairedPrefix(ok)).toEqual(ok)
const dangling = [assistantTools('a'), user('next')]
expect(truncateToToolPairedPrefix(dangling)).toEqual([])
})
it('leaves a valid full conversation unchanged (no loss on the normal path)', () => {
const msgs = [
assistant('thinking'),
assistantTools('a', 'b'),
tool('a'),
tool('b'),
assistant('done')
]
expect(truncateToToolPairedPrefix(msgs)).toEqual(msgs)
})
})
describe('runChatLoop history sanitization', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockReturnValue(false)
mocks.resolveRequestReasoning.mockReturnValue(undefined)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
})
it('replaces unparseable historical tool_call arguments before sending, without mutating the stored history', async () => {
const config = createConfig({ workspace: `workspace-${randomUUID()}` })
const poisoned: ChatCompletionMessageParam = {
role: 'assistant',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'patch_app_file', arguments: '{"path": "u/x", "old_string": "trunc' }
}
]
}
config.messages.push(poisoned, {
role: 'tool',
tool_call_id: 'call_1',
content: 'Error while calling tool'
})
await runChatLoop(config)
const sent = mocks.getOpenAIResponsesCompletion.mock.calls[0][0] as any[]
const sentAssistant = sent.find((m) => m.role === 'assistant' && m.tool_calls)
expect(sentAssistant.tool_calls[0].function.arguments).toBe('{}')
expect((poisoned as any).tool_calls[0].function.arguments).toContain('trunc')
})
})
describe('runChatLoop per-iteration vision gating', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockReturnValue(false)
mocks.resolveRequestReasoning.mockReturnValue(undefined)
})
// The loop owns the vision strip entirely — the caller passes the full
// history even for a known text-only model (see AIChatManager.chatRequest).
it('strips image parts from the first iteration on a known text-only model', async () => {
const config = createConfig({
workspace: `workspace-${randomUUID()}`,
modelProvider: { provider: 'groq', model: 'llama-3.3-70b-versatile' }
})
config.messages.splice(0, config.messages.length, {
role: 'user',
content: [
{ type: 'text', text: 'earlier turn' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,IMG' } }
]
} as any)
mocks.getCompletion.mockResolvedValue({})
mocks.parseOpenAICompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
await runChatLoop(config)
expect(mocks.getCompletion).toHaveBeenCalled()
expect(JSON.stringify(mocks.getCompletion.mock.calls[0][0])).not.toContain('image_url')
})
// The model selector stays enabled while the loop runs, and the loop re-reads
// the model each iteration. The vision gate has to be re-applied at the same
// cadence: filtering once at send start would ship the history's image parts
// to a text-only model the user switched to mid-turn.
it('strips image parts when the model switches to a text-only one mid-loop', async () => {
const config = createConfig({ workspace: `workspace-${randomUUID()}` })
config.maxIterations = 2
config.messages.splice(0, config.messages.length, {
role: 'user',
content: [
{ type: 'text', text: 'look at this' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,IMG' } }
]
} as any)
// vision model on the first iteration, known text-only on the second
let iteration = 0
Object.defineProperty(config, 'modelProvider', {
get: () =>
iteration === 0
? { provider: 'openai', model: 'gpt-4.1' }
: { provider: 'groq', model: 'llama-3.3-70b-versatile' }
})
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockImplementation(async () => {
iteration++
return { shouldContinue: true, tokenUsage }
})
mocks.getCompletion.mockResolvedValue({})
mocks.parseOpenAICompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
await runChatLoop(config)
// the vision iteration carries the image...
const first = mocks.getOpenAIResponsesCompletion.mock.calls[0][0]
expect(JSON.stringify(first)).toContain('image_url')
// ...the text-only iteration must not
expect(mocks.getCompletion).toHaveBeenCalled()
const second = mocks.getCompletion.mock.calls[0][0]
expect(JSON.stringify(second)).not.toContain('image_url')
})
// A history whose images together exceed the provider request-size limit gets
// the whole request rejected with a 413 the vision-rejection fallback cannot
// classify — the loop must keep the outbound copy under the byte cap.
it('drops the oldest images when the history exceeds the total byte cap', async () => {
const config = createConfig({ workspace: `workspace-${randomUUID()}` })
const bigImage = () => ({
type: 'image_url',
// two of these exceed MAX_TOTAL_IMAGE_BYTES (12MB decoded)
image_url: { url: 'data:image/png;base64,' + 'A'.repeat(9_000_000) }
})
config.messages.splice(
0,
config.messages.length,
{ role: 'user', content: [{ type: 'text', text: 'old' }, bigImage()] } as any,
{ role: 'assistant', content: 'ok' },
{ role: 'user', content: [{ type: 'text', text: 'new' }, bigImage()] } as any
)
mocks.getOpenAIResponsesCompletion.mockResolvedValue({})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({ shouldContinue: false, tokenUsage })
await runChatLoop(config)
const sent = mocks.getOpenAIResponsesCompletion.mock.calls[0][0] as any[]
const users = sent.filter((m) => m.role === 'user')
// the oldest message's image is stripped to a placeholder...
expect(users[0].content.some((p: any) => p.type === 'image_url')).toBe(false)
expect(JSON.stringify(users[0].content)).toContain('[image omitted]')
// ...the newest keeps its image part
expect(users[1].content.some((p: any) => p.type === 'image_url')).toBe(true)
// the stored history is untouched
expect((config.messages[0] as any).content.some((p: any) => p.type === 'image_url')).toBe(true)
})
})