fix(coding): preserve runtime model and failure contracts

This commit is contained in:
brother7 committed 2026-08-23 21:21:02 +08:00
1 parent 52b2467d5d
commit 195979d30f
11 files changed
+427 -112

No files matched your search

+86 -58
View File
@@ -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);
});
});