fix(coding): preserve runtime model and failure contracts
This commit is contained in:
1 parent
52b2467d5d
commit
195979d30f
11 files changed
+427
-112
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user