Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .playwright/scripts/__tests__/agentInput.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
35 changes: 34 additions & 1 deletion .playwright/scripts/__tests__/settings.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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/)
Expand Down
4 changes: 2 additions & 2 deletions .playwright/scripts/buddy-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
2 changes: 1 addition & 1 deletion apps/buddy/electron/main/app/DesktopRuntimeHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
5 changes: 3 additions & 2 deletions apps/buddy/electron/main/config/LexoraConfigStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions apps/buddy/platform/filesystem/resolveFilePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions apps/buddy/service/src/BuddyService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions apps/buddy/service/src/agent/events/PiApplicationObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} })
Expand Down Expand Up @@ -131,7 +140,7 @@ describe('piEventBridge', () => {
type: 'tool_execution_update',
})
}
emit({ type: 'agent_settled' })
emit({ type: 'agent_settled', aborted: false })

await channel.flush()

Expand Down
Loading