mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
* 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>
818 lines
29 KiB
TypeScript
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)
|
|
})
|
|
})
|