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
17 changes: 10 additions & 7 deletions apps/daemon/src/__tests__/config-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ import { createProviderConfigStore } from '../provider-store';
import { createInMemoryVault } from './fixtures/in-memory-vault';

const fsMocks = vi.hoisted(() => ({
openTargets: new Map<number, string>(),
openTargets: new Map<number, { path: string; flags: string }>(),
renameTarget: null as string | null,
renameTargets: [] as string[],
syncTargets: [] as string[],
syncTargets: [] as Array<{ path: string; flags: string }>,
}));

vi.mock('node:fs', async (importOriginal) => {
Expand All @@ -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(
Expand All @@ -42,7 +42,7 @@ vi.mock('node:fs', async (importOriginal) => {
mode?: Parameters<typeof actual.openSync>[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(
Expand Down Expand Up @@ -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', () => {
Expand Down
15 changes: 8 additions & 7 deletions apps/daemon/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,56 @@
// @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) {
tayoriMock.params.push(params);
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' },
Expand All @@ -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' },
});
Expand Down
8 changes: 7 additions & 1 deletion packages/client/workbench/src/surface/use-agent-catalogs.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -17,10 +18,15 @@ import { useData } from '../runtime/tayori';
const SCOPED = { keepPreviousData: false } as const;

export function useAgentStartCatalogs(cwd?: string): Partial<Record<AgentKind, AgentStartCatalog>> {
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 }),
Expand Down
67 changes: 67 additions & 0 deletions packages/host/agent-adapter/src/__tests__/pi-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface Model {
provider: string;
id: string;
name?: string;
baseUrl?: string;
reasoning: boolean;
thinkingLevelMap?: Record<string, string | null>;
}
Expand All @@ -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,
}),
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 17 additions & 3 deletions packages/host/agent-adapter/src/native/pi/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
if (cred.baseUrl) {
for (const model of modelRegistry.getAll()) {
if (model.id === opts.model && model.baseUrl === cred.baseUrl) {
Comment thread
lucas77778 marked this conversation as resolved.
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}')`);
Comment thread
lucas77778 marked this conversation as resolved.
}
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
Expand Down
Loading