diff --git a/.playwright/scripts/__tests__/agentInput.e2e.mjs b/.playwright/scripts/__tests__/agentInput.e2e.mjs index 6ae63142..14e485ec 100644 --- a/.playwright/scripts/__tests__/agentInput.e2e.mjs +++ b/.playwright/scripts/__tests__/agentInput.e2e.mjs @@ -127,6 +127,84 @@ for (const dimensions of [[2, 1], [2400, 1200]]) { }) } +test('codemode opt-in executes native sandbox tools and stays disabled after opting out and restarting', async ({ buddy }) => { + const requests = [] + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || request.url !== '/v1/chat/completions') { + response.writeHead(404).end() + return + } + const chunks = [] + for await (const chunk of request) + chunks.push(chunk) + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) + requests.push(payload) + const enabled = payload.tools?.some(tool => tool.function?.name === 'codemode') + const execute = enabled && payload.messages.findLastIndex(message => message.role === 'user') > payload.messages.findLastIndex(message => message.role === 'tool') + const common = { id: `codemode-${requests.length}`, model: 'codemode-fixture', object: 'chat.completion.chunk', created: 1 } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + const delta = execute + ? { role: 'assistant', tool_calls: [{ index: 0, id: `script-${requests.length}`, type: 'function', function: { name: 'codemode', arguments: JSON.stringify({ code: 'await tools.write({ path: "codemode-result.txt", content: "codemode verified" }); text(await tools.read({ path: "codemode-result.txt" }));' }) } }] } + : { role: 'assistant', content: 'Codemode preference verified.' } + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`) + response.write(`data: ${JSON.stringify({ ...common, choices: [{ index: 0, delta: {}, finish_reason: execute ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 32, completion_tokens: 8, total_tokens: 40 } })}\n\n`) + response.end('data: [DONE]\n\n') + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const instance = await buddy.createInstance('codemode-native') + try { + let desktop = await instance.launch() + await useSyntheticCredentialStorage(desktop) + await desktop.page.evaluate(async (baseUrl) => { + const providers = window.lexoraDesktop.localChat.providers + await providers.upsertCustom({ id: 'codemode-fixture', displayName: 'Codemode fixture', api: 'openai-completions', baseUrl, enabled: true, models: [{ id: 'codemode-fixture', name: 'Codemode fixture', input: ['text'], reasoning: false, contextWindow: 128000, maxTokens: 1024 }] }) + const stop = providers.onAuthChallenge((challenge) => { + if (challenge.providerId === 'codemode-fixture' && challenge.type === 'secret') + void providers.respondToAuth(challenge.challengeId, 'offline-fixture-key') + }) + try { + await providers.login('codemode-fixture', 'api_key') + } + finally { + stop() + } + await providers.setDefaultModel({ providerId: 'codemode-fixture', modelId: 'codemode-fixture', reasoning: null }) + }, `http://127.0.0.1:${server.address().port}/v1`) + await desktop.page.reload() + const send = async (text, count) => { + await desktop.page.locator('.desktop-chat-composer__prosemirror:visible').fill(text) + await desktop.page.getByRole('button', { name: '发送消息', exact: true }).click() + await expect.poll(() => completedRuns(instance.home)).toBe(count) + } + await send('Verify the default tools.', 1) + expect(requests.at(-1).tools.some(tool => tool.function?.name === 'codemode')).toBe(false) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { codemode: true } })) + await send('Run the script and inspect its file.', 2) + const scriptResult = requests.at(-1).messages.findLast(message => message.role === 'tool' && message.content.includes('Script completed')) + expect(scriptResult?.content).toContain('codemode verified') + const journal = await sessionJournal(instance.home) + expect(journal).toContain('"nestedCalls"') + expect(journal).toContain('codemode-result.txt') + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'codemode-native-tools.png'), animations: 'disabled' }) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ runtime: { codemode: false } })) + await send('Verify ordinary tools again.', 3) + expect(requests.at(-1).tools.some(tool => tool.function?.name === 'codemode')).toBe(false) + await instance.stop() + desktop = await instance.launch() + await useSyntheticCredentialStorage(desktop) + await desktop.page.reload() + await send('Verify tools after restart.', 4) + expect(requests.at(-1).tools.some(tool => tool.function?.name === 'codemode')).toBe(false) + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) + } + finally { + await instance.stop() + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +}) + test('model retries retain their run budget, expose progress and allow cancelling unlimited backoff', async ({ buddy }) => { const requests = [] const finishThinking = Promise.withResolvers() diff --git a/.playwright/scripts/__tests__/settings.e2e.mjs b/.playwright/scripts/__tests__/settings.e2e.mjs index 79a23a1d..4d097b51 100644 --- a/.playwright/scripts/__tests__/settings.e2e.mjs +++ b/.playwright/scripts/__tests__/settings.e2e.mjs @@ -391,6 +391,39 @@ export function render(context, container) { expect(diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) }) +test('codemode defaults off and its runtime switch persists across restart and language changes', async ({ buddy }) => { + const instance = await buddy.createInstance('codemode-settings') + let desktop = await instance.launch() + const openRuntime = async () => { + await openSettings(desktop.page) + await desktop.page.locator('.desktop-settings-sidebar').getByRole('link', { name: '运行时', exact: true }).click() + } + const toggle = () => desktop.page.getByTestId('codemode-setting').getByRole('switch') + const preference = () => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime.codemode) + await openRuntime() + await expect(toggle()).toHaveAttribute('aria-checked', 'false') + expect(await preference()).toBe(false) + await toggle().click() + await expect.poll(preference).toBe(true) + await expect(toggle()).toHaveAttribute('aria-checked', 'true') + await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'codemode-runtime-enabled.png'), animations: 'disabled' }) + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'en-US' } })) + await expect(toggle()).toHaveAccessibleName('Codemode tool orchestration') + await expect(toggle()).toHaveAttribute('aria-checked', 'true') + await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { language: 'zh-CN' } })) + await instance.stop() + desktop = await instance.launch() + await openRuntime() + await expect(toggle()).toHaveAttribute('aria-checked', 'true') + await toggle().click() + await expect.poll(preference).toBe(false) + await instance.stop() + desktop = await instance.launch() + await openRuntime() + await expect(toggle()).toHaveAttribute('aria-checked', 'false') + expect(desktop.diagnostics.console.filter(item => item.type === 'pageerror')).toEqual([]) +}) + test('global model retry settings preserve finite, unlimited and disabled values across restart', async ({ buddy }) => { const instance = await buddy.createInstance('model-retry-settings') let desktop = await instance.launch() @@ -475,7 +508,7 @@ test('global model retry settings preserve finite, unlimited and disabled values await desktop.page.emulateMedia({ reducedMotion: 'no-preference' }) await desktop.page.getByTestId('cache-warming-setting').locator('.n-select').click() await desktop.page.locator('.n-base-select-menu').getByText('任务执行期间', { exact: true }).click() - await expect.poll(() => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime)).toEqual({ cacheWarming: 'streaming', modelRetryLimit: 'unlimited' }) + await expect.poll(() => desktop.page.evaluate(async () => (await window.lexoraDesktop.settings.get()).runtime)).toEqual({ cacheWarming: 'streaming', codemode: false, modelRetryLimit: 'unlimited' }) await desktop.page.screenshot({ path: path.join(instance.artifactDirectory, 'retry-settings-light.png'), animations: 'disabled' }) await desktop.page.evaluate(() => window.lexoraDesktop.settings.update({ desktop: { theme: 'dark' } })) await expect(desktop.page.locator('.buddy-app')).toHaveClass(/is-dark/) diff --git a/.playwright/scripts/buddy-test.mjs b/.playwright/scripts/buddy-test.mjs index 30c799c9..2606ab7c 100644 --- a/.playwright/scripts/buddy-test.mjs +++ b/.playwright/scripts/buddy-test.mjs @@ -14,7 +14,7 @@ const buddyRoot = path.join(repoRoot, 'apps/buddy') const require = createRequire(path.join(buddyRoot, 'package.json')) const defaultConfig = '[desktop]\nlanguage = "zh-CN"\ntheme = "light"\nnotifications_enabled = false\nlaunch_at_login = false\n[pet]\nenabled = false\n[proxy]\nmode = "direct"\nserver = ""\n' -export async function createBuddyTestRun({ runId = process.env.LEXORA_TEST_RUN_ID, dataRoot = path.join(homedir(), '.lexora-test'), artifactRoot = path.join(repoRoot, '.playwright/runs', randomUUID()) } = {}) { +export async function createBuddyTestRun({ appPath = process.env.LEXORA_TEST_APP_PATH ?? buddyRoot, runId = process.env.LEXORA_TEST_RUN_ID, dataRoot = path.join(homedir(), '.lexora-test'), artifactRoot = path.join(repoRoot, '.playwright/runs', randomUUID()) } = {}) { runId ??= `${new Date().toISOString().replaceAll(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}` assert(/^[\w-]{1,100}$/.test(runId), 'Invalid test run ID') const root = await canonicalPath(path.resolve(dataRoot)) @@ -89,7 +89,7 @@ export async function createBuddyTestRun({ runId = process.env.LEXORA_TEST_RUN_I try { application = await electron.launch({ executablePath: require('electron'), - args: [...(process.platform === 'linux' ? ['--ozone-platform=x11', '--disable-setuid-sandbox'] : []), '--disable-gpu', buddyRoot], + args: [...(process.platform === 'linux' ? ['--ozone-platform=x11', '--disable-setuid-sandbox'] : []), '--disable-gpu', path.resolve(appPath)], chromiumSandbox: true, cwd: repoRoot, env: environment, diff --git a/apps/buddy/electron.vite.config.ts b/apps/buddy/electron.vite.config.ts index cf0f0606..34b4d4ce 100644 --- a/apps/buddy/electron.vite.config.ts +++ b/apps/buddy/electron.vite.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ build: { outDir: join(electronOutputRoot, 'main'), rollupOptions: { - external: ['electron', '@silvia-odwyer/photon-node', 'fflate/node'], + external: ['electron', '@silvia-odwyer/photon-node', 'fflate/node', /^@earendil-works\/pi-/], input: { 'index': fileURLToPath(new URL('./electron/main/index.ts', import.meta.url)), 'buddy-service': fileURLToPath(new URL('./service/src/index.ts', import.meta.url)), diff --git a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts index c2fa36ca..9107072c 100644 --- a/apps/buddy/electron/main/app/DesktopRuntimeHost.ts +++ b/apps/buddy/electron/main/app/DesktopRuntimeHost.ts @@ -224,7 +224,7 @@ export class DesktopRuntimeHost { await this.#network?.apply(config.proxy) const previous = this.#config?.runtime this.#config = config - if (previous?.cacheWarming !== config.runtime.cacheWarming || previous?.modelRetryLimit !== config.runtime.modelRetryLimit) + if (previous?.cacheWarming !== config.runtime.cacheWarming || previous?.codemode !== config.runtime.codemode || previous?.modelRetryLimit !== config.runtime.modelRetryLimit) this.#service?.notify(runtimePreferencesRpc.changed, config.runtime) await Promise.all(this.#features.map(feature => feature.applyConfig(config))) if (app.isPackaged && this.#environment.paths.profile === 'stable') diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index 6637b64e..8945d630 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -109,6 +109,7 @@ const petConfigSchema = z.object({ const lexoraConfigFileSchema = z.object({ runtime: z.object({ cache_warming: runtimePreferencesSchema.shape.cacheWarming.default(DEFAULT_RUNTIME_PREFERENCES.cacheWarming), + codemode: runtimePreferencesSchema.shape.codemode.default(DEFAULT_RUNTIME_PREFERENCES.codemode), model_retry_limit: runtimePreferencesSchema.shape.modelRetryLimit.default(DEFAULT_RUNTIME_PREFERENCES.modelRetryLimit), }).passthrough().prefault({}), browser: z.object({ @@ -280,7 +281,7 @@ function decodeConfig(value: unknown): LexoraConfig { } return { - runtime: { cacheWarming: config.runtime.cache_warming, modelRetryLimit: config.runtime.model_retry_limit }, + runtime: { cacheWarming: config.runtime.cache_warming, codemode: config.runtime.codemode, modelRetryLimit: config.runtime.model_retry_limit }, browser: { screenshotDestination: config.browser.screenshot_destination, defaultZoomFactor: config.browser.default_zoom_factor, @@ -331,7 +332,7 @@ function decodeConfig(value: unknown): LexoraConfig { function encodeConfig(config: LexoraConfig) { return { - runtime: { cache_warming: config.runtime.cacheWarming, model_retry_limit: config.runtime.modelRetryLimit }, + runtime: { cache_warming: config.runtime.cacheWarming, codemode: config.runtime.codemode, model_retry_limit: config.runtime.modelRetryLimit }, browser: { screenshot_destination: config.browser.screenshotDestination, default_zoom_factor: config.browser.defaultZoomFactor, diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index f0be128b..797efbed 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -33,11 +33,11 @@ describe('lexoraConfigStore', () => { const { store, configPath } = await createConfigStore() await mkdir(dirname(configPath), { recursive: true }) await writeFile(configPath, '[runtime]\ncache_warming = "streaming"\nfuture = true\n') - expect((await store.read()).runtime).toEqual({ cacheWarming: 'streaming', modelRetryLimit: 3 }) + expect((await store.read()).runtime).toEqual({ cacheWarming: 'streaming', codemode: false, modelRetryLimit: 3 }) for (const modelRetryLimit of [0, 7, 'unlimited'] as const) { await store.update({ runtime: { modelRetryLimit } }) await store.update({ desktop: { language: 'en-US' } }) - expect((await new LexoraConfigStore({ configPath }).read()).runtime).toEqual({ cacheWarming: 'streaming', modelRetryLimit }) + expect((await new LexoraConfigStore({ configPath }).read()).runtime).toEqual({ cacheWarming: 'streaming', codemode: false, modelRetryLimit }) expect(await readFile(configPath, 'utf8')).toContain('future = true') } const saved = await readFile(configPath, 'utf8') diff --git a/apps/buddy/package.json b/apps/buddy/package.json index 8242b87d..16b114c9 100644 --- a/apps/buddy/package.json +++ b/apps/buddy/package.json @@ -49,8 +49,8 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.77", - "@earendil-works/pi-ai": "^0.99.1", - "@earendil-works/pi-coding-agent": "^0.99.1", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-coding-agent": "1.1.0", "@js-temporal/polyfill": "^0.5.1", "@modelcontextprotocol/client": "2.0.0", "@mozilla/readability": "^0.6.0", diff --git a/apps/buddy/platform/filesystem/resolveFilePath.ts b/apps/buddy/platform/filesystem/resolveFilePath.ts index 484374c4..f3ecabfc 100644 --- a/apps/buddy/platform/filesystem/resolveFilePath.ts +++ b/apps/buddy/platform/filesystem/resolveFilePath.ts @@ -31,6 +31,8 @@ export async function resolveFilePath( try { const canonicalParent = filePaths.resolveInput(await realpath(cursor)) const metadata = await stat(canonicalParent) + if (!metadata.isFile() && !metadata.isDirectory()) + throw new FilePathResolutionError('INVALID_PATH') if (missingSegments.length > 0 && !metadata.isDirectory()) throw new FilePathResolutionError('INVALID_PATH') return { diff --git a/apps/buddy/service/src/BuddyService.ts b/apps/buddy/service/src/BuddyService.ts index 5537b9f9..7a574fe5 100644 --- a/apps/buddy/service/src/BuddyService.ts +++ b/apps/buddy/service/src/BuddyService.ts @@ -30,6 +30,7 @@ import { notificationsChanged } from '../../shared/notifications/notificationApi import { ApplicationEvents as EventPublisher } from '../../shared/observability/ApplicationEvents' import { observeLifecycleDiagnostics } from '../../shared/observability/lifecycleDiagnostics' import { openExternalResultSchema } from '../../shared/runtime/credentialProtocol' +import { runtimePreferencesRpc, runtimePreferencesSchema } from '../../shared/runtime/runtimePreferences' import { spaceChanged } from '../../shared/spaces/spaceApi' import { PiEventBridge } from './agent/events/PiEventBridge' import { BuddyAgentRunner } from './agent/execution/BuddyAgentRunner' @@ -1103,6 +1104,9 @@ export async function startBuddyService( startTurn: input => chatTurnService.start(input), } const contextUsageService = new ContextUsageSnapshotService({ + getRuntimePreferences: async () => { + return runtimePreferencesSchema.parse(await options.rpc.request(runtimePreferencesRpc.get, {})) + }, drafts: composerDrafts, tree: conversationTree, agentDirectory, diff --git a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts index 87378732..4a0e2662 100644 --- a/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts +++ b/apps/buddy/service/src/__tests__/buddyService.integration.spec.ts @@ -1145,7 +1145,7 @@ class OfflinePiSession implements ReusableBuddySession { } this.#emit({ type: 'message_end', message: toolMessage }) this.#emit({ type: 'entry_appended', entry: entry('pi-tool-1', toolMessage) }) - this.#emit({ type: 'agent_settled' }) + this.#emit({ type: 'agent_settled', aborted: false }) } subscribe(listener: (event: AgentSessionEvent) => void): () => void { @@ -1370,7 +1370,7 @@ function createRuntimeRpcHarness( value?: unknown } if (method === 'host.runtimePreferences.get') - return Promise.resolve({ cacheWarming: 'off', modelRetryLimit: 3 }) + return Promise.resolve({ cacheWarming: 'off', codemode: false, modelRetryLimit: 3 }) if (method === 'host.credentials.list') { return Promise.resolve({ ok: true, diff --git a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts index d0ec1de3..b5fd1b24 100644 --- a/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts +++ b/apps/buddy/service/src/agent/context/__tests__/buddyComposerInputBoundary.spec.ts @@ -304,10 +304,10 @@ describe('composer input at the Buddy session boundary', () => { let calls = 0 const fixture = await createFixture({ stream: model => ++calls === 1 - ? terminalStream(model, { stopReason: 'error', errorMessage: '429 rate limit exceeded' }) + ? terminalStream(model, { content: [], stopReason: 'error', errorMessage: '429 rate limit exceeded' }) : terminalStream(model), }) - fixture.reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 1 }) + fixture.reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 1 }) fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) await fixture.send(plan()) @@ -783,7 +783,7 @@ async function createFixture(options: { shutdown: created.shutdown, }) - reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 0 }) + reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 0 }) async function send(input: InputPlan, runId = `run-${input.messageId}`) { const reference = toBuddyInputReference(input) diff --git a/apps/buddy/service/src/agent/events/PiApplicationObserver.ts b/apps/buddy/service/src/agent/events/PiApplicationObserver.ts index 78153e94..ca581387 100644 --- a/apps/buddy/service/src/agent/events/PiApplicationObserver.ts +++ b/apps/buddy/service/src/agent/events/PiApplicationObserver.ts @@ -44,7 +44,7 @@ export class PiApplicationObserver { case 'tool_execution_end': { const tool = this.#tools.get(event.toolCallId) if (tool?.authorized) - this.#events.publish({ event: event.isError ? 'pi.tool.failed' : 'pi.tool.completed', level: event.isError ? 'warn' : 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: tool.turnId, durationMs: Math.round(performance.now() - tool.startedAt) }) + this.#events.publish({ event: event.isError ? 'pi.tool.failed' : 'pi.tool.completed', level: event.isError ? 'warn' : 'info', toolCallId: diagnosticToolCallId(event.toolCallId), turnId: tool.turnId, durationMs: event.durationMs }) this.#tools.delete(event.toolCallId) break } @@ -65,7 +65,7 @@ export class PiApplicationObserver { this.#compactionStartedAt = undefined break case 'agent_settled': - this.#events.publish({ event: 'pi.agent.settled', level: 'info' }) + this.#events.publish({ event: 'pi.agent.settled', level: 'info', ...(event.aborted ? { errorCode: 'MODEL_REQUEST_ABORTED' } : {}) }) break } } diff --git a/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts b/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts index f8f26eb8..448c88a9 100644 --- a/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/PiEventBridge.spec.ts @@ -8,6 +8,15 @@ import { describe, expect, it, vi } from 'vitest' import { PiEventBridge } from '../PiEventBridge' describe('piEventBridge', () => { + it('preserves cancellation reported by Pi after a continuation settles without an aborted message', async () => { + const { appended, channel, emit } = createProjectionHarness() + emit({ type: 'agent_start' }) + emit({ type: 'agent_settled', aborted: true }) + await channel.flush() + expect(channel.outcome.failureCode).toBe('MODEL_REQUEST_ABORTED') + expect(appended.at(-1)).toMatchObject({ type: 'run.progress', payload: { phase: 'idle', toolName: null } }) + }) + it('preserves registered labels in replayable tool events without adding them to output deltas', async () => { const { appended, channel, emit } = createProjectionHarness(undefined, () => 'Query local data') emit({ type: 'tool_execution_start', toolCallId: 'custom', toolName: 'custom_query', args: {} }) @@ -131,7 +140,7 @@ describe('piEventBridge', () => { type: 'tool_execution_update', }) } - emit({ type: 'agent_settled' }) + emit({ type: 'agent_settled', aborted: false }) await channel.flush() diff --git a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts index a1fe57e1..505b146a 100644 --- a/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts +++ b/apps/buddy/service/src/agent/events/__tests__/projectPiEvent.spec.ts @@ -24,7 +24,7 @@ describe('projectPiEvent retries', () => { expect(projectPiEvent({ type: 'turn_start' }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'model_requesting', toolName: null, retry: { ...retry, retryAt: null } } }]) expect(projectPiEvent({ type: 'auto_retry_end', success: true, attempt: 1 }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'model_requesting', toolName: null } }]) projectPiEvent({ type: 'auto_retry_start', attempt: 2, maxAttempts, delayMs: 4000, errorMessage: 'private' }, state, now) - expect(projectPiEvent({ type: 'agent_settled' }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'idle', toolName: null } }]) + expect(projectPiEvent({ type: 'agent_settled', aborted: false }, state).events).toEqual([{ type: 'run.progress', payload: { phase: 'idle', toolName: null } }]) }) it.each(['thinking_delta', 'text_delta', 'toolcall_delta'] as const)('restores live activity on the first nonempty %s before the retried response finishes', (type) => { @@ -224,7 +224,7 @@ describe('projectPiEvent product messages', () => { }, type: 'tool.denied', }]) - expect(projectPiEvent({ type: 'agent_settled' }, state).events).toEqual([{ + expect(projectPiEvent({ type: 'agent_settled', aborted: false }, state).events).toEqual([{ payload: { phase: 'idle', toolName: null }, type: 'run.progress', }]) diff --git a/apps/buddy/service/src/agent/events/projectPiEvent.ts b/apps/buddy/service/src/agent/events/projectPiEvent.ts index 78a47c97..39ccda3d 100644 --- a/apps/buddy/service/src/agent/events/projectPiEvent.ts +++ b/apps/buddy/service/src/agent/events/projectPiEvent.ts @@ -118,7 +118,10 @@ export function projectPiEvent( case 'agent_start': return progressProjection(state, 'preparing') case 'agent_settled': - return progressProjection(state, 'idle', null, null) + return { + ...progressProjection(state, 'idle', null, null), + ...(event.aborted ? { failureCode: 'MODEL_REQUEST_ABORTED' as const } : {}), + } case 'turn_start': case 'summarization_retry_attempt_start': return progressProjection(state, 'model_requesting', null, state.progress?.retry ? { ...state.progress.retry, retryAt: null } : null) diff --git a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts index 0f5bebd3..3dd8e262 100644 --- a/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts +++ b/apps/buddy/service/src/agent/execution/__tests__/BuddyAgentRunner.spec.ts @@ -1086,7 +1086,7 @@ class OfflineSession implements ReusableBuddySession { type: 'compaction_end', willRetry: false, }) - this.#emit({ type: 'agent_settled' }) + this.#emit({ type: 'agent_settled', aborted: false }) } subscribe(listener: (event: AgentSessionEvent) => void): () => void { @@ -1176,7 +1176,7 @@ class WaitingSession implements ReusableBuddySession { async abort(): Promise { this.#releasePrompt() - this.#emit({ type: 'agent_settled' }) + this.#emit({ type: 'agent_settled', aborted: true }) } abortCompaction(): void {} diff --git a/apps/buddy/service/src/agent/extensions/codemodeExtension.ts b/apps/buddy/service/src/agent/extensions/codemodeExtension.ts new file mode 100644 index 00000000..b5a09b88 --- /dev/null +++ b/apps/buddy/service/src/agent/extensions/codemodeExtension.ts @@ -0,0 +1,26 @@ +import type { BuddyCapability } from './BuddyCapability' +import { createCodemodeExtension } from '@earendil-works/pi-coding-agent' + +export const CODEMODE_EXTENSION = 'lexora-codemode' +export const CODEMODE_TOOL_NAME = 'codemode' + +export function createCodemodeCapability(isEnabled: () => boolean): BuddyCapability { + return { + extension: { + name: CODEMODE_EXTENSION, + factory: createCodemodeExtension({ + mode: 'on', + models: false, + runtime: { computeTimeoutMs: 30_000, maxPendingToolCalls: 16, maxQueuedToolCalls: 128 }, + }), + }, + classify: event => event.toolName === CODEMODE_TOOL_NAME ? { access: 'read', paths: [] } : null, + disclosure: [{ + source: { kind: 'builtin', id: 'codemode', title: 'Tool orchestration' }, + exposure: 'direct', + keywords: 'JavaScript parallel batch filter tools', + tools: [{ name: CODEMODE_TOOL_NAME }], + available: isEnabled, + }], + } +} diff --git a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts index d3a9d87e..9ba6c4f4 100644 --- a/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts +++ b/apps/buddy/service/src/agent/extensions/createBuddySessionExtensions.ts @@ -24,6 +24,7 @@ import { createShellCapability } from '../../sandbox/shellCapability' import { resolveShellExecution } from '../../sandbox/shellExecution' import { createChangeCaptureExtension } from './changeCaptureExtension' import { createChatQueueExtension } from './chatQueueExtension' +import { createCodemodeCapability } from './codemodeExtension' import { observeSessionTools } from './discovery/observeSessionTools' import { SessionToolCapabilities } from './discovery/SessionToolCapabilities' import { createToolDiscoveryCapability } from './discovery/toolDiscoveryExtension' @@ -57,6 +58,7 @@ export interface CreateBuddySessionExtensionsOptions { } export interface BuddySessionExtensions { + setCodemodeEnabled: (enabled: boolean) => void toolCapabilities: Pick dispose: () => Promise resourceRevisions: readonly BuddyCapabilityResourceRevision[] @@ -76,6 +78,7 @@ export async function createBuddySessionExtensions( const grants = new SessionDirectoryGrants(options.grants) const sandboxDirectories = new SandboxDirectoryPermissions() const runContext: BuddyExtensionRunContextStore = { current: null } + let codemodeEnabled = false const inputReferences: BuddyInputReferenceStore = { pending: null } const executionPermissions = new ToolExecutionPermissions() const diagnostics = observeSessionPermissions({ grants: grants.onDidChange, sandbox: sandboxDirectories.onDidChange, tools: executionPermissions.onDidChange }, options.conversationId, services.recordPermissions) @@ -124,7 +127,7 @@ export async function createBuddySessionExtensions( ? { id: options.spaceId, kind: 'space' } : { id: options.conversationId, kind: 'conversation' }, }) - capabilities.push(...await services.createCapabilities({ + capabilities.push(createCodemodeCapability(() => codemodeEnabled), ...await services.createCapabilities({ conversationId: options.conversationId, executionProfile: options.executionProfile, cwd: options.canonicalRoot, @@ -183,6 +186,7 @@ export async function createBuddySessionExtensions( ] return { + setCodemodeEnabled: enabled => codemodeEnabled = enabled, dispose, resourceRevisions, toolCapabilities, diff --git a/apps/buddy/service/src/agent/extensions/toolPolicyExtension.ts b/apps/buddy/service/src/agent/extensions/toolPolicyExtension.ts index a0f04121..ce2d36b1 100644 --- a/apps/buddy/service/src/agent/extensions/toolPolicyExtension.ts +++ b/apps/buddy/service/src/agent/extensions/toolPolicyExtension.ts @@ -24,14 +24,18 @@ export function createToolPolicyExtension(options: CreateToolPolicyExtensionOpti const run = options.getRunContext() if (!run) return block('RUN_CONTEXT_UNAVAILABLE') - const classification = await options.classifyTool?.(event, run) + const signal = event.signal ? AbortSignal.any([run.signal, event.signal]) : run.signal + signal.throwIfAborted() + const classification = await options.classifyTool?.(event, event.signal ? { ...run, signal } : run) + signal.throwIfAborted() const reason = classification && isToolClassificationFailure(classification) ? classification.reason - : await options.authorization.authorize(event, run, classification ?? {}) + : await options.authorization.authorize(event, run, classification ?? {}, { signal }) if (reason) { await run.onToolExecutionDenied?.({ denialCode: reason, toolCallId: event.toolCallId, toolName: event.toolName }) return block(reason) } + signal.throwIfAborted() await run.onToolExecutionAuthorized({ arguments: event.input, toolCallId: event.toolCallId, diff --git a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts index 6e04cec2..93eaff0f 100644 --- a/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts +++ b/apps/buddy/service/src/agent/resources/createBuddyResourceLoader.ts @@ -14,6 +14,7 @@ import { } from '@earendil-works/pi-coding-agent' import { DEFAULT_MODEL_RETRY_LIMIT } from '../../../../shared/runtime/runtimePreferences' import { SHELL_SANDBOX_EXTENSION } from '../../sandbox/shellCapability' +import { CODEMODE_EXTENSION, CODEMODE_TOOL_NAME } from '../extensions/codemodeExtension' import { getPiShellToolName, PI_BUILTIN_TOOL_NAME_SET } from '../extensions/piBuiltinTools' import { createReadFileExtension, READ_FILE_EXTENSION } from '../extensions/readFileExtension' import { createSystemSectionsExtension } from '../extensions/systemSectionsExtension' @@ -125,7 +126,8 @@ function validateLoadedExtensions(loader: DefaultResourceLoader): void { (PI_BUILTIN_TOOL_NAME_SET.has(toolName) && !(toolName === getPiShellToolName(process.platform) && extension.path === ``) && !(toolName === 'read' && extension.path === ``)) - || (!toolName.startsWith('lexora_') && !toolName.startsWith('mcp__') && !PI_BUILTIN_TOOL_NAME_SET.has(toolName)) + || (!toolName.startsWith('lexora_') && !toolName.startsWith('mcp__') && !PI_BUILTIN_TOOL_NAME_SET.has(toolName) + && !(toolName === CODEMODE_TOOL_NAME && extension.path === ``)) )) )) const hasInvalidToolSchema = result.extensions.some(extension => ( diff --git a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts index fd495200..b49a16ff 100644 --- a/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts +++ b/apps/buddy/service/src/agent/sessions/BuddySessionFactory.ts @@ -131,6 +131,7 @@ export class BuddySessionFactory { const inputWorkspace = new AttachmentToolWorkspace(blueprint.scratchRoot) let unsubscribePreferences: (() => void) | undefined reusable = createReusableBuddySession({ + setCodemodeEnabled: extensions.setCodemodeEnabled, skillReferences: blueprint.resources.skillReferences, tree, assertModelAccess: async (provider, model, contextWindow, maxTokens) => { diff --git a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts index f47eccbd..229b8c57 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/cacheWarming.spec.ts @@ -108,7 +108,7 @@ describe('buddy cache warming', () => { const unsubscribe = channel.subscribe() let task: Promise | undefined try { - reusable.applyPreferences({ cacheWarming: 'streaming', modelRetryLimit: 3 }) + reusable.applyPreferences({ cacheWarming: 'streaming', codemode: false, modelRetryLimit: 3 }) expect(created.session.settingsManager.getCacheWarmingMode()).toBe('off') expect(reusable.getCacheWarmingStatus?.()).toBe(stop === 'unsupported' ? 'unsupported' : 'idle') const release = await reusable.activateTurn({ @@ -155,7 +155,7 @@ describe('buddy cache warming', () => { expect(warmRequests).toBe(1) expect(reusable.getCacheWarmingStatus?.()).toBe('refreshing') if (stop === 'inflight-disable') - reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 3 }) + reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 3 }) if (stop === 'inflight-abort') await reusable.abort() if (stop === 'inflight-shutdown') @@ -186,7 +186,7 @@ describe('buddy cache warming', () => { expect(await usage.record({ createdAt: records[0]!.createdAt, model: model.id, provider: model.provider, purpose: 'cache_warm', runId: 'run-1', sourceEntryId: entry.id, usage: responseUsage(true) })).toBeNull() expect(repository.listForRun('run-1').filter(record => record.purpose === 'cache_warm')).toHaveLength(1) if (stop === 'disable') - reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 3 }) + reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 3 }) if (stop === 'abort') await reusable.abort() if (stop === 'model') diff --git a/apps/buddy/service/src/agent/sessions/__tests__/codemode.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/codemode.spec.ts new file mode 100644 index 00000000..f768f0c3 --- /dev/null +++ b/apps/buddy/service/src/agent/sessions/__tests__/codemode.spec.ts @@ -0,0 +1,389 @@ +import type { Api, AssistantMessage, Model, ToolCall } from '@earendil-works/pi-ai' +import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent' +import type { BuddyExecutionProfile } from '../../../../../shared/permissions/executionProfile' +import type { AuthorizedToolExecution, DeniedToolExecution } from '../../events/projectPiEvent' +import type { BuddySessionExtensionServices } from '../../extensions/createBuddySessionExtensions' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createAssistantMessageEventStream, InMemoryCredentialStore } from '@earendil-works/pi-ai' +import { createCodemodeExtension, ModelRuntime } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DEFAULT_RUNTIME_PREFERENCES } from '../../../../../shared/runtime/runtimePreferences' +import { ApprovalCancelledError } from '../../../approvals/ApprovalService' +import { CODEMODE_EXTENSION } from '../../extensions/codemodeExtension' +import { createBuddySessionExtensions } from '../../extensions/createBuddySessionExtensions' +import { createReusableBuddySession } from '../createReusableBuddySession' +import { createIsolatedBuddySession } from './isolatedBuddySession' + +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) + await cleanup() +}) + +describe('codemode runtime preference', () => { + it('cannot be enabled through discovery or expose host globals', async () => { + const fixture = await createFixture() + await fixture.turn({ name: 'lexora_tool_search', arguments: { toolNames: ['codemode'] } }) + expect(fixture.session.getActiveToolNames()).not.toContain('codemode') + expect(fixture.lastResult()).toContain('"notFound":["codemode"]') + + fixture.apply(true) + await fixture.turn({ name: 'codemode', arguments: { code: 'text({ node: typeof process, network: typeof fetch, models: typeof models });' } }) + expect(fixture.lastResult()).toContain('"node":"undefined"') + expect(fixture.lastResult()).toContain('"network":"undefined"') + expect(fixture.lastResult()).toContain('"models":"undefined"') + }) + + it('authorizes nested tools individually and keeps their parent and bounded journal records', async () => { + const fixture = await createFixture('workspace_write') + const outside = join(fixture.root, 'outside.txt') + await writeFile(outside, 'unchanged') + fixture.apply(true) + await fixture.turn({ + name: 'codemode', + arguments: { + code: `const results = await Promise.allSettled([tools.read({ path: "allowed.txt" }), tools.write({ path: ${JSON.stringify(outside)}, content: "blocked" })]); for (const result of results) text(result.status === "fulfilled" ? result.value : result.reason.message);`, + }, + }) + expect(fixture.lastResult()).toContain('allowed content') + expect(fixture.lastResult()).toContain('APPROVAL_DENIED') + expect(await readFile(outside, 'utf8')).toBe('unchanged') + const nested = fixture.events.flatMap(event => event.type === 'tool_execution_end' && event.parentToolCallId ? [event] : []) + expect(nested).toEqual(expect.arrayContaining([ + expect.objectContaining({ parentToolCallId: 'call-1', toolCallId: expect.stringMatching(/^call-1\/\d+$/), toolName: 'read', isError: false }), + expect.objectContaining({ parentToolCallId: 'call-1', toolCallId: expect.stringMatching(/^call-1\/\d+$/), toolName: 'write', isError: true }), + ])) + expect(new Set(nested.map(event => event.toolCallId)).size).toBe(2) + expect(fixture.authorized.map(call => call.toolName)).toEqual(['codemode', 'read']) + expect(fixture.denied).toEqual([expect.objectContaining({ toolName: 'write', denialCode: 'APPROVAL_DENIED' })]) + const result = fixture.session.messages.find(message => message.role === 'toolResult') + expect(result).toMatchObject({ toolName: 'codemode', nestedCalls: { + complete: true, + calls: [{ name: 'read', status: 'ok' }, { name: 'write', status: 'error' }], + } }) + const journal = fixture.session.sessionManager.getEntries().filter(entry => entry.type === 'message' && entry.message.role === 'toolResult') + expect(journal).toHaveLength(1) + }) + + it('keeps read-only file protection for scripts', async () => { + const fixture = await createFixture('read_only') + fixture.apply(true) + await fixture.turn({ name: 'codemode', arguments: { code: 'await tools.write({ path: "allowed.txt", content: "blocked" });' } }) + expect(fixture.lastResult()).toContain('READ_ONLY_PROFILE') + expect(await readFile(join(fixture.cwd, 'allowed.txt'), 'utf8')).toBe('allowed content') + }) + + it('interrupts runaway microtasks that cannot reach a host tool', async () => { + const code = 'while (true) { await Promise.resolve(); }' + const fixture = await createFixture('read_only', { computeTimeoutMs: 100 }) + fixture.apply(true) + await fixture.turn({ name: 'codemode', arguments: { code } }) + expect(fixture.lastResult()).toContain('100 ms compute budget') + expect(fixture.session.isStreaming).toBe(false) + expect(fixture.gates).toHaveLength(0) + }) + + it('excludes host tool waiting from computation and accumulates computation across awaits', async () => { + const fixture = await createFixture('read_only', { computeTimeoutMs: 200 }) + fixture.apply(true) + const waiting = fixture.turn({ name: 'codemode', arguments: { code: 'await tools.lexora_gate({ value: 0 }); text("wait completed");' } }) + await vi.waitFor(() => expect(fixture.gates).toHaveLength(1)) + await new Promise(resolve => setTimeout(resolve, 300)) + fixture.gates[0]!.release() + await waiting + expect(fixture.lastResult()).toContain('wait completed') + + const computing = fixture.turn({ name: 'codemode', arguments: { code: 'for (let i = 1; i <= 4; i++) { const start = Date.now(); while (Date.now() - start < 85) {} await tools.lexora_gate({ value: i }); } text("budget reset incorrectly");' } }) + for (let index = 1; index <= 2; index++) { + await vi.waitFor(() => expect(fixture.gates).toHaveLength(index + 1)) + fixture.gates[index]!.release() + } + await computing + expect(fixture.lastResult()).toContain('200 ms compute budget') + expect(fixture.lastResult()).not.toContain('budget reset incorrectly') + expect(fixture.gates).toHaveLength(3) + }) + + it('bounds parallel calls and drains waiting calls in order', async () => { + const fixture = await createFixture('read_only', { computeTimeoutMs: 1000, maxPendingToolCalls: 2, maxQueuedToolCalls: 4 }) + fixture.apply(true) + const task = fixture.turn({ name: 'codemode', arguments: { code: 'text(await Promise.all([0, 1, 2, 3, 4, 5].map(value => tools.lexora_gate({ value }))));' } }) + await vi.waitFor(() => expect(fixture.gates.map(gate => gate.value)).toEqual([0, 1])) + for (let index = 0; index < 6; index++) { + fixture.gates[index]!.release() + if (index < 4) + await vi.waitFor(() => expect(fixture.gates).toHaveLength(index + 3)) + } + await task + expect(fixture.lastResult()).toContain('Script completed') + expect(fixture.gates.map(gate => gate.value)).toEqual([0, 1, 2, 3, 4, 5]) + expect(fixture.maximumActive()).toBe(2) + }) + + it('cancels active calls and never starts queued calls when the queue overflows', async () => { + const fixture = await createFixture('read_only', { maxPendingToolCalls: 2, maxQueuedToolCalls: 1 }) + fixture.apply(true) + await fixture.turn({ name: 'codemode', arguments: { code: 'await Promise.all([0, 1, 2, 3].map(value => tools.lexora_gate({ value })));' } }) + expect(fixture.lastResult()).toContain('Too many queued tool calls') + expect(fixture.lastResult()).toContain('Inspect any completed actions') + expect(fixture.gates.map(gate => gate.value)).toEqual([0, 1]) + expect(fixture.gates.every(gate => gate.signal.aborted)).toBe(true) + }) + + it('retains completed side effects and warns against replay after a later script error', async () => { + const fixture = await createFixture('workspace_write') + fixture.apply(true) + await fixture.turn({ name: 'codemode', arguments: { code: 'await tools.write({ path: "allowed.txt", content: "completed write" }); throw new Error("later step failed");' } }) + expect(await readFile(join(fixture.cwd, 'allowed.txt'), 'utf8')).toBe('completed write') + expect(fixture.lastResult()).toContain('later step failed') + expect(fixture.lastResult()).toContain('do not replay the entire script') + expect(fixture.session.messages.filter(message => message.role === 'toolResult').at(-1)).toMatchObject({ nestedCalls: { complete: true, calls: [{ name: 'write', status: 'ok' }] } }) + }) + + it('waits for nested tool cleanup before releasing a failed script turn', async () => { + const fixture = await createFixture() + fixture.apply(true) + let settled = false + const task = fixture.turn({ name: 'codemode', arguments: { code: 'await Promise.all([tools.lexora_wait({}), tools.lexora_gate({ value: 0 }).then(() => { throw new Error("stop-script"); })]);' } }).then(() => { + settled = true + }) + const signal = await fixture.waiting.promise + try { + await vi.waitFor(() => expect(fixture.gates).toHaveLength(1)) + fixture.gates[0]!.release() + await vi.waitFor(() => expect(signal.aborted).toBe(true)) + expect(settled).toBe(false) + expect(fixture.session.isStreaming).toBe(true) + } + finally { + fixture.cleanup.resolve() + await task + } + expect(fixture.lastResult()).toContain('stop-script') + expect(fixture.controller.signal.aborted).toBe(false) + expect(fixture.session.isStreaming).toBe(false) + expect(fixture.events.filter(event => event.type === 'tool_execution_end').map(event => event.toolName)).toEqual(['lexora_gate', 'lexora_wait', 'codemode']) + await fixture.turn() + expect(fixture.session.messages.at(-1)).toMatchObject({ content: [{ type: 'text', text: 'Done' }] }) + }) + + it('cancels a nested approval on script timeout while keeping the run authorization lifetime', async () => { + const fixture = await createFixture('workspace_write') + const pending = Promise.withResolvers[0]>() + fixture.approvals.request = async (input) => { + pending.resolve(input) + await new Promise((resolve) => { + if (input.signal.aborted) + resolve() + else + input.signal.addEventListener('abort', () => resolve(), { once: true }) + }) + throw new ApprovalCancelledError() + } + fixture.apply(true) + const task = fixture.turn({ name: 'codemode', arguments: { code: `// @options: {"timeout_ms": 1000}\nawait tools.write({ path: ${JSON.stringify(join(fixture.root, 'outside.txt'))}, content: "blocked" });` } }) + const approval = await pending.promise + await task + expect(fixture.lastResult()).toContain('timed out') + expect(approval.signal.aborted).toBe(true) + expect(approval.runSignal).toBe(fixture.controller.signal) + expect(approval.runSignal?.aborted).toBe(false) + expect(fixture.authorized.map(call => call.toolName)).toEqual(['codemode']) + expect(fixture.events).toContainEqual(expect.objectContaining({ type: 'tool_execution_end', toolName: 'write', parentToolCallId: 'call-1', isError: true })) + }) +}) + +async function createFixture(executionProfile: BuddyExecutionProfile = 'read_only', runtimeOptions?: NonNullable[0]>['runtime']) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'buddy-codemode-'))) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const cwd = join(root, 'workspace') + await mkdir(cwd) + await writeFile(join(cwd, 'allowed.txt'), 'allowed content') + const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) + const model = runtime.getModels().find(model => model.provider === 'anthropic')! + await runtime.setRuntimeApiKey(model.provider, 'offline-fixture') + let nextCall: Pick | undefined + let first = false + let run = 0 + vi.spyOn(runtime, 'streamSimple').mockImplementation((target) => { + const call = first ? nextCall : undefined + first = false + return response(target, call, run) + }) + const waiting = Promise.withResolvers() + const cleanup = Promise.withResolvers() + const gates: { value: number, signal: AbortSignal, release: () => void }[] = [] + let active = 0 + let maximumActive = 0 + const services: BuddySessionExtensionServices = { + approvalService: { request: async () => ({ approvalId: 'denied-1', decision: 'denied' }) }, + attachmentService: {} as BuddySessionExtensionServices['attachmentService'], + changeCaptureService: { + beginFileTool: async () => {}, + beginWorkspaceTool: async () => {}, + finalizeRun: async () => {}, + finishFileTool: async () => {}, + finishWorkspaceTool: async () => ({ complete: true }), + markPartial: async () => {}, + }, + createCapabilities: async () => [{ + classify: event => ['lexora_wait', 'lexora_gate'].includes(event.toolName) ? { access: 'read', paths: [] } : null, + disclosure: [{ source: { kind: 'builtin', id: 'wait', title: 'Wait' }, exposure: 'direct', keywords: '', tools: [{ name: 'lexora_wait' }, { name: 'lexora_gate' }] }], + extension: { + name: 'lexora-wait-fixture', + factory(pi) { + pi.registerTool({ + name: 'lexora_gate', + label: 'Gate', + description: 'Controlled parallel operation', + parameters: Type.Object({ value: Type.Number() }), + executionMode: 'parallel', + async execute(_id, { value }, signal) { + const gate = Promise.withResolvers() + gates.push({ value, signal: signal!, release: () => gate.resolve() }) + active++ + maximumActive = Math.max(maximumActive, active) + const abort = () => gate.resolve() + if (signal?.aborted) + abort() + else + signal?.addEventListener('abort', abort, { once: true }) + try { + await gate.promise + return { content: [{ type: 'text', text: String(value) }], details: {} } + } + finally { + signal?.removeEventListener('abort', abort) + active-- + } + }, + }) + pi.registerTool({ + name: 'lexora_wait', + label: 'Wait', + description: 'Wait until cancelled', + parameters: Type.Object({}), + async execute(_id, _arguments, signal) { + waiting.resolve(signal!) + await new Promise((resolve) => { + if (signal?.aborted) + resolve() + else + signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + await cleanup.promise + return { content: [{ type: 'text', text: 'Cancelled' }], details: {} } + }, + }) + }, + }, + }], + directoryGrants: {} as BuddySessionExtensionServices['directoryGrants'], + } + const extensions = await createBuddySessionExtensions({ + canonicalRoot: cwd, + conversationId: 'codemode-conversation', + approvalPolicy: 'policy', + executionProfile, + grants: [{ canonicalRoot: cwd, root: cwd, grantId: 'workspace-1', kind: 'workspace' }], + services, + sessionMode: 'interactive', + signal: new AbortController().signal, + spaceId: null, + }) + cleanups.push(() => extensions.dispose()) + const created = await createIsolatedBuddySession({ + agentDir: join(root, 'agent'), + canonicalRoot: cwd, + cwd, + conversationsDirectory: join(root, 'conversations'), + conversationId: 'codemode-conversation', + branchId: 'codemode-branch', + approvalPolicy: 'policy', + executionProfile, + model, + modelRuntime: runtime, + thinkingLevel: 'off', + resources: { skillReadRoots: [], skillReferences: [], approvedSkills: [], context: { agentsFiles: [], diagnostics: [] }, directoryContext: '', revision: 'empty' }, + inProcessExtensions: extensions.inProcessExtensions.map(extension => runtimeOptions && extension.name === CODEMODE_EXTENSION + ? { ...extension, factory: createCodemodeExtension({ mode: 'on', models: false, runtime: runtimeOptions }) } + : extension), + }) + cleanups.push(() => created.shutdown('quit')) + cleanups.push(async () => { + cleanup.resolve() + }) + const { session } = created + session.settingsManager.setCompactionEnabled(false) + const events: AgentSessionEvent[] = [] + session.subscribe(event => events.push(event)) + const reusable = createReusableBuddySession({ + session, + shutdown: created.shutdown, + assertModelAccess: async () => model, + runContext: extensions.runContext, + inputReferences: extensions.inputReferences, + materializeInput: async input => input.prompt, + setCodemodeEnabled: extensions.setCodemodeEnabled, + }) + const controller = new AbortController() + const authorized: AuthorizedToolExecution[] = [] + const denied: DeniedToolExecution[] = [] + return { + root, + cwd, + session, + reusable, + events, + waiting, + cleanup, + approvals: services.approvalService, + gates, + maximumActive: () => maximumActive, + controller, + authorized, + denied, + apply: (codemode: boolean) => reusable.applyPreferences({ ...DEFAULT_RUNTIME_PREFERENCES, codemode }), + lastResult: () => session.messages.filter(message => message.role === 'toolResult').at(-1)?.content.filter(block => block.type === 'text').map(block => block.text).join('\n') ?? '', + async turn(call?: typeof nextCall) { + nextCall = call + first = true + const release = await reusable.activateTurn({ + runId: `run-${++run}`, + provider: model.provider, + model: model.id, + contextWindow: null, + maxTokens: null, + signal: controller.signal, + flushProjectedEvents: async () => {}, + onToolExecutionAuthorized: async (call) => { authorized.push(call) }, + onToolExecutionDenied: async (call) => { denied.push(call) }, + }) + try { + await reusable.prompt('Run the requested operation') + } + finally { + release() + } + }, + } +} + +function response(model: Model, call: Pick | undefined, run: number) { + const message: AssistantMessage = { + api: model.api, + provider: model.provider, + model: model.id, + role: 'assistant', + timestamp: Date.now(), + content: call ? [{ type: 'toolCall', id: `call-${run}`, ...call }] : [{ type: 'text', text: 'Done' }], + stopReason: call ? 'toolUse' : 'stop', + usage: { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + } + const stream = createAssistantMessageEventStream() + queueMicrotask(() => stream.push({ type: 'done', reason: call ? 'toolUse' : 'stop', message })) + return stream +} diff --git a/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts index efe70202..18f8d5e9 100644 --- a/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts +++ b/apps/buddy/service/src/agent/sessions/__tests__/modelRetry.spec.ts @@ -16,6 +16,33 @@ afterEach(async () => { }) describe('model retry policy', () => { + it('retries a busy provider within the same run and input', async () => { + let requests = 0 + const fixture = await createFixture(() => ++requests === 1 ? 'server_busy' : undefined) + const release = await fixture.activate(1) + await fixture.reusable.prompt('Retry the busy provider') + expect(requests).toBe(2) + expect(fixture.session.messages.at(-1)).toMatchObject({ stopReason: 'stop' }) + expect(fixture.session.sessionManager.getEntries().filter(entry => entry.type === 'message' && entry.message.role === 'user')).toHaveLength(1) + release() + }) + + it.each([ + [[{ type: 'text' as const, text: 'Already delivered output' }]], + [[{ type: 'toolCall' as const, id: 'side-effect', name: 'write', arguments: { path: 'result', content: 'written' } }]], + ])('preserves committed output without replaying a failed request', async (content) => { + let requests = 0 + const fixture = await createFixture(() => { + requests++ + return '503 service unavailable' + }, content) + const release = await fixture.activate('unlimited') + await fixture.reusable.prompt('Keep partial progress') + expect(requests).toBe(1) + expect(fixture.session.messages.at(-1)).toMatchObject({ stopReason: 'error', content }) + release() + }) + it.each([0, 2, 'unlimited'] as const)('executes the %s budget without duplicating the user input', async (limit) => { let requests = 0 const fixture = await createFixture(() => ++requests <= 5 ? '503 service unavailable' : undefined) @@ -27,7 +54,7 @@ describe('model retry policy', () => { release() }) - it.each(['401 invalid api key', '429 insufficient_quota', '403 forbidden'])('does not retry %s even with an unlimited budget', async (failure) => { + it.each(['401 invalid api key', '429 insufficient_quota', '403 forbidden', '400 server_error'])('does not retry %s even with an unlimited budget', async (failure) => { let requests = 0 const fixture = await createFixture(() => { requests++ @@ -69,9 +96,9 @@ describe('model retry policy', () => { return '503 service unavailable' }) let release = await fixture.activate(2) - fixture.reusable.applyPreferences({ cacheWarming: 'streaming', modelRetryLimit: 0 }) + fixture.reusable.applyPreferences({ cacheWarming: 'streaming', codemode: false, modelRetryLimit: 0 }) expect(fixture.session.settingsManager.getRetrySettings()).toMatchObject({ enabled: true, maxRetries: 2 }) - fixture.reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: 0 }) + fixture.reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: 0 }) fixture.session.settingsManager.applyOverrides({ retry: { baseDelayMs: 1 } }) await fixture.reusable.prompt('First run') expect(requests).toBe(3) @@ -84,13 +111,13 @@ describe('model retry policy', () => { }) }) -async function createFixture(failure: () => string | undefined) { +async function createFixture(failure: () => string | undefined, failureContent: AssistantMessage['content'] = []) { const root = await realpath(await mkdtemp(join(tmpdir(), 'buddy-model-retry-'))) cleanups.push(() => rm(root, { recursive: true, force: true })) const runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) const model = runtime.getModels().find(model => model.provider === 'anthropic')! await runtime.setRuntimeApiKey(model.provider, 'offline-fixture') - vi.spyOn(runtime, 'streamSimple').mockImplementation(target => response(target, failure())) + vi.spyOn(runtime, 'streamSimple').mockImplementation(target => response(target, failure(), failureContent)) const created = await createIsolatedBuddySession({ agentDir: join(root, 'agent'), canonicalRoot: root, @@ -120,7 +147,7 @@ async function createFixture(failure: () => string | undefined) { let run = 0 async function activate(limit?: ModelRetryLimit) { if (limit !== undefined) - reusable.applyPreferences({ cacheWarming: 'off', modelRetryLimit: limit }) + reusable.applyPreferences({ cacheWarming: 'off', codemode: false, modelRetryLimit: limit }) const release = await reusable.activateTurn({ runId: `retry-run-${++run}`, provider: model.provider, @@ -138,14 +165,14 @@ async function createFixture(failure: () => string | undefined) { return { session, reusable, activate } } -function response(model: Model, failure?: string) { +function response(model: Model, failure?: string, failureContent: AssistantMessage['content'] = []) { const message: AssistantMessage = { api: model.api, provider: model.provider, model: model.id, role: 'assistant', timestamp: Date.now(), - content: failure ? [] : [{ type: 'text', text: 'Recovered' }], + content: failure ? failureContent : [{ type: 'text', text: 'Recovered' }], stopReason: failure ? 'error' : 'stop', errorMessage: failure, usage: { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, diff --git a/apps/buddy/service/src/agent/sessions/createBuddySession.ts b/apps/buddy/service/src/agent/sessions/createBuddySession.ts index 47d7092e..1de1ccd6 100644 --- a/apps/buddy/service/src/agent/sessions/createBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createBuddySession.ts @@ -155,12 +155,11 @@ export async function createBuddyContextSnapshot( if (persistedContextUsageUnknown) return null - const tools = result.session.getActiveToolNames().flatMap((name) => { - const tool = result.session.getToolDefinition(name) - return tool - ? [{ description: tool.description, name: tool.name, parameters: tool.parameters }] - : [] - }) + const tools = result.session.agent.state.tools.map(tool => ({ + description: tool.description, + name: tool.name, + parameters: tool.parameters, + })) return createEstimatedContextUsage(buildBuddyRequestContext({ messages: convertToLlm(prepareBuddyInputHistory(result.session.messages)).filter(message => message.role !== 'system'), systemPrompt: result.session.systemPrompt, diff --git a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts index 179ca957..0eb5272f 100644 --- a/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts +++ b/apps/buddy/service/src/agent/sessions/createReusableBuddySession.ts @@ -34,6 +34,7 @@ import { toBuddySessionStorageError } from './BuddySessionErrors' import { getBuddyCacheWarmingStatus } from './getBuddyCacheWarmingStatus' export interface CreateReusableBuddySessionOptions { + setCodemodeEnabled?: (enabled: boolean) => void skillReferences?: readonly SkillReference[] tree?: BuddyConversationTreeCursor assertModelAccess: ( @@ -245,6 +246,7 @@ export function createReusableBuddySession( async activateTurn(input) { input.signal.throwIfAborted() turnRetryLimit = preferences.modelRetryLimit + options.setCodemodeEnabled?.(preferences.codemode) options.runContext.current = { flushProjectedEvents: input.flushProjectedEvents, onToolExecutionAuthorized: input.onToolExecutionAuthorized, diff --git a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts index 19fe817b..fb3c5b4c 100644 --- a/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts +++ b/apps/buddy/service/src/agent/sessions/tree/__tests__/BuddyConversationTree.spec.ts @@ -23,6 +23,14 @@ afterEach(async () => { }) describe('native conversation tree', () => { + it('counts separate tool invocations when a provider reuses a call ID across steps', async () => { + const fixture = await createFixture() + const run = fixture.run('reused-tools', 'b0', 'q1') + for (let sequence = 1; sequence <= 2; sequence++) + fixture.toolEvent(run.id, sequence, 'provider-call') + expect(fixture.repository.listToolCounts('conversation').get(run.id)).toBe(2) + }) + it('retains a durable file commit when binding fails and reconciles without recreating the file', async () => { const fixture = await createFixture() const commits: BuddyTreeCommit[] = [] @@ -366,6 +374,10 @@ async function createFixture(recovery?: BuddySessionRecoveryService['create']) { conversations.createMessage({ id: answerId, branchId: run.branchId, conversationId: run.conversationId, content: { text: answerId }, role: 'assistant', runId: run.id, createdAt: now() }) } }, + toolEvent(runId: string, sequence: number, toolCallId: string) { + database.prepare('INSERT INTO run_events (run_id, sequence, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?)') + .run(runId, sequence, 'tool.started', JSON.stringify({ toolCallId, toolName: 'read' }), now()) + }, source(runId: string, sourceRunId: string, position: string) { database.prepare('INSERT INTO run_tree_sources (run_id, source_run_id, position) VALUES (?, ?, ?)').run(runId, sourceRunId, position) }, diff --git a/apps/buddy/service/src/context/ContextUsageSnapshotService.ts b/apps/buddy/service/src/context/ContextUsageSnapshotService.ts index 633c8988..b99332da 100644 --- a/apps/buddy/service/src/context/ContextUsageSnapshotService.ts +++ b/apps/buddy/service/src/context/ContextUsageSnapshotService.ts @@ -4,6 +4,7 @@ import type { } from '../../../shared/conversation/modelSelection' import type { BuddyApprovalPolicy } from '../../../shared/permissions/approvalPolicy' import type { BuddyExecutionProfile } from '../../../shared/permissions/executionProfile' +import type { RuntimePreferences } from '../../../shared/runtime/runtimePreferences' import type { BuddySessionExtensionServices } from '../agent/extensions/createBuddySessionExtensions' import type { BuddySessionBlueprintService } from '../agent/sessions/BuddySessionBlueprintService' import type { BuddyContextSnapshot } from '../agent/sessions/createBuddySession' @@ -37,6 +38,7 @@ export interface ContextUsageSnapshotInput { } export interface ContextUsageSnapshotServiceOptions { + getRuntimePreferences?: () => Promise tree: BuddyConversationTree drafts: Pick agentDirectory: string @@ -132,6 +134,7 @@ export class ContextUsageSnapshotService implements ContextUsageSnapshotReader { executionProfile, spaceId: input.spaceId, }) + const runtimePreferences = await this.#options.getRuntimePreferences?.() const extensions = await createBuddySessionExtensions({ approvalPolicy: blueprint.approvalPolicy, canonicalRoot: blueprint.canonicalRoot, @@ -144,6 +147,7 @@ export class ContextUsageSnapshotService implements ContextUsageSnapshotReader { spaceId: blueprint.space?.id ?? null, services: this.#options.sessionExtensionServices, }) + extensions.setCodemodeEnabled(runtimePreferences?.codemode ?? false) const identity = { contextWindow: selected.model.contextWindow, createdAt: new Date().toISOString(), diff --git a/apps/buddy/service/src/events/RunEventCompaction.ts b/apps/buddy/service/src/events/RunEventCompaction.ts index 0e000e7b..28e25fa3 100644 --- a/apps/buddy/service/src/events/RunEventCompaction.ts +++ b/apps/buddy/service/src/events/RunEventCompaction.ts @@ -9,6 +9,7 @@ interface RunEventCompactionFacts { readonly completedBlockKeys: ReadonlySet readonly completedMessageIds: ReadonlySet readonly completedToolCallIds: ReadonlySet + readonly reusedToolCallIds: ReadonlySet readonly latestToolReplacementSequences: ReadonlyMap } @@ -27,12 +28,17 @@ export function createRunEventCompactionPlan( const key = readMessageBlockKey(event.payload) return key ? [key] : [] })) - const completedToolCallIds = new Set(events.flatMap((event) => { - if (event.type !== 'tool.completed') - return [] + const completedToolCallIds = new Set() + const reusedToolCallIds = new Set() + for (const event of events) { const toolCallId = readToolCallId(event.payload) - return toolCallId ? [toolCallId] : [] - })) + if (!toolCallId) + continue + if (event.type === 'tool.completed') + completedToolCallIds.add(toolCallId) + else if ((event.type === 'tool.preparing' || event.type === 'tool.started') && completedToolCallIds.has(toolCallId)) + reusedToolCallIds.add(toolCallId) + } const latestToolReplacementSequences = new Map() for (const event of events) { if (event.type !== 'tool.updated' || isToolPresentationDelta(event.payload)) @@ -45,6 +51,7 @@ export function createRunEventCompactionPlan( completedBlockKeys, completedMessageIds, completedToolCallIds, + reusedToolCallIds, latestToolReplacementSequences, })) const removedSequences = new Set(removed.map(event => event.sequence)) @@ -66,11 +73,13 @@ function shouldRemoveRunEvent( return facts.completedBlockKeys.has(readMessageBlockKey(event.payload) ?? '') if (event.type === 'tool.preparing') { const toolCallId = readToolCallId(event.payload) ?? '' - return facts.completedToolCallIds.has(toolCallId) + return facts.completedToolCallIds.has(toolCallId) && !facts.reusedToolCallIds.has(toolCallId) } if (event.type !== 'tool.updated') return false const toolCallId = readToolCallId(event.payload) ?? '' + if (facts.reusedToolCallIds.has(toolCallId)) + return false if (facts.completedToolCallIds.has(toolCallId)) return true const latestReplacementSequence = facts.latestToolReplacementSequences.get(toolCallId) diff --git a/apps/buddy/service/src/permissions/__tests__/classifyPath.spec.ts b/apps/buddy/service/src/permissions/__tests__/classifyPath.spec.ts index 07040ed1..a6a4b3ad 100644 --- a/apps/buddy/service/src/permissions/__tests__/classifyPath.spec.ts +++ b/apps/buddy/service/src/permissions/__tests__/classifyPath.spec.ts @@ -1,7 +1,9 @@ import type { PermissionGrant } from '../permissionContract' +import { execFile } from 'node:child_process' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { promisify } from 'node:util' import { afterEach, describe, expect, it } from 'vitest' import { classifyPath, toGrantRoot } from '../classifyPath' @@ -14,6 +16,25 @@ afterEach(async () => { }) describe('classifyPath', () => { + it.skipIf(process.platform === 'win32')('rejects devices and pipes, including symlinks and create targets', async () => { + const root = await createRoot() + const pipe = join(root, 'pipe') + const link = join(root, 'linked-pipe') + await promisify(execFile)('mkfifo', [pipe]) + await symlink(pipe, link) + for (const path of [pipe, link, '/dev/null']) { + for (const mode of ['existing', 'create'] as const) { + await expect(classifyPath({ + cwd: root, + grants: [grant('workspace', root, 'workspace')], + mode, + path, + sensitive: createSensitivePathMatcher({ home: join(root, 'home') }), + })).rejects.toMatchObject({ code: 'INVALID_PATH' }) + } + } + }) + it('classifies workspace, granted and outside paths after realpath resolution', async () => { const root = await createRoot() const workspace = await createDirectory(root, 'workspace') diff --git a/apps/buddy/service/src/providers/ProviderService.ts b/apps/buddy/service/src/providers/ProviderService.ts index 9112187a..e59b71d8 100644 --- a/apps/buddy/service/src/providers/ProviderService.ts +++ b/apps/buddy/service/src/providers/ProviderService.ts @@ -165,23 +165,26 @@ export class ProviderService { const credentialProviderIds = new Set((await this.#credentialStatus.listOrEmpty()) .map(credential => credential.providerId)) for (const provider of this.#builtinTemplates.values()) { - if (customProviderIds.has(provider.id) - || (!this.#states.findByProviderId(provider.id) && !credentialProviderIds.has(provider.id))) { - continue - } - this.#state.commit('initialize', () => { - this.#ensureProviderState(provider.id, false) - if (!this.#builtins.findById(provider.id)) { - const now = new Date().toISOString() - this.#builtins.upsert({ - id: provider.id, - builtinProviderId: provider.id, - displayName: null, - createdAt: now, - updatedAt: now, - }) + const ids = provider.id === 'azure' ? [provider.id, 'azure-openai-responses'] : [provider.id] + for (const id of ids) { + if (customProviderIds.has(id) + || (!this.#states.findByProviderId(id) && !credentialProviderIds.has(id))) { + continue } - }) + this.#state.commit('initialize', () => { + this.#ensureProviderState(id, false) + if (!this.#builtins.findById(id)) { + const now = new Date().toISOString() + this.#builtins.upsert({ + id, + builtinProviderId: provider.id, + displayName: null, + createdAt: now, + updatedAt: now, + }) + } + }) + } } const instances = this.#builtins.list() for (const instance of instances) { diff --git a/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts b/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts index f4714ad4..460777ab 100644 --- a/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/BuiltinProviderInstances.spec.ts @@ -22,6 +22,63 @@ const databases: DatabaseSync[] = [] afterEach(() => databases.splice(0).forEach(database => database.close())) describe('built-in provider instances', () => { + it('restores credential-only Azure accounts and runs both Responses and Foundry chat models through their original identity', async () => { + const database = openBuddyDatabase({ databasePath: ':memory:' }) + databases.push(database) + const credentials = new InMemoryCredentialStore() + const providerId = 'azure-openai-responses' + await credentials.modify(providerId, async () => ({ + type: 'api_key', + key: 'fixture-azure-key', + env: { AZURE_OPENAI_BASE_URL: 'https://azure.example.test/v1' }, + })) + const runtime = await ModelRuntime.create({ credentials, modelsPath: null, modelsStore: new InMemoryModelsStore(), refreshOnCreate: false }) + const service = new ProviderService({ + authInteractions: new AuthInteractionService(), + credentialStatus: createProviderCredentialStatus(credentials), + modelDiscovery: { supports: () => false, discover: async () => [] }, + modelRuntime: runtime, + providers: createProviderRepository(database), + sessionRuntime: runtime, + }) + try { + await service.initializeProviders() + expect(await service.listProviders()).toContainEqual(expect.objectContaining({ id: providerId, builtinProviderId: 'azure', status: 'available' })) + expect(service.listBuiltinPresets().filter(preset => preset.id.startsWith('azure'))).toMatchObject([{ id: 'azure' }]) + expect(await credentials.read(providerId)).toMatchObject({ key: 'fixture-azure-key' }) + const requests: Array<{ url: string, headers: Headers }> = [] + const request: typeof fetch = async (input, init) => { + requests.push({ url: String(input), headers: new Headers(init?.headers) }) + const item = { type: 'message', id: 'msg_fixture', role: 'assistant', content: [{ type: 'output_text', text: 'Azure answer', annotations: [] }] } + const events = String(input).includes('/chat/completions') + ? [{ id: 'fixture', choices: [{ index: 0, delta: { role: 'assistant', content: 'Azure answer' }, finish_reason: 'stop' }] }] + : [ + { type: 'response.output_item.added', output_index: 0, item: { ...item, content: [] } }, + { type: 'response.output_text.delta', output_index: 0, content_index: 0, item_id: item.id, delta: 'Azure answer' }, + { type: 'response.output_item.done', output_index: 0, item }, + { type: 'response.completed', response: { id: 'resp_fixture', status: 'completed', output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } }, + ] + return new Response(`${events.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')}data: [DONE]\n\n`, { headers: { 'content-type': 'text/event-stream' } }) + } + for (const api of ['azure-openai-responses', 'openai-completions']) { + const model = runtime.getModels(providerId).find(model => model.api === api)! + expect(model).toBeDefined() + const result = await runtime.completeSimple(model, { messages: [{ role: 'user', content: 'hello', timestamp: 1 }] }, { fetch: request }) + expect(result, result.errorMessage).toMatchObject({ provider: providerId, stopReason: 'stop', content: [expect.objectContaining({ type: 'text', text: 'Azure answer' })] }) + expect(result.durationMs).toEqual(expect.any(Number)) + const headers = requests.at(-1)!.headers + expect(headers.get('authorization') ?? headers.get('api-key')).toContain('fixture-azure-key') + } + expect(requests.every(request => request.url.startsWith('https://azure.example.test/'))).toBe(true) + const other = await service.addProvider('azure') + expect(other).toMatchObject({ builtinProviderId: 'azure', status: 'authentication_required' }) + expect(await credentials.read(other.id)).toBeUndefined() + } + finally { + await service.dispose() + } + }) + it('preserves mixed catalogs and authenticates each operation with its own instance and headers', async () => { const database = openBuddyDatabase({ databasePath: ':memory:' }) databases.push(database) @@ -72,6 +129,7 @@ describe('built-in provider instances', () => { const source = () => { const { provider } = createSource() provider.auth.oauth!.login = async (_interaction, options) => { + expect(options?.agentName).toBe('Lexora') const id = options?.getDeviceId?.() expect(id).toMatch(/^[0-9a-f-]{36}$/) ids.push(id!) diff --git a/apps/buddy/service/src/providers/__tests__/ProviderStreamProgress.spec.ts b/apps/buddy/service/src/providers/__tests__/ProviderStreamProgress.spec.ts new file mode 100644 index 00000000..bf6f7ea9 --- /dev/null +++ b/apps/buddy/service/src/providers/__tests__/ProviderStreamProgress.spec.ts @@ -0,0 +1,98 @@ +import type { AssistantMessage, Model, Provider, StreamOptions } from '@earendil-works/pi-ai' +import { createAssistantMessageEventStream, normalizeContext } from '@earendil-works/pi-ai' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MODEL_PROGRESS_TIMEOUT_MS, withProviderStream } from '../withProviderStream' + +const model: Model<'openai-completions'> = { api: 'openai-completions', provider: 'fixture', id: 'fixture', name: 'Fixture', reasoning: true, input: ['text'], baseUrl: 'https://fixture.example.test', contextWindow: 4096, maxTokens: 1024, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } } +const context = normalizeContext({ messages: [{ role: 'user', content: 'hello', timestamp: 1 }] }) +afterEach(() => vi.useRealTimers()) + +function fixture(signal?: AbortSignal) { + vi.useFakeTimers() + const source = createAssistantMessageEventStream() + const partial: AssistantMessage = { role: 'assistant', api: model.api, provider: model.provider, model: model.id, timestamp: 1, stopReason: 'stop', content: [], usage: { input: 1, output: 0, totalTokens: 1, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } + let options!: StreamOptions + const stream: Provider['stream'] = (_model, _context, value) => { + options = value! + source.push({ type: 'start', partial }) + return source + } + const provider = withProviderStream({ id: 'fixture', name: 'Fixture', stream, streamSimple: stream } as Provider) + const result = provider.stream(model, context, { signal }).result() + return { + result, + source, + partial, + options: () => options, + text(text: string) { + partial.content = [{ type: 'text', text }] + source.push({ type: 'text_delta', contentIndex: 0, delta: text, partial }) + }, + done() { + source.push({ type: 'done', reason: 'stop', message: partial }) + }, + } +} + +describe('provider stream progress deadline', () => { + it('does not let empty transport heartbeats extend the deadline and aborts the producer', async () => { + const f = fixture() + await vi.advanceTimersByTimeAsync(0) + for (let index = 0; index < 2; index++) { + await vi.advanceTimersByTimeAsync(100_000) + await f.options().onProviderStreamEvent?.({ id: 'heartbeat', choices: [{ delta: { role: 'assistant' } }] }, model) + } + await vi.advanceTimersByTimeAsync(100_000) + expect(await f.result).toMatchObject({ stopReason: 'error', errorMessage: 'Model stream timeout: no model progress for 300 seconds' }) + expect(f.options().signal?.aborted).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('renews the deadline for text and preserves committed text if the stream later stalls', async () => { + const f = fixture() + await vi.advanceTimersByTimeAsync(MODEL_PROGRESS_TIMEOUT_MS - 1) + f.text('committed answer') + await vi.advanceTimersByTimeAsync(MODEL_PROGRESS_TIMEOUT_MS - 1) + expect(f.options().signal?.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(await f.result).toMatchObject({ stopReason: 'error', content: [{ type: 'text', text: 'committed answer' }] }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('settles caller cancellation even if a provider ignores its abort signal', async () => { + const controller = new AbortController() + const f = fixture(controller.signal) + await vi.advanceTimersByTimeAsync(0) + f.text('partial') + await vi.advanceTimersByTimeAsync(0) + controller.abort() + expect(await f.result).toMatchObject({ stopReason: 'aborted', content: [{ type: 'text', text: 'partial' }] }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves committed content when an iterator throws instead of emitting a terminal error', async () => { + vi.useFakeTimers() + const partial: AssistantMessage = { role: 'assistant', api: model.api, provider: model.provider, model: model.id, timestamp: 1, stopReason: 'stop', content: [{ type: 'text', text: 'committed' }], usage: { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 } } } + const stream = () => ({ + async* [Symbol.asyncIterator]() { + yield { type: 'text_delta' as const, delta: 'committed', contentIndex: 0, partial } + throw new Error('socket hang up') + }, + }) + const provider = withProviderStream({ id: 'fixture', name: 'Fixture', stream, streamSimple: stream } as unknown as Provider) + expect(await provider.stream(model, context).result()).toMatchObject({ stopReason: 'error', errorMessage: 'socket hang up', content: [{ type: 'text', text: 'committed' }] }) + expect(vi.getTimerCount()).toBe(0) + }) + + it('counts hidden reasoning as progress before display filtering', async () => { + const event = { choices: [{ delta: { reasoning_content: 'hidden reasoning' } }] } + const f = fixture() + await vi.advanceTimersByTimeAsync(200_000) + await f.options().onProviderStreamEvent?.(event, model) + await vi.advanceTimersByTimeAsync(200_000) + expect(f.options().signal?.aborted).toBe(false) + f.done() + expect(await f.result).toMatchObject({ stopReason: 'stop' }) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/buddy/service/src/providers/__tests__/ToolCallArguments.spec.ts b/apps/buddy/service/src/providers/__tests__/ToolCallArguments.spec.ts new file mode 100644 index 00000000..a25bf2f5 --- /dev/null +++ b/apps/buddy/service/src/providers/__tests__/ToolCallArguments.spec.ts @@ -0,0 +1,211 @@ +import type { Api, AssistantMessageFrame, ToolCall } from '@earendil-works/pi-ai' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AssistantMessageFrameEncoder, InMemoryCredentialStore, normalizeContext, reduceAssistantMessageFrames, validateToolArguments } from '@earendil-works/pi-ai' +import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' +import { beforeAll, describe, expect, it } from 'vitest' +import { withProviderStream } from '../withProviderStream' + +let runtime: ModelRuntime +const context = normalizeContext({ messages: [{ role: 'user', content: 'Use the tool', timestamp: 1 }] }) +const tool = { name: 'write', description: 'Write a file', parameters: Type.Object({ path: Type.Optional(Type.String()), content: Type.Optional(Type.String()) }) } +const apis = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const + +beforeAll(async () => { + runtime = await ModelRuntime.create({ credentials: new InMemoryCredentialStore(), modelsPath: null, refreshOnCreate: false }) + for (const api of apis) { + runtime.registerProvider(api, { + api, + baseUrl: 'https://fixture.example.test/v1', + models: [{ id: 'fixture', name: 'Fixture', reasoning: false, input: ['text'], contextWindow: 4096, maxTokens: 1024, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + }) + await runtime.setRuntimeApiKey(api, 'offline-fixture') + } +}) + +describe.each(apis)('%s final tool arguments', (api) => { + it('rejects truncated arguments through permissive schemas and replay', async () => { + const json = '{"path":"unsafe.txt","content":"partial"' + const { call, frames } = await stream(api, json) + expect(call.arguments).toEqual({}) + expect(call.argumentsError).toContain('not a complete JSON object') + expect(() => validateToolArguments(tool, call)).toThrow('not a complete JSON object') + const replayed = reduceAssistantMessageFrames(frames)!.content.find(block => block.type === 'toolCall')! + expect(replayed).toMatchObject({ arguments: {}, argumentsError: call.argumentsError }) + expect(() => validateToolArguments(tool, replayed)).toThrow('not a complete JSON object') + }) + + it('preserves complete JSON arguments', async () => { + const { call } = await stream(api, '{"path":"safe.txt","content":"complete"}') + expect(call.argumentsError).toBeUndefined() + expect(validateToolArguments(tool, call)).toEqual({ path: 'safe.txt', content: 'complete' }) + }) +}) + +it('projects invalid names and orphaned results safely without rewriting stored history', async () => { + const api = 'openai-completions' + const model = runtime.getModels(api)[0]! + const longName = 'x'.repeat(129) + const invalid = ['bad name', longName, ''] + const usage = { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 } } + const history = normalizeContext({ messages: [ + { role: 'assistant', api, provider: api, model: model.id, timestamp: 1, stopReason: 'toolUse', usage, content: [ + ...invalid.map((name, index) => ({ type: 'toolCall' as const, id: `invalid-${index}`, name, arguments: {} })), + { type: 'toolCall', id: 'valid', name: 'write', arguments: { content: 'valid arguments' } }, + { type: 'toolCall', id: 'orphan', name: 'write', arguments: { path: 'possibly-written.txt' } }, + ] }, + ...invalid.map((_name, index) => ({ role: 'toolResult' as const, toolCallId: `invalid-${index}`, toolName: 'invalid', content: [{ type: 'text' as const, text: 'invalid-result-marker' }], isError: true, timestamp: 2 })), + { role: 'toolResult', toolCallId: 'valid', toolName: 'write', content: [{ type: 'text', text: 'valid-result-marker' }], isError: false, timestamp: 3 }, + { role: 'user', content: 'Continue', timestamp: 4 }, + ], tools: [tool] }) + const saved = structuredClone(history) + let payload: unknown + await runtime.getProvider(api)!.stream(model, history, { + apiKey: 'offline-fixture', + maxRetries: 0, + onPayload: (value) => { payload = value }, + fetch: async () => new Response(body(api, '{}'), { headers: { 'content-type': 'text/event-stream' } }), + }).result() + const serialized = JSON.stringify(payload) + expect(serialized).not.toContain(longName) + expect(serialized).not.toContain('bad name') + expect(serialized).not.toContain('invalid-result-marker') + expect(serialized).toContain('valid-result-marker') + expect(serialized).toContain('valid arguments') + expect(serialized).toContain('its outcome is unknown') + expect(serialized).toContain('verify the current state first') + expect(history).toEqual(saved) +}) + +it('rejects duplicate tool IDs before any tool side effect', async () => { + const api = 'openai-completions' + const ids = ['same', 'same'] + const model = runtime.getModels(api)[0]! + const provider = withProviderStream(runtime.getProvider(api)!) + const writes: unknown[] = [] + const root = await mkdtemp(join(tmpdir(), 'buddy-tool-identity-')) + const settingsManager = SettingsManager.inMemory({ retry: { enabled: false }, compaction: { enabled: false } }) + const resourceLoader = new DefaultResourceLoader({ + cwd: root, + agentDir: root, + settingsManager, + noExtensions: true, + disabledBuiltinExtensions: ['mcp', 'codemode'], + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + }) + await resourceLoader.reload() + const { session } = await createAgentSession({ + cwd: root, + agentDir: root, + model, + modelRuntime: runtime, + resourceLoader, + sessionManager: SessionManager.inMemory(root), + settingsManager, + tools: ['write'], + customTools: [{ ...tool, label: 'Write', execute: async (_id, args) => { + writes.push(args) + return { content: [{ type: 'text', text: 'written' }], details: null } + } }], + }) + let requests = 0 + session.agent.streamFunction = (model, context, options) => provider.stream(model, context, { + ...options, + apiKey: 'offline-fixture', + maxRetries: 0, + fetch: async () => { + if (++requests > 1) + throw new Error('Unexpected repeated model request') + return new Response(body(api, '{"content":"must not be written"}', ids), { headers: { 'content-type': 'text/event-stream' } }) + }, + }) + try { + await session.prompt('Write a file') + const reply = session.messages.find(message => message.role === 'assistant')! + expect(reply).toMatchObject({ stopReason: 'error', errorMessage: expect.stringContaining('duplicate or empty tool call IDs') }) + expect(reply.content).toHaveLength(ids.length) + expect(writes).toEqual([]) + expect(session.messages.some(message => message.role === 'toolResult')).toBe(false) + } + finally { + session.dispose() + await rm(root, { recursive: true, force: true }) + } +}) + +it('keeps an unfinished streaming preview non-executable when its progress frames are reconstructed', () => { + const partial = { + role: 'assistant' as const, + api: 'openai-completions' as const, + provider: 'fixture', + model: 'fixture', + content: [], + stopReason: 'toolUse' as const, + timestamp: 1, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + } + const frames: AssistantMessageFrame[] = [ + { type: 'start', partial }, + { type: 'toolcall_start', contentIndex: 0, toolCall: { type: 'toolCall', id: 'call', name: 'write', arguments: {} } }, + { type: 'toolcall_delta', contentIndex: 0, delta: '{"content":"unfinished' }, + ] + const call = reduceAssistantMessageFrames(frames)!.content[0] as ToolCall + expect(() => validateToolArguments(tool, call)).toThrow('not a complete JSON object') +}) + +async function stream(api: Api, json: string) { + const model = runtime.getModels(api)[0]! + const provider = runtime.getProvider(api)! + const source = provider.stream(model, normalizeContext({ messages: context.messages, tools: [tool] }), { + apiKey: 'offline-fixture', + maxRetries: 0, + fetch: async () => new Response(body(api, json), { headers: { 'content-type': 'text/event-stream' } }), + }) + const encoder = new AssistantMessageFrameEncoder() + const frames: AssistantMessageFrame[] = [] + for await (const event of source) { + const frame = encoder.encode(event) + if (frame) + frames.push(frame) + } + const message = await source.result() + expect(message.stopReason, message.errorMessage).toBe('toolUse') + return { call: message.content.find(block => block.type === 'toolCall')!, frames } +} + +function body(api: Api, json: string, ids = ['call']) { + const frame = (data: unknown, event?: string) => `${event ? `event: ${event}\n` : ''}data: ${JSON.stringify(data)}\n\n` + if (api === 'openai-completions') { + return `${frame({ id: 'response', choices: [{ index: 0, delta: { tool_calls: ids.map((id, index) => ({ index, id, type: 'function', function: { name: 'write', arguments: json } })) }, finish_reason: null }] }) + + frame({ id: 'response', choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }) + }data: [DONE]\n\n` + } + if (api === 'anthropic-messages') { + const events = [ + { type: 'message_start', message: { id: 'response', type: 'message', role: 'assistant', model: 'fixture', content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } }, + ...ids.flatMap((id, index) => [ + { type: 'content_block_start', index, content_block: { type: 'tool_use', id, name: 'write', input: {} } }, + { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } }, + { type: 'content_block_stop', index }, + ]), + { type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: 'message_stop' }, + ] + return events.map(event => frame(event, event.type)).join('') + } + const items = ids.map((id, index) => ({ type: 'function_call', id: `fc_${index}`, call_id: id, name: 'write', arguments: json, status: 'completed' })) + return [ + { type: 'response.created', response: { id: 'response', status: 'in_progress', output: [] } }, + ...items.flatMap((item, index) => [ + { type: 'response.output_item.added', output_index: index, item: { ...item, arguments: '', status: 'in_progress' } }, + { type: 'response.function_call_arguments.delta', item_id: item.id, output_index: index, delta: json }, + { type: 'response.output_item.done', output_index: index, item }, + ]), + { type: 'response.completed', response: { id: 'response', status: 'completed', output: items, usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } }, + ].map(event => frame(event, event.type)).join('') +} diff --git a/apps/buddy/service/src/providers/__tests__/modelInputBudget.spec.ts b/apps/buddy/service/src/providers/__tests__/modelInputBudget.spec.ts index f671e66a..6fb2c735 100644 --- a/apps/buddy/service/src/providers/__tests__/modelInputBudget.spec.ts +++ b/apps/buddy/service/src/providers/__tests__/modelInputBudget.spec.ts @@ -15,6 +15,8 @@ describe('complete provider input budgets', () => { expect(() => assertModelRequestBytes(model, { messages: [{ role: 'user', content: 'Hello' }] })).not.toThrow() expect(getModelRequestBytesLimit({ api: 'openai-completions' })).toBe(32 * 1024 * 1024) expect(getModelRequestBytesLimit({ api: 'google-generative-ai' })).toBe(20_000_000) + expect(getModelRequestBytesLimit({ api: 'anthropic-messages' })).toBe(32_000_000) + expect(getModelRequestBytesLimit({ api: 'bedrock-converse-stream' })).toBe(20_000_000) }) it.each([ diff --git a/apps/buddy/service/src/providers/__tests__/providerEnvironment.spec.ts b/apps/buddy/service/src/providers/__tests__/providerEnvironment.spec.ts new file mode 100644 index 00000000..6c9fc0f5 --- /dev/null +++ b/apps/buddy/service/src/providers/__tests__/providerEnvironment.spec.ts @@ -0,0 +1,27 @@ +import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic' +import { describe, expect, it } from 'vitest' +import { clearAmbientProviderCredentials } from '../providerEnvironment' + +describe('provider credential isolation', () => { + it('prevents ambient workload identity from configuring Anthropic', async () => { + const identity = { + ANTHROPIC_FEDERATION_RULE_ID: 'fixture-rule', + ANTHROPIC_ORGANIZATION_ID: 'fixture-org', + ANTHROPIC_IDENTITY_TOKEN_FILE: '/fixture/identity-token', + ANTHROPIC_SERVICE_ACCOUNT_ID: 'fixture-account', + ANTHROPIC_WORKSPACE_ID: 'fixture-workspace', + } + const provider = anthropicProvider() + const environment: NodeJS.ProcessEnv = { ...identity, PATH: '/fixture/bin' } + const options = { + ctx: { env: async (name: string) => environment[name], fileExists: async () => false }, + signal: new AbortController().signal, + } + expect(await provider.auth.apiKey!.resolve(options)).toMatchObject({ source: 'workload identity federation' }) + clearAmbientProviderCredentials(environment) + expect(environment).toEqual({ PATH: '/fixture/bin' }) + expect(await provider.auth.apiKey!.resolve(options)).toBeUndefined() + expect(await provider.auth.apiKey!.resolve({ ...options, credential: { type: 'api_key', key: 'fixture-managed-key' } })) + .toMatchObject({ auth: { apiKey: 'fixture-managed-key' }, source: 'stored credential' }) + }) +}) diff --git a/apps/buddy/service/src/providers/createProviderLoginOptions.ts b/apps/buddy/service/src/providers/createProviderLoginOptions.ts index 24d499d0..d9d50b6e 100644 --- a/apps/buddy/service/src/providers/createProviderLoginOptions.ts +++ b/apps/buddy/service/src/providers/createProviderLoginOptions.ts @@ -7,6 +7,7 @@ const DEVICE_ID_KEY = 'buddy.providers.device-id' export function createProviderLoginOptions(settings: WorkspaceRepository): LoginOptions { return { + agentName: 'Lexora', getDeviceId() { const stored = settings.get(DEVICE_ID_KEY) if (stored !== null) diff --git a/apps/buddy/service/src/providers/modelInputBudget.ts b/apps/buddy/service/src/providers/modelInputBudget.ts index c5515ea0..1a26c0ac 100644 --- a/apps/buddy/service/src/providers/modelInputBudget.ts +++ b/apps/buddy/service/src/providers/modelInputBudget.ts @@ -7,7 +7,9 @@ const MAX_REQUEST_BYTES = 32 * 1024 * 1024 const DEFAULT_IMAGE_RESIZE = { maxWidth: 2000, maxHeight: 2000, maxBytes: 4.5 * 1024 * 1024, jpegQuality: 80 } export function getModelRequestBytesLimit(model: BudgetModel): number { - const limit = model.api === 'google-generative-ai' ? 20_000_000 : MAX_REQUEST_BYTES + const limit = model.api === 'google-generative-ai' || model.api === 'bedrock-converse-stream' + ? 20_000_000 + : model.api === 'anthropic-messages' ? 32_000_000 : MAX_REQUEST_BYTES return boundedLimit(model.inputLimits?.maxRequestBytes, limit) } diff --git a/apps/buddy/service/src/providers/providerEnvironment.ts b/apps/buddy/service/src/providers/providerEnvironment.ts index 568c41d4..36a06650 100644 --- a/apps/buddy/service/src/providers/providerEnvironment.ts +++ b/apps/buddy/service/src/providers/providerEnvironment.ts @@ -2,7 +2,12 @@ const PROVIDER_CREDENTIAL_ENVIRONMENT_VARIABLES = [ 'AI_GATEWAY_API_KEY', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_FEDERATION_RULE_ID', + 'ANTHROPIC_IDENTITY_TOKEN_FILE', 'ANTHROPIC_OAUTH_TOKEN', + 'ANTHROPIC_ORGANIZATION_ID', + 'ANTHROPIC_SERVICE_ACCOUNT_ID', + 'ANTHROPIC_WORKSPACE_ID', 'ANT_LING_API_KEY', 'AWS_ACCESS_KEY_ID', 'AWS_BEARER_TOKEN_BEDROCK', diff --git a/apps/buddy/service/src/providers/withProviderStream.ts b/apps/buddy/service/src/providers/withProviderStream.ts index a586ce17..b38911f7 100644 --- a/apps/buddy/service/src/providers/withProviderStream.ts +++ b/apps/buddy/service/src/providers/withProviderStream.ts @@ -1,4 +1,4 @@ -import type { Api, AssistantMessageEvent, AssistantMessageEventStream, Model, Provider, StreamOptions } from '@earendil-works/pi-ai' +import type { Api, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Model, Provider, StreamOptions } from '@earendil-works/pi-ai' import type { ApplicationDiagnosticReporter } from '../../../shared/diagnostics/applicationDiagnostic' import type { ProviderRequestDiagnostic } from '../../../shared/diagnostics/providerRequestDiagnostic' import { lazyStream } from '@earendil-works/pi-ai' @@ -6,6 +6,7 @@ import { safeDiagnosticReporter } from '../../../shared/diagnostics/applicationD import { diagnosticResponseType, diagnosticTransportCode } from '../../../shared/diagnostics/providerRequestDiagnostic' import { diagnosticContext } from '../diagnostics/diagnosticContext' +export const MODEL_PROGRESS_TIMEOUT_MS = 5 * 60 * 1000 export const INFERRED_STREAM_COMPLETION = 'model.stream.completion_inferred' export function withProviderStream(provider: Provider, record?: ApplicationDiagnosticReporter): Provider { @@ -86,14 +87,55 @@ function complete(model: Model, options: T | undef async function* finish(): AsyncIterable { let textCompleted = false + let latest: AssistantMessage | undefined + const deadline = new AbortController() + const signal = options?.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal + let timer: ReturnType | undefined + let ended = false + let rejectWait: ((reason: unknown) => void) | undefined + const interrupt = () => rejectWait?.(signal.reason) + signal.addEventListener('abort', interrupt, { once: true }) + const progress = () => { + if (ended) + return + clearTimeout(timer) + timer = setTimeout(() => { + const error = new Error('Model stream timeout: no model progress for 300 seconds') + deadline.abort(error) + }, MODEL_PROGRESS_TIMEOUT_MS) + timer.unref?.() + } + let iterator: AsyncIterator | undefined try { - const source = start({ ...options, fetch, onResponse: async (response, responseModel) => { + const source = start({ ...options, fetch, signal, onProviderStreamEvent: async (event, responseModel) => { + if (hasProviderProgress(event)) + progress() + await options?.onProviderStreamEvent?.(event, responseModel) + }, onResponse: async (response, responseModel) => { observedResponse(response.status, new Headers(response.headers)) await options?.onResponse?.(response, responseModel) } } as T) - for await (let event of source) { - if (event.type === 'text_delta' || event.type === 'thinking_delta' || event.type === 'toolcall_delta') + iterator = source[Symbol.asyncIterator]() + progress() + while (true) { + const next = await new Promise>((resolve, reject) => { + rejectWait = reject + if (signal.aborted) + reject(signal.reason) + else + void iterator!.next().then(resolve, reject) + }) + if (next.done) + break + let event = next.value + latest = event.type === 'done' ? event.message : event.type === 'error' ? event.error : event.partial + if (deadline.signal.aborted) + throw deadline.signal.reason + if (event.type === 'text_delta' || event.type === 'thinking_delta' || event.type === 'toolcall_delta') { evidence.contentEvents!++ + if (event.delta.length) + progress() + } if (event.type === 'text_end' && event.content.trim()) textCompleted = true if ( @@ -116,7 +158,16 @@ function complete(model: Model, options: T | undef }, } } + if (event.type === 'done' && hasInvalidToolCallIds(event.message)) { + event = { + type: 'error', + reason: 'error', + error: { ...event.message, stopReason: 'error', errorMessage: 'Model returned duplicate or empty tool call IDs in one response. No tool calls from this response were executed.' }, + } + } if (event.type === 'done' || event.type === 'error') { + ended = true + clearTimeout(timer) const message = event.type === 'done' ? event.message : event.error evidence.textCharacters = message.content.reduce((count, block) => count + (block.type === 'text' ? block.text.length : 0), 0) evidence.toolCalls = message.content.filter(block => block.type === 'toolCall').length @@ -130,11 +181,69 @@ function complete(model: Model, options: T | undef } catch (error) { evidence.transportCode ??= diagnosticTransportCode(error) + if (!latest && !signal.aborted) { + recordEnd(true) + throw error + } + ended = true + clearTimeout(timer) + evidence.completion = evidence.contentEvents! > 0 ? 'incomplete' : 'unknown' recordEnd(true, options?.signal?.aborted) - throw error + const message: AssistantMessage = { + ...latest ?? { + role: 'assistant', + api: model.api, + provider: model.provider, + model: model.id, + timestamp: Date.now(), + content: [], + usage: { input: 0, output: 0, totalTokens: 0, cacheRead: 0, cacheWrite: 0, cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 } }, + }, + stopReason: options?.signal?.aborted ? 'aborted' : 'error', + errorMessage: options?.signal?.aborted ? 'Request aborted' : error instanceof Error ? error.message : String(error), + } + yield { type: 'error', reason: options?.signal?.aborted ? 'aborted' : 'error', error: message } } finally { + ended = true + clearTimeout(timer) + rejectWait = undefined + signal.removeEventListener('abort', interrupt) + void iterator?.return?.().catch(() => {}) recordEnd(true, options?.signal?.aborted) } } } + +function hasProviderProgress(value: unknown): boolean { + if (!value || typeof value !== 'object') + return false + const event = value as Record + if (typeof event.delta === 'string') + return event.delta.length > 0 + const delta = event.delta && typeof event.delta === 'object' ? event.delta as Record : undefined + if (delta && ['text', 'thinking', 'signature', 'partial_json'].some(key => typeof delta[key] === 'string' && delta[key].length > 0)) + return true + if (!Array.isArray(event.choices)) + return false + return event.choices.some((choice) => { + const delta = choice?.delta + if (!delta || typeof delta !== 'object') + return false + return ['content', 'reasoning_content', 'reasoning', 'thinking'].some(key => typeof delta[key] === 'string' && delta[key].length > 0) + || (Array.isArray(delta.tool_calls) && delta.tool_calls.some((call: { function?: { name?: string, arguments?: string } }) => Boolean(call.function?.name || call.function?.arguments))) + }) +} + +function hasInvalidToolCallIds(message: AssistantMessage): boolean { + const seen = new Set() + for (const block of message.content) { + if (block.type !== 'toolCall') + continue + const id = message.api.endsWith('responses') ? block.id.split('|')[0]! : block.id + if (!id.trim() || seen.has(id)) + return true + seen.add(id) + } + return false +} diff --git a/apps/buddy/service/src/storage/__tests__/schema.spec.ts b/apps/buddy/service/src/storage/__tests__/schema.spec.ts index 52044d57..ff3d32bf 100644 --- a/apps/buddy/service/src/storage/__tests__/schema.spec.ts +++ b/apps/buddy/service/src/storage/__tests__/schema.spec.ts @@ -80,6 +80,49 @@ function seedRun( } describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { + it('upgrades Azure source identifiers without changing account identities, selections or history', () => { + const directory = mkdtempSync(join(tmpdir(), 'buddy-azure-migration-')) + directories.push(directory) + const databasePath = join(directory, 'buddy.sqlite3') + const previous = openMigrationFixtureDatabase(databasePath) + for (const migration of BUDDY_SCHEMA_MIGRATIONS.filter(migration => migration.version <= 22)) + previous.exec(migration.sql) + seedRun(previous) + previous.exec(` + PRAGMA user_version = 22; + UPDATE runs SET provider = 'azure-openai-responses'; + INSERT INTO provider_states (provider_id, enabled, request_headers_json, created_at, updated_at) + VALUES ('azure-openai-responses', 1, '[{"name":"api-key","value":"\${apiKey}"}]', 'created', 'updated'); + INSERT INTO builtin_provider_configs (id, builtin_provider_id, display_name, created_at, updated_at) + VALUES ('azure-openai-responses', 'azure-openai-responses', 'Personal Azure', 'created', 'updated'), + ('builtin-work', 'azure-openai-responses', 'Work Azure', 'created', 'updated'), + ('anthropic', 'anthropic', NULL, 'created', 'updated'); + INSERT INTO default_model_setting (singleton, provider_id, model_id, reasoning, updated_at) + VALUES (1, 'azure-openai-responses', 'model', 'high', 'updated'); + INSERT INTO provider_model_states (provider_id, model_id, display_name, api, input_json, reasoning, cost_json, + context_window, max_tokens, override_context_window, override_max_tokens, source, enabled, available, created_at, updated_at) + VALUES ('azure-openai-responses', 'model', 'Model', 'azure-openai-responses', '["text"]', 1, '{}', + 32000, 4000, 16000, 2000, 'builtin', 1, 1, 'created', 'updated'); + INSERT INTO provider_configs (id, display_name, api, base_url, models_json, enabled, created_at, updated_at) + VALUES ('custom-azure', 'Custom Azure', 'azure-openai-responses', 'https://example.test/v1', '[]', 1, 'created', 'updated'); + `) + const configs = previous.prepare('SELECT * FROM builtin_provider_configs ORDER BY id').all() + const tables = ['runs', 'provider_states', 'provider_model_states', 'provider_configs', 'default_model_setting'] + const before = tables.map(table => previous.prepare(`SELECT * FROM ${table}`).all()) + previous.close() + const upgraded = openBuddyDatabase({ databasePath }) + expect(upgraded.prepare('SELECT * FROM builtin_provider_configs ORDER BY id').all()).toEqual(configs.map(config => ({ + ...config, + builtin_provider_id: config.builtin_provider_id === 'azure-openai-responses' ? 'azure' : config.builtin_provider_id, + }))) + expect(tables.map(table => upgraded.prepare(`SELECT * FROM ${table}`).all())).toEqual(before) + upgraded.close() + const reopened = openBuddyDatabase({ databasePath }) + databases.push(reopened) + expect(tables.map(table => reopened.prepare(`SELECT * FROM ${table}`).all())).toEqual(before) + expect(reopened.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + }) + it('preserves v21 usage and attributes independent actions without modifying completed runs', () => { const directory = mkdtempSync(join(tmpdir(), 'buddy-action-migration-')) directories.push(directory) @@ -183,6 +226,7 @@ describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { seedRun(legacy) const now = '2026-09-12T00:00:00.000Z' legacy.prepare('INSERT INTO provider_states VALUES (?, ?, ?, ?)').run('anthropic', 1, now, now) + legacy.prepare('INSERT INTO provider_states VALUES (?, ?, ?, ?)').run('azure-openai-responses', 1, now, now) legacy.prepare('INSERT INTO provider_states VALUES (?, ?, ?, ?)').run('proxy', 1, now, now) legacy.prepare(`INSERT INTO provider_configs (id, display_name, api, base_url, models_json, credential_ref, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run('proxy', 'Proxy', 'openai-completions', 'https://models.example.test/v1', '[]', 'proxy', 1, now, now) @@ -197,6 +241,7 @@ describe('buddy schema', { timeout: MIGRATION_TEST_TIMEOUT }, () => { expect(upgraded.prepare('SELECT * FROM runs').all()).toEqual(runs) expect(upgraded.prepare('SELECT id, builtin_provider_id, display_name FROM builtin_provider_configs').all()).toEqual([ { id: 'anthropic', builtin_provider_id: 'anthropic', display_name: null }, + { id: 'azure-openai-responses', builtin_provider_id: 'azure', display_name: null }, ]) expect(upgraded.prepare('PRAGMA user_version').get()).toEqual({ user_version: BUDDY_SCHEMA_VERSION }) expect((upgraded.prepare('PRAGMA table_info(provider_model_states)').all() as Array<{ name: string }>).map(column => column.name)) diff --git a/apps/buddy/service/src/storage/conversationTreeRepository.ts b/apps/buddy/service/src/storage/conversationTreeRepository.ts index 07fa5f10..7037abb6 100644 --- a/apps/buddy/service/src/storage/conversationTreeRepository.ts +++ b/apps/buddy/service/src/storage/conversationTreeRepository.ts @@ -28,7 +28,7 @@ export function createConversationTreeReader(database: DatabaseSync) { created_at AS createdAt FROM messages WHERE conversation_id = ? AND role IN ('user', 'assistant') ORDER BY created_at, id`) const tools = database.prepare(`SELECT run_events.run_id AS runId, - COUNT(DISTINCT json_extract(payload_json, '$.toolCallId')) AS count + COUNT(*) AS count FROM run_events INNER JOIN runs ON runs.id = run_events.run_id WHERE runs.conversation_id = ? AND event_type = 'tool.started' GROUP BY run_events.run_id`) const runMessages = database.prepare(`SELECT id, conversation_id AS conversationId, diff --git a/apps/buddy/service/src/storage/database.ts b/apps/buddy/service/src/storage/database.ts index d2210afd..5e3b7f2d 100644 --- a/apps/buddy/service/src/storage/database.ts +++ b/apps/buddy/service/src/storage/database.ts @@ -102,13 +102,13 @@ function migrateBuddyDatabase(database: DatabaseSync): void { if (currentVersion === 0 && hasApplicationTables(database)) throw new BuddyDatabaseVersionError('unversioned schema') + if (currentVersion === 15) + completeModelServicesMigration(database) for (const migration of BUDDY_SCHEMA_MIGRATIONS) { if (migration.version <= currentVersion) continue applyMigration(database, migration) } - if (currentVersion === 15) - completeModelServicesMigration(database) assertCurrentSchema(database) } @@ -124,10 +124,6 @@ function completeModelServicesMigration(database: DatabaseSync): void { ['capability_overrides_json', BUDDY_V15_CAPABILITY_OVERRIDES_SCHEMA_SQL], ] as const const missing = additions.filter(([column]) => !columns.has(column)) - assertCurrentSchema(database, [ - ...(hasInstances ? [] : ['builtin_provider_configs']), - ...(hasHeaders ? [] : ['provider_states']), - ], missing.map(([column]) => column)) if (hasInstances && hasHeaders && !missing.length) return withTransaction(database, () => { @@ -140,14 +136,12 @@ function completeModelServicesMigration(database: DatabaseSync): void { }) } -function assertCurrentSchema(database: DatabaseSync, excludedTables: readonly string[] = [], excludedModelColumns: readonly string[] = []): void { +function assertCurrentSchema(database: DatabaseSync): void { for (const [table, requiredColumns] of Object.entries(BUDDY_CURRENT_SCHEMA_COLUMNS)) { - if (excludedTables.includes(table)) - continue const columns = new Set((database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(column => column.name)) - if (requiredColumns.some(column => !columns.has(column) && !(table === 'provider_model_states' && excludedModelColumns.includes(column)))) + if (requiredColumns.some(column => !columns.has(column))) throw new BuddyDatabaseVersionError('incomplete schema version') } } diff --git a/apps/buddy/service/src/storage/migrations/v23Providers.ts b/apps/buddy/service/src/storage/migrations/v23Providers.ts new file mode 100644 index 00000000..8b9e4877 --- /dev/null +++ b/apps/buddy/service/src/storage/migrations/v23Providers.ts @@ -0,0 +1,5 @@ +export const BUDDY_V23_PROVIDERS_SCHEMA_SQL = ` +UPDATE builtin_provider_configs +SET builtin_provider_id = 'azure' +WHERE builtin_provider_id = 'azure-openai-responses'; +` diff --git a/apps/buddy/service/src/storage/schema.ts b/apps/buddy/service/src/storage/schema.ts index d348b05c..81ef6f6f 100644 --- a/apps/buddy/service/src/storage/schema.ts +++ b/apps/buddy/service/src/storage/schema.ts @@ -23,6 +23,7 @@ import { BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL } from './migrations/v20TaskDrafts' import { BUDDY_V21_TITLE_SCHEMA_SQL } from './migrations/v21Title' import { BUDDY_V22_EXTENSION_SCHEMA_SQL } from './migrations/v22Extension' +import { BUDDY_V23_PROVIDERS_SCHEMA_SQL } from './migrations/v23Providers' export interface BuddySchemaMigration { foreignKeys?: 'off' @@ -30,7 +31,7 @@ export interface BuddySchemaMigration { version: number } -export const BUDDY_SCHEMA_VERSION = 22 as const +export const BUDDY_SCHEMA_VERSION = 23 as const export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { sql: BUDDY_V1_INITIAL_SCHEMA_SQL, version: 1 }, @@ -55,4 +56,5 @@ export const BUDDY_SCHEMA_MIGRATIONS: readonly BuddySchemaMigration[] = [ { foreignKeys: 'off', sql: BUDDY_V20_TASK_DRAFTS_SCHEMA_SQL, version: 20 }, { sql: BUDDY_V21_TITLE_SCHEMA_SQL, version: 21 }, { sql: BUDDY_V22_EXTENSION_SCHEMA_SQL, version: 22 }, + { sql: BUDDY_V23_PROVIDERS_SCHEMA_SQL, version: 23 }, ] diff --git a/apps/buddy/shared/runtime/runtimePreferences.ts b/apps/buddy/shared/runtime/runtimePreferences.ts index ef4d0f8d..3633b6b2 100644 --- a/apps/buddy/shared/runtime/runtimePreferences.ts +++ b/apps/buddy/shared/runtime/runtimePreferences.ts @@ -7,6 +7,7 @@ export type ModelRetryLimit = z.infer export const runtimePreferencesSchema = z.object({ cacheWarming: z.enum(['off', 'streaming']), + codemode: z.boolean(), modelRetryLimit: modelRetryLimitSchema, }).strict() @@ -14,6 +15,7 @@ export type RuntimePreferences = z.infer export const DEFAULT_RUNTIME_PREFERENCES: RuntimePreferences = { cacheWarming: 'off', + codemode: false, modelRetryLimit: DEFAULT_MODEL_RETRY_LIMIT, } diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index 8bac5b24..e735b5d6 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -12,6 +12,9 @@ export default { 'desktop.settings.runtime.retryUnlimited': 'Unlimited', 'desktop.settings.runtime.retryCountDescription': 'Excludes the initial request. Stop retrying when this limit is reached.', 'desktop.settings.runtime.retryCountUnit': 'retries', + 'desktop.settings.runtime.toolExecution': 'Tool execution', + 'desktop.settings.runtime.codemode': 'Codemode tool orchestration', + 'desktop.settings.runtime.codemodeDescription': 'Let the model use JavaScript to call tools in parallel and filter results. File, shell, plugin, and MCP tools still follow task permissions. Off by default. Applies to the next task run.', 'desktop.settings.runtime.taskPermissions': 'Task permissions', 'desktop.settings.runtime.defaultPermissionMode': 'New task permissions', 'desktop.settings.runtime.defaultPermissionModeDescription': 'New tasks start with the selected permissions. Existing tasks, including unsent drafts, keep their current settings.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index e6b70def..5e5cd133 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -10,6 +10,9 @@ export default { 'desktop.settings.runtime.retryUnlimited': '无限', 'desktop.settings.runtime.retryCountDescription': '不含首次请求,达到次数后停止重试。', 'desktop.settings.runtime.retryCountUnit': '次', + 'desktop.settings.runtime.toolExecution': '工具执行', + 'desktop.settings.runtime.codemode': 'Codemode 工具编排', + 'desktop.settings.runtime.codemodeDescription': '允许模型用 JavaScript 并行调用工具并筛选结果。文件、Shell、插件和 MCP 工具仍遵循任务权限。默认关闭,下次任务运行时生效。', 'desktop.settings.runtime.taskPermissions': '任务权限', 'desktop.settings.runtime.defaultPermissionMode': '新任务权限', 'desktop.settings.runtime.defaultPermissionModeDescription': '新建任务时使用所选权限;已有任务(包括未发送的草稿)保持当前设置。', diff --git a/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue b/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue index f92e274c..a18ae4ad 100644 --- a/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue +++ b/apps/buddy/src/modules/settings/widgets/runtime/DesktopRuntimeSettings.vue @@ -5,13 +5,13 @@ import type { RuntimePreferences } from '@buddy-shared/runtime/runtimePreference import type { ApplicationSettingsProps } from '../app/typing' import { BUDDY_PERMISSION_MODES } from '@buddy-shared/permissions/permissionMode' import { DEFAULT_MODEL_RETRY_LIMIT } from '@buddy-shared/runtime/runtimePreferences' -import { NInputNumber, NSelect, useMessage } from 'naive-ui' +import { NInputNumber, NSelect, NSwitch, useMessage } from 'naive-ui' import { computed, shallowRef, useId } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' import { DesktopFullAccessConfirmationDialog } from '@/modules/prompt-input/ui' import DesktopSegmentedControl from '@/shared/ui/segmented-control/DesktopSegmentedControl.vue' -type RuntimeSettingField = 'cacheWarming' | 'defaultPermissionMode' | 'modelRetryLimit' +type RuntimeSettingField = 'cacheWarming' | 'codemode' | 'defaultPermissionMode' | 'modelRetryLimit' type RetryMode = 0 | 'limited' | 'unlimited' const props = defineProps() @@ -26,6 +26,8 @@ const fullAccessConfirmationText = computed(() => ({ const message = useMessage() const cacheWarmingLabelId = useId() const cacheWarmingDescriptionId = useId() +const codemodeLabelId = useId() +const codemodeDescriptionId = useId() const defaultPermissionLabelId = useId() const defaultPermissionDescriptionId = useId() const retryLabelId = useId() @@ -113,6 +115,10 @@ function updateRetryCount(count: number | null) { function updateCacheWarming(cacheWarming: RuntimePreferences['cacheWarming']) { void save('cacheWarming', { runtime: { cacheWarming } }) } + +function updateCodemode(codemode: boolean) { + void save('codemode', { runtime: { codemode } }) +}