fix(coding): preserve runtime model and failure contracts
This commit is contained in:
@@ -187,6 +187,57 @@ describe('PI-100 coding core Host contract', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('switches a resolved active Conversation model through the target runtime without disposing it', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
await result.conversations.acceptPrompt({
|
||||
conversationId: conversation.id,
|
||||
clientRequestId: 'request-active-model-switch',
|
||||
mode: 'prompt',
|
||||
text: 'Keep this run active',
|
||||
attachments: [],
|
||||
});
|
||||
await expect(result.runtime.getSnapshot(conversation.id)).resolves.toMatchObject({
|
||||
run: { status: 'running' },
|
||||
});
|
||||
const setModel = vi.spyOn(result.runtime, 'setModel');
|
||||
const dispose = vi.spyOn(result.runtime, 'dispose');
|
||||
const nextModel = { ...MODEL, modelId: 'model-next' };
|
||||
|
||||
await expect(result.conversations.setModel(conversation.id, nextModel)).resolves.toEqual({
|
||||
model: nextModel,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
expect(setModel).toHaveBeenCalledWith({
|
||||
conversationId: conversation.id,
|
||||
accountId: nextModel.accountId,
|
||||
modelId: nextModel.modelId,
|
||||
});
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
await expect(result.runtime.getSnapshot(conversation.id)).resolves.toMatchObject({
|
||||
run: { status: 'running' },
|
||||
});
|
||||
await expect(result.projects.conversationStore(result.root).get(conversation.id)).resolves.toMatchObject({
|
||||
model: nextModel,
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps Conversation thinking metadata write failures to the stable storage error', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
await result.conversations.getSnapshot(conversation.id);
|
||||
const store = result.projects.conversationStore(result.root);
|
||||
vi.spyOn(result.projects, 'conversationStore').mockReturnValue(store);
|
||||
vi.spyOn(store, 'setModelState')
|
||||
.mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
await expect(result.conversations.setThinking(conversation.id, 'high')).rejects.toMatchObject({
|
||||
status: 500,
|
||||
code: 'CODING_STORAGE_WRITE_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => {
|
||||
const result = await setup();
|
||||
const conversation = await createConversation(result.conversations);
|
||||
|
||||
63
tests/unit/coding-provider-auth.test.ts
Normal file
63
tests/unit/coding-provider-auth.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAccount: vi.fn(),
|
||||
updateAccount: vi.fn(),
|
||||
resolveCredential: vi.fn(),
|
||||
getFreshCredential: vi.fn(),
|
||||
markExpired: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../electron/services/providers/provider-service', () => ({
|
||||
getProviderService: () => ({
|
||||
getAccount: mocks.getAccount,
|
||||
updateAccount: mocks.updateAccount,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../electron/coding-runtime/pi/provider-config', () => ({
|
||||
resolvePiProviderCredentialFromSecretStore: mocks.resolveCredential,
|
||||
}));
|
||||
|
||||
vi.mock('../../electron/services/works-square-ai-gateway', () => ({
|
||||
getFreshWorksSquareAIGatewayCredential: mocks.getFreshCredential,
|
||||
markWorksSquareAIGatewayCredentialExpired: mocks.markExpired,
|
||||
}));
|
||||
|
||||
import {
|
||||
CodingProviderCredentialRefreshError,
|
||||
isCodingProviderAuthenticationError,
|
||||
refreshCodingProviderCredential,
|
||||
} from '../../electron/api/coding-provider-auth';
|
||||
|
||||
describe('coding Provider credential refresh boundary', () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
});
|
||||
|
||||
it('types a rejected production refresh callback as an authentication failure', async () => {
|
||||
mocks.getAccount.mockRejectedValueOnce(new Error('secure store unavailable'));
|
||||
|
||||
const failure = await refreshCodingProviderCredential('account-a').catch((error) => error);
|
||||
expect(failure).toBeInstanceOf(CodingProviderCredentialRefreshError);
|
||||
expect(isCodingProviderAuthenticationError(failure)).toBe(true);
|
||||
});
|
||||
|
||||
it('types a missing refreshed Works credential as an authentication failure', async () => {
|
||||
mocks.getAccount.mockResolvedValueOnce({
|
||||
id: 'account-works',
|
||||
enabled: true,
|
||||
authMode: 'api_key',
|
||||
metadata: { worksSquareCredentialMode: 'works_square_ai_gateway' },
|
||||
});
|
||||
mocks.getFreshCredential.mockResolvedValueOnce(null);
|
||||
|
||||
const failure = await refreshCodingProviderCredential('account-works').catch((error) => error);
|
||||
expect(mocks.markExpired).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(CodingProviderCredentialRefreshError);
|
||||
expect(isCodingProviderAuthenticationError(failure)).toBe(true);
|
||||
expect(mocks.updateAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CodingProviderCredentialRefreshError,
|
||||
isCodingProviderAuthenticationError,
|
||||
} from '../../electron/api/coding-provider-auth';
|
||||
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
|
||||
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||
import {
|
||||
@@ -64,67 +68,70 @@ afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function setupAuthRuntime(refreshCredential: (accountId: string) => Promise<void>) {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-auth-'));
|
||||
roots.push(projectPath);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-auth',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-auth',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Auth Agent',
|
||||
model: { accountId: 'account-auth', modelId: 'model-auth', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
}, { now: NOW });
|
||||
const store = createCodingConversationStore(projectPath, {
|
||||
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
now: () => NOW,
|
||||
});
|
||||
const conversation = await store.create({
|
||||
agentId: 'agent-auth',
|
||||
title: 'Auth Conversation',
|
||||
model: { accountId: 'account-auth', modelId: 'model-auth', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const workers: AuthFailureWorker[] = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
openWorker: async ({ conversation: input, existingSession }) => {
|
||||
const worker = new AuthFailureWorker(`worker-${workers.length + 1}`);
|
||||
workers.push(worker);
|
||||
return {
|
||||
worker,
|
||||
session: existingSession ?? {
|
||||
piSessionId: `session-${input.conversationId}`,
|
||||
sessionKey: `key-${input.conversationId}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const runtime = new PiConversationRuntime({
|
||||
pool,
|
||||
registry: new PiSessionRegistry({ projectStore }),
|
||||
resolveModel: async () => { throw new Error('not used'); },
|
||||
refreshCredential,
|
||||
isAuthenticationError: isCodingProviderAuthenticationError,
|
||||
createId: () => 'run-auth',
|
||||
});
|
||||
await runtime.prepare({
|
||||
conversationId: conversation.id,
|
||||
projectId: 'project-auth',
|
||||
agentId: 'agent-auth',
|
||||
title: conversation.title,
|
||||
model: { model: conversation.model, modelResolution: conversation.modelResolution },
|
||||
});
|
||||
return { conversation, pool, runtime, workers };
|
||||
}
|
||||
|
||||
describe('Pi runtime Provider authentication recovery', () => {
|
||||
it('refreshes and reopens once, then exposes the second authentication failure without looping', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-auth-'));
|
||||
roots.push(projectPath);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-auth',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-auth',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Auth Agent',
|
||||
model: { accountId: 'account-auth', modelId: 'model-auth', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
}, { now: NOW });
|
||||
const store = createCodingConversationStore(projectPath, {
|
||||
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
now: () => NOW,
|
||||
});
|
||||
const conversation = await store.create({
|
||||
agentId: 'agent-auth',
|
||||
title: 'Auth Conversation',
|
||||
model: { accountId: 'account-auth', modelId: 'model-auth', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const workers: AuthFailureWorker[] = [];
|
||||
const pool = new PiWorkerPool({
|
||||
maxIdle: 2,
|
||||
openWorker: async ({ conversation: input, existingSession }) => {
|
||||
const worker = new AuthFailureWorker(`worker-${workers.length + 1}`);
|
||||
workers.push(worker);
|
||||
return {
|
||||
worker,
|
||||
session: existingSession ?? {
|
||||
piSessionId: `session-${input.conversationId}`,
|
||||
sessionKey: `key-${input.conversationId}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const refreshCredential = vi.fn(async () => undefined);
|
||||
const runtime = new PiConversationRuntime({
|
||||
pool,
|
||||
registry: new PiSessionRegistry({ projectStore }),
|
||||
resolveModel: async () => { throw new Error('not used'); },
|
||||
refreshCredential,
|
||||
isAuthenticationError: (error) => (
|
||||
error instanceof PiProcessError && error.message.includes('401')
|
||||
),
|
||||
createId: () => 'run-auth',
|
||||
});
|
||||
await runtime.prepare({
|
||||
conversationId: conversation.id,
|
||||
projectId: 'project-auth',
|
||||
agentId: 'agent-auth',
|
||||
title: conversation.title,
|
||||
model: { model: conversation.model, modelResolution: conversation.modelResolution },
|
||||
});
|
||||
const { conversation, pool, runtime, workers } = await setupAuthRuntime(refreshCredential);
|
||||
|
||||
await expect(runtime.prompt({
|
||||
clientRequestId: 'request-auth',
|
||||
@@ -147,4 +154,25 @@ describe('Pi runtime Provider authentication recovery', () => {
|
||||
.toEqual([1, 1]);
|
||||
expect(pool.getState(conversation.id)).toMatchObject({ state: 'idle', generation: 2 });
|
||||
});
|
||||
|
||||
it('projects a rejected credential refresh as authentication required', async () => {
|
||||
const refreshCredential = vi.fn(async () => {
|
||||
throw new CodingProviderCredentialRefreshError('Provider credential refresh failed');
|
||||
});
|
||||
const { conversation, runtime } = await setupAuthRuntime(refreshCredential);
|
||||
|
||||
await expect(runtime.prompt({
|
||||
clientRequestId: 'request-auth-refresh-rejected',
|
||||
conversationId: conversation.id,
|
||||
mode: 'prompt',
|
||||
text: 'Do not leak refresh failures',
|
||||
attachments: [],
|
||||
})).rejects.toMatchObject({
|
||||
publicError: {
|
||||
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
expect(refreshCredential).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,101 @@ describe('Pi session registry', () => {
|
||||
session: { piSessionId: 'pi-session-a', sessionKey: 'session-key-a' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps first session binding persistence failure to the stable storage error', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-registry-binding-failure-'));
|
||||
roots.push(projectPath);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-binding-failure',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-a',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Agent A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
}, { now: NOW });
|
||||
const conversations = createCodingConversationStore(projectPath, {
|
||||
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d480',
|
||||
now: () => NOW,
|
||||
});
|
||||
const created = await conversations.create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Conversation A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const registry = new PiSessionRegistry({
|
||||
projectStore,
|
||||
createConversationStore: () => conversations,
|
||||
});
|
||||
const input = {
|
||||
conversationId: created.id,
|
||||
projectId: 'project-binding-failure',
|
||||
agentId: 'agent-a',
|
||||
title: created.title,
|
||||
model: { model: created.model, modelResolution: created.modelResolution },
|
||||
} as const;
|
||||
await registry.prepare(input);
|
||||
vi.spyOn(conversations, 'ensureSessionBinding').mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
await expect(registry.ensureBinding(input, async () => ({
|
||||
piSessionId: 'pi-session-a',
|
||||
sessionKey: 'session-key-a',
|
||||
}))).rejects.toMatchObject({
|
||||
publicError: { code: 'CODING_STORAGE_WRITE_FAILED', recoverable: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps model metadata persistence failure to the stable storage error', async () => {
|
||||
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-registry-model-failure-'));
|
||||
roots.push(projectPath);
|
||||
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
|
||||
createId: () => 'project-model-failure',
|
||||
now: () => NOW,
|
||||
});
|
||||
await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
|
||||
await createCodingProjectAgent(projectPath, {
|
||||
id: 'agent-a',
|
||||
avatarId: 'avatar-01',
|
||||
roleName: 'Implementer',
|
||||
name: 'Agent A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
responsibility: { mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [] },
|
||||
}, { now: NOW });
|
||||
const conversations = createCodingConversationStore(projectPath, {
|
||||
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d481',
|
||||
now: () => NOW,
|
||||
});
|
||||
const created = await conversations.create({
|
||||
agentId: 'agent-a',
|
||||
title: 'Conversation A',
|
||||
model: { accountId: 'account-a', modelId: 'model-a', thinkingLevel: 'medium' },
|
||||
modelResolution: 'resolved',
|
||||
});
|
||||
const registry = new PiSessionRegistry({
|
||||
projectStore,
|
||||
createConversationStore: () => conversations,
|
||||
});
|
||||
await registry.prepare({
|
||||
conversationId: created.id,
|
||||
projectId: 'project-model-failure',
|
||||
agentId: 'agent-a',
|
||||
title: created.title,
|
||||
model: { model: created.model, modelResolution: created.modelResolution },
|
||||
});
|
||||
vi.spyOn(conversations, 'setModelState').mockRejectedValueOnce(new Error('disk full'));
|
||||
|
||||
await expect(registry.setModel(created.id, {
|
||||
model: { accountId: 'account-b', modelId: 'model-b', thinkingLevel: 'high' },
|
||||
modelResolution: 'resolved',
|
||||
})).rejects.toMatchObject({
|
||||
publicError: { code: 'CODING_STORAGE_WRITE_FAILED', recoverable: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user