openpencil/tests/engine/app/chat/step-limit.test.ts
Danila Poyarkov 0ff6b414e3
feat(ai): choose a thinking level per message (#809)
Reasoning effort was a free-text profile field that only reached OpenAI
and OpenRouter, so Anthropic, Google, and DeepSeek models never thought
in direct chat. AI SDK 7 standardizes a `reasoning` call option that
those providers map to their own thinking settings, so profiles now
store one typed thinking level, shared with Pi, and requests pass it
through that option. OpenRouter's provider ignores the standard option
and receives its own reasoning option instead.

The composer offers the level next to the Design profile and reads it
per request, so a change applies to the next message without
rebuilding the transport. Saved profiles migrate from the Pi level or
the old effort string. Finished reasoning shows how long the model
thought while the block streamed.
2026-10-03 13:30:05 +04:00

127 lines
4.1 KiB
TypeScript

import 'fake-indexeddb/auto'
import { expect, spyOn, test } from 'bun:test'
import { simulateReadableStream } from 'ai'
import { MockLanguageModelV4 } from 'ai/test'
import { toRaw } from 'vue'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { maxAgentSteps, reasoningDisplay } from '@/app/ai/chat/preferences'
import { DEFAULT_AGENT_STEPS, resolveAgentStepLimit } from '@/app/ai/chat/step-limit'
import { createToolLoopTransport } from '@/app/ai/chat/transports'
import { didHitStepLimit } from '@/app/ai/tools'
import { aiToolOverrides } from '@/app/ai/tools/preferences'
import * as figmaFactory from '@/app/automation/bridge/figma-factory'
import { createEditorStore } from '@/app/editor/session/create'
import { appPreferences } from '@/app/settings/preferences/store'
import { MOCK_USAGE } from '#tests/helpers/chat/usage'
test.each([undefined, null, '100', 0, -1, 1.5, 1001, Number.NaN, Infinity])(
'invalid stored step limit %p falls back to the default',
(value) => {
expect(resolveAgentStepLimit(value)).toBe(DEFAULT_AGENT_STEPS)
}
)
test('step limit and reasoning updates preserve each other', () => {
const previous = structuredClone(toRaw(appPreferences.value))
try {
maxAgentSteps.value = 200
reasoningDisplay.value = 'expanded'
expect(maxAgentSteps.value).toBe(200)
maxAgentSteps.value = 1
expect(reasoningDisplay.value).toBe('expanded')
expect(maxAgentSteps.value).toBe(1)
maxAgentSteps.value = 1000
expect(maxAgentSteps.value).toBe(1000)
} finally {
appPreferences.value = previous
}
})
test('stop condition, warnings and limit detection share the budget captured per message', async () => {
const previousPreferences = structuredClone(toRaw(appPreferences.value))
const previousTools = aiToolOverrides.value
const store = createEditorStore()
// Viewport DOM plumbing is outside this transport/budget contract.
const factory = spyOn(figmaFactory, 'makeFigmaFromStore').mockImplementation(
(editor) => new FigmaAPI(editor.graph)
)
let calls = 0
const model = new MockLanguageModelV4({
doStream: async () => {
calls++
// Changing Settings while streaming must only affect the next message.
maxAgentSteps.value = 1
return {
stream: simulateReadableStream({
initialDelayInMs: null,
chunkDelayInMs: null,
chunks: [
{
type: 'tool-call',
toolCallId: `call-${calls}`,
toolName: 'get_selection',
input: '{}'
},
{
type: 'finish',
finishReason: { unified: 'tool-calls', raw: undefined },
usage: MOCK_USAGE
}
]
})
}
}
})
try {
maxAgentSteps.value = 2
aiToolOverrides.value = {}
const transport = createToolLoopTransport({
store,
providerID: 'openai',
model,
effectiveModelID: 'test',
maxOutputTokens: 100,
thinkingLevel: () => 'default'
})
async function send() {
const stream = await transport.sendMessages({
trigger: 'submit-message',
chatId: 'step-limit',
messageId: undefined,
messages: [{ id: 'user', role: 'user', parts: [{ type: 'text', text: 'Inspect' }] }]
})
const reader = stream.getReader()
try {
while (true) {
const next = await reader.read()
if (next.done) break
expect(next.value.type).not.toBe('error')
}
} finally {
reader.releaseLock()
}
}
await send()
expect(calls).toBe(2)
expect(
JSON.stringify(model.doStreamCalls[1]?.prompt.filter((message) => message.role === 'tool'))
).toContain('out of 2')
expect(didHitStepLimit(store)).toBe(true)
maxAgentSteps.value = 100
expect(didHitStepLimit(store)).toBe(true)
maxAgentSteps.value = 1
await send()
expect(calls).toBe(3)
expect(didHitStepLimit(store)).toBe(true)
} finally {
appPreferences.value = previousPreferences
aiToolOverrides.value = previousTools
factory.mockRestore()
store.dispose()
}
})