From 883b4a2b875577d172deebb211f2e1c12a2f0e95 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 17 Aug 2026 09:18:13 +0000 Subject: [PATCH 1/2] fix(daemon): fsync config through writable handle Amp-Thread-ID: https://ampcode.com/threads/T-01a00ed0-84ba-71ee-be77-845e678c66bc --- .../src/__tests__/config-persistence.test.ts | 17 ++++++++++------- apps/daemon/src/config.ts | 15 ++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/daemon/src/__tests__/config-persistence.test.ts b/apps/daemon/src/__tests__/config-persistence.test.ts index 82a3bf079..0dec71627 100644 --- a/apps/daemon/src/__tests__/config-persistence.test.ts +++ b/apps/daemon/src/__tests__/config-persistence.test.ts @@ -15,10 +15,10 @@ import { createProviderConfigStore } from '../provider-store'; import { createInMemoryVault } from './fixtures/in-memory-vault'; const fsMocks = vi.hoisted(() => ({ - openTargets: new Map(), + openTargets: new Map(), renameTarget: null as string | null, renameTargets: [] as string[], - syncTargets: [] as string[], + syncTargets: [] as Array<{ path: string; flags: string }>, })); vi.mock('node:fs', async (importOriginal) => { @@ -33,7 +33,7 @@ vi.mock('node:fs', async (importOriginal) => { } }, fsyncSync(descriptor: number) { - fsMocks.syncTargets.push(fsMocks.openTargets.get(descriptor) ?? ''); + fsMocks.syncTargets.push(fsMocks.openTargets.get(descriptor) ?? { path: '', flags: '' }); actual.fsyncSync(descriptor); }, openSync( @@ -42,7 +42,7 @@ vi.mock('node:fs', async (importOriginal) => { mode?: Parameters[2], ) { const descriptor = actual.openSync(path, flags, mode); - fsMocks.openTargets.set(descriptor, String(path)); + fsMocks.openTargets.set(descriptor, { path: String(path), flags: String(flags) }); return descriptor; }, renameSync( @@ -115,9 +115,12 @@ describe('provider config persistence', () => { expect(store.getAccounts()).toEqual([oauthAccount]); expect(statSync(config).mode & 0o777).toBe(0o600); expect(fsMocks.renameTargets).toEqual([config]); - expect(fsMocks.syncTargets[0]).toContain(join(dir, '.config.')); - expect(fsMocks.syncTargets[0]?.endsWith('.tmp')).toBe(true); - expect(fsMocks.syncTargets.slice(1)).toEqual(process.platform === 'win32' ? [] : [dir]); + expect(fsMocks.syncTargets[0]?.path).toContain(join(dir, '.config.')); + expect(fsMocks.syncTargets[0]).toMatchObject({ flags: 'wx' }); + expect(fsMocks.syncTargets[0]?.path.endsWith('.tmp')).toBe(true); + expect(fsMocks.syncTargets.slice(1).map(({ path }) => path)).toEqual( + process.platform === 'win32' ? [] : [dir], + ); }); it('rejects corrupt JSON without replacing the file or publishing memory', () => { diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index 752214590..56ad8aa0d 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -457,13 +457,14 @@ function writeConfigFields( mkdirSync(directory, { recursive: true }); const temporaryPath = join(directory, `.config.${process.pid}.${randomUUID()}.tmp`); try { - writeFileSync(temporaryPath, `${JSON.stringify(file, null, 2)}\n`, { - encoding: 'utf8', - flag: 'wx', - mode: 0o600, - }); - chmodSync(temporaryPath, 0o600); - fsyncPath(temporaryPath); + const descriptor = openSync(temporaryPath, 'wx', 0o600); + try { + writeFileSync(descriptor, `${JSON.stringify(file, null, 2)}\n`, { encoding: 'utf8' }); + chmodSync(temporaryPath, 0o600); + fsyncSync(descriptor); + } finally { + closeSync(descriptor); + } renameSync(temporaryPath, path); if (process.platform !== 'win32') fsyncPath(directory); } finally { From 70cdeb65e68385027546e1ac168cc0bd6c1c2a58 Mon Sep 17 00:00:00 2001 From: lucas77778 <3098274296@qq.com> Date: Mon, 17 Aug 2026 09:20:09 +0000 Subject: [PATCH 2/2] fix(pi): resolve bare models after runtime ready Amp-Thread-ID: https://ampcode.com/threads/T-01a00ed0-84ba-71ee-be77-845e678c66bc --- .../__tests__/use-agent-catalogs.test.ts | 31 ++++++++- .../src/surface/use-agent-catalogs.ts | 8 ++- .../src/__tests__/pi-model.test.ts | 67 +++++++++++++++++++ .../agent-adapter/src/native/pi/adapter.ts | 20 +++++- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/packages/client/workbench/src/surface/__tests__/use-agent-catalogs.test.ts b/packages/client/workbench/src/surface/__tests__/use-agent-catalogs.test.ts index 4915a49a6..a0d9fd29f 100644 --- a/packages/client/workbench/src/surface/__tests__/use-agent-catalogs.test.ts +++ b/packages/client/workbench/src/surface/__tests__/use-agent-catalogs.test.ts @@ -1,10 +1,12 @@ // @vitest-environment jsdom +import type { AgentRuntimes } from '@linkcode/schema'; import { renderHook } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useAgentStartCatalogs } from '../use-agent-catalogs'; const tayoriMock = vi.hoisted(() => ({ params: [] as unknown[] })); +const runtimeMock = vi.hoisted(() => ({ runtimes: undefined as AgentRuntimes | undefined })); vi.mock('../../runtime/tayori', () => ({ useData(_operation: unknown, params: unknown) { @@ -12,17 +14,43 @@ vi.mock('../../runtime/tayori', () => ({ return { data: undefined }; }, })); +vi.mock('../../agent-runtime/hooks', () => ({ + useAgentRuntimes: () => ({ data: runtimeMock.runtimes }), +})); afterEach(() => { tayoriMock.params.length = 0; + runtimeMock.runtimes = undefined; }); describe('useAgentStartCatalogs', () => { - it('scopes every agent catalog request to the selected workspace', () => { + it('pauses only the Pi catalog while runtime availability is loading', () => { renderHook(() => useAgentStartCatalogs('/repo/app')); // The cwd is what lets an adapter resolve the tier a session would really start under — // claude-code reads `permissions.defaultMode` from the workspace's own settings. + expect(tayoriMock.params).toEqual([ + { agentKind: 'claude-code', cwd: '/repo/app' }, + { agentKind: 'codex', cwd: '/repo/app' }, + { agentKind: 'opencode', cwd: '/repo/app' }, + null, + { agentKind: 'grok-build', cwd: '/repo/app' }, + ]); + }); + + it('keeps the Pi catalog paused while its managed runtime is missing', () => { + runtimeMock.runtimes = { pi: { status: 'missing' } }; + + renderHook(() => useAgentStartCatalogs('/repo/app')); + + expect(tayoriMock.params[3]).toBeNull(); + }); + + it('requests the Pi catalog once its runtime is available', () => { + runtimeMock.runtimes = { pi: { status: 'available', source: 'managed' } }; + + renderHook(() => useAgentStartCatalogs('/repo/app')); + expect(tayoriMock.params).toEqual([ { agentKind: 'claude-code', cwd: '/repo/app' }, { agentKind: 'codex', cwd: '/repo/app' }, @@ -33,6 +61,7 @@ describe('useAgentStartCatalogs', () => { }); it('follows a workspace switch rather than capturing the first cwd', () => { + runtimeMock.runtimes = { pi: { status: 'available', source: 'managed' } }; const { rerender } = renderHook(({ cwd }: { cwd: string }) => useAgentStartCatalogs(cwd), { initialProps: { cwd: '/repo/app' }, }); diff --git a/packages/client/workbench/src/surface/use-agent-catalogs.ts b/packages/client/workbench/src/surface/use-agent-catalogs.ts index 7a713971f..94a2aaf3a 100644 --- a/packages/client/workbench/src/surface/use-agent-catalogs.ts +++ b/packages/client/workbench/src/surface/use-agent-catalogs.ts @@ -1,5 +1,6 @@ import type { AgentKind, AgentStartCatalog } from '@linkcode/schema'; import { getAgentCatalog } from '@linkcode/sdk'; +import { useAgentRuntimes } from '../agent-runtime/hooks'; import { useData } from '../runtime/tayori'; /** @@ -17,10 +18,15 @@ import { useData } from '../runtime/tayori'; const SCOPED = { keepPreviousData: false } as const; export function useAgentStartCatalogs(cwd?: string): Partial> { + const { data: runtimes } = useAgentRuntimes(); const claude = useData(getAgentCatalog, { agentKind: 'claude-code', cwd }, SCOPED); const codex = useData(getAgentCatalog, { agentKind: 'codex', cwd }, SCOPED); const opencode = useData(getAgentCatalog, { agentKind: 'opencode', cwd }, SCOPED); - const pi = useData(getAgentCatalog, { agentKind: 'pi', cwd }, SCOPED); + const pi = useData( + getAgentCatalog, + runtimes?.pi?.status === 'available' ? { agentKind: 'pi', cwd } : null, + SCOPED, + ); const grok = useData(getAgentCatalog, { agentKind: 'grok-build', cwd }, SCOPED); return { ...(claude.data && { 'claude-code': claude.data }), diff --git a/packages/host/agent-adapter/src/__tests__/pi-model.test.ts b/packages/host/agent-adapter/src/__tests__/pi-model.test.ts index 55f9e053b..3d7ad332e 100644 --- a/packages/host/agent-adapter/src/__tests__/pi-model.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi-model.test.ts @@ -11,6 +11,7 @@ interface Model { provider: string; id: string; name?: string; + baseUrl?: string; reasoning: boolean; thinkingLevelMap?: Record; } @@ -28,6 +29,7 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({ create: () => ({ find: (provider: string, id: string) => sdk.models.find((m) => m.provider === provider && m.id === id), + getAll: () => sdk.models, getAvailable: () => sdk.models, registerProvider: sdk.registerProvider, }), @@ -157,6 +159,71 @@ describe('Pi dynamic model catalog', () => { }); }); + it('qualifies a bare model with the resolved known provider', async () => { + await start({ model: 'nulls', config: { knownProvider: 'other' } }); + + expect(sdk.createOptions).toMatchObject({ model: sdk.models[1] }); + }); + + it('loads a catalog and qualifies a legacy bare model through a unique matching endpoint', async () => { + sdk.models.push( + { + provider: 'opencode', + id: 'deepseek-v4-flash', + baseUrl: 'https://opencode.ai/zen/v1', + reasoning: true, + }, + { + provider: 'opencode-go', + id: 'deepseek-v4-flash', + baseUrl: 'https://opencode.ai/zen/go/v1', + reasoning: true, + }, + ); + + const options = { + model: 'deepseek-v4-flash', + config: { apiKey: 'account-key', baseUrl: 'https://opencode.ai/zen/go/v1' }, + }; + const catalog = await new PiAdapter().startCatalog(options); + expect(catalog.models).toContainEqual( + expect.objectContaining({ id: 'opencode-go/deepseek-v4-flash' }), + ); + + await start(options); + + expect(sdk.setRuntimeApiKey).toHaveBeenCalledWith('opencode-go', 'account-key'); + expect(sdk.createOptions).toMatchObject({ model: sdk.models[3] }); + }); + + it('keeps a qualified model authoritative over provider hints', async () => { + await start({ model: 'other/nulls', config: { knownProvider: 'openai' } }); + + expect(sdk.createOptions).toMatchObject({ model: sdk.models[1] }); + }); + + it('rejects a bare model when its provider cannot be resolved uniquely', async () => { + sdk.models[1].baseUrl = 'https://gateway.example.test/v1'; + sdk.models.push({ + provider: 'third', + id: 'nulls', + baseUrl: 'https://gateway.example.test/v1', + reasoning: false, + }); + + await expect( + start({ model: 'nulls', config: { baseUrl: 'https://gateway.example.test/v1' } }), + ).rejects.toThrow("pi: model must be 'provider/modelId' (got 'nulls')"); + expect(sdk.createOptions).toBeNull(); + }); + + it('rejects a bare model when the account provides no provider evidence', async () => { + await expect(start({ model: 'gpt' })).rejects.toThrow( + "pi: model must be 'provider/modelId' (got 'gpt')", + ); + expect(sdk.createOptions).toBeNull(); + }); + it('switches model and effort live and reflects SDK readback', async () => { const { adapter, events } = await start(); events.length = 0; diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 8ed9f71a0..85c99f84b 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -137,12 +137,26 @@ function createConfiguredRegistry( ) { const authStorage = pi.AuthStorage.create(); const modelRegistry = pi.ModelRegistry.create(authStorage); - const ref = opts.model ? parseModel(opts.model) : null; + const cred = readAgentCredential(opts.config); + let ref = opts.model ? parseModel(opts.model) : null; if (!ref && opts.model) { - throw new Error(`pi: model must be 'provider/modelId' (got '${opts.model}')`); + const endpointProviders = new Set(); + if (cred.baseUrl) { + for (const model of modelRegistry.getAll()) { + if (model.id === opts.model && model.baseUrl === cred.baseUrl) { + endpointProviders.add(model.provider); + } + } + } + const endpointProvider = + endpointProviders.size === 1 ? endpointProviders.values().next().value : undefined; + const provider = fallbackProvider ?? cred.knownProvider ?? endpointProvider; + if (!provider) { + throw new Error(`pi: model must be 'provider/modelId' (got '${opts.model}')`); + } + ref = { provider, modelId: opts.model }; } - const cred = readAgentCredential(opts.config); const key = cred.apiKey ?? cred.authToken; // The model ref decides which provider pi routes through, so it wins; a resumed session's own // last-routed provider comes next, being direct evidence rather than a catalog default; the