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
@@ -96,6 +96,17 @@
|
|||||||
cleanup, and stable fixed Host error projection including storage failures.
|
cleanup, and stable fixed Host error projection including storage failures.
|
||||||
- PI-105 remains closed; its vendor-neutral project seam and live target
|
- PI-105 remains closed; its vendor-neutral project seam and live target
|
||||||
`get_commands` seam were accepted structurally.
|
`get_commands` seam were accepted structurally.
|
||||||
|
- Planner re-review of cumulative HEAD `52b2467` again concluded `NEEDS FIX`
|
||||||
|
and kept the Ready Frontier at `{PI-100}`. The three remaining P1 gaps were
|
||||||
|
resolved/active Conversation model switching bypassing `runtime.setModel()`,
|
||||||
|
credential-refresh rejection escaping the auth-required contract, and
|
||||||
|
unmapped thinking/session-binding/runtime-model persistence failures.
|
||||||
|
- The incremental correction keeps unresolved first-model selection on the
|
||||||
|
validate/persist/initial-prepare path, restores target-only runtime model
|
||||||
|
switching for resolved Conversations without disposal, types every
|
||||||
|
production credential-refresh failure as authentication failure, and maps
|
||||||
|
Pi session registry binding/model writes plus service thinking writes to the
|
||||||
|
stable storage failure contract.
|
||||||
|
|
||||||
## Outcome
|
## Outcome
|
||||||
|
|
||||||
@@ -118,30 +129,37 @@
|
|||||||
- Provider changes advance the Pi provider revision, project config/knowledge
|
- Provider changes advance the Pi provider revision, project config/knowledge
|
||||||
changes advance the resource revision and invalidate cached Agent resources,
|
changes advance the resource revision and invalidate cached Agent resources,
|
||||||
and deactivating a project disposes its Conversation workers/interactions.
|
and deactivating a project disposes its Conversation workers/interactions.
|
||||||
|
- Resolved model changes reuse the target runtime's `setModel` operation and do
|
||||||
|
not dispose an active worker; unresolved Conversations still persist the
|
||||||
|
validated first model before lazy preparation. Provider refresh rejection is
|
||||||
|
projected as `CODING_PROVIDER_AUTH_REQUIRED`, while authoritative
|
||||||
|
Conversation/session write failures are projected as
|
||||||
|
`CODING_STORAGE_WRITE_FAILED`.
|
||||||
- No Renderer route was migrated here; PI-110 remains the next consumer of the
|
- No Renderer route was migrated here; PI-110 remains the next consumer of the
|
||||||
new Main contract. Old `/api/opencode` removal remains PI-140 scope.
|
new Main contract. Old `/api/opencode` removal remains PI-140 scope.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- `pnpm install --frozen-lockfile` — Passed using the repository-pinned pnpm.
|
- `pnpm install --frozen-lockfile` — Passed using the repository-pinned pnpm.
|
||||||
- `pnpm run typecheck` — Passed after final implementation changes.
|
- `pnpm run typecheck` — Passed after the latest review corrections.
|
||||||
- `pnpm run lint:check` — Passed with zero errors; six existing Renderer
|
- `pnpm run lint:check` — Passed with zero errors; six existing Renderer
|
||||||
warnings remain outside PI-100-owned files.
|
warnings remain outside PI-100-owned files.
|
||||||
- Focused PI/coding/provider suites — Passed: 7 files / 53 tests.
|
- Latest review-focused suites — Passed: 4 files / 26 tests. Coverage adds
|
||||||
- In-memory Conversation contract plus PI-100 Host/SSE suite — Passed: 2 files /
|
resolved active-run target-only model switching without disposal,
|
||||||
23 tests. Coverage includes local-only create, one Main composition, concurrent
|
credential-refresh rejection and missing refreshed Works credentials,
|
||||||
dedupe, uncertain no-resend, global and target snapshot-first streams, real
|
first session-binding write failure, runtime model metadata write failure,
|
||||||
HTTP SSE ordering, interaction route ID correlation, command degradation, and
|
and service thinking metadata write failure.
|
||||||
runtime error redaction.
|
- `pnpm test` — Passed after the latest corrections: 209 files / 2263 tests
|
||||||
- `pnpm test` — Passed: 208 files / 2247 tests passed, 2 skipped.
|
passed, 2 skipped (2265 total).
|
||||||
- `pnpm run build:vite` — Passed for Renderer, Electron Main, Preload, and
|
- `pnpm run build:vite` — Passed for Renderer, Electron Main, Preload, and
|
||||||
utility worker. Existing dynamic-import/chunk-size warnings remain.
|
utility worker. Existing dynamic-import/chunk-size warnings remain.
|
||||||
- `pnpm run test:e2e` — Production build passed and 27/28 Windows Electron tests
|
- `pnpm run test:e2e` — Earlier cumulative PI-100 verification built the
|
||||||
passed. The pre-existing OpenCode slash-command assertion failed because its
|
production app and passed 27/28 Windows Electron tests. The pre-existing
|
||||||
legacy `/command` request omitted `model`; isolated rerun reproduced it. This
|
OpenCode slash-command assertion failed because its legacy `/command` request
|
||||||
task changes no Renderer file, OpenCode command route, or slash-command spec,
|
omitted `model`; isolated rerun reproduced it. The latest correction changes
|
||||||
and the new composition performs no runtime/provider/project read before a
|
only target model/auth/storage failure paths plus unit tests, so this legacy
|
||||||
coding Snapshot/execution request, so no causal PI-100 path was found.
|
E2E was not rerun. No Renderer, OpenCode command route, or slash-command spec
|
||||||
|
is changed by PI-100.
|
||||||
- Real external Provider turns remain **Explicitly Waived / Accepted Risk**;
|
- Real external Provider turns remain **Explicitly Waived / Accepted Risk**;
|
||||||
`realTurnVerified=false`. This is not Pass evidence.
|
`realTurnVerified=false`. This is not Pass evidence.
|
||||||
- macOS x64/arm64 validation remains skipped by user direction and mandatory at
|
- macOS x64/arm64 validation remains skipped by user direction and mandatory at
|
||||||
|
|||||||
@@ -8,33 +8,53 @@ import {
|
|||||||
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
||||||
const AUTHENTICATION_ERROR_PATTERN = /\b(?:401|403|unauthori[sz]ed|forbidden|authentication failed|auth failed|invalid (?:api key|credential|access token|bearer token)|(?:access |bearer )?token expired)\b/i;
|
const AUTHENTICATION_ERROR_PATTERN = /\b(?:401|403|unauthori[sz]ed|forbidden|authentication failed|auth failed|invalid (?:api key|credential|access token|bearer token)|(?:access |bearer )?token expired)\b/i;
|
||||||
|
|
||||||
|
export class CodingProviderCredentialRefreshError extends Error {
|
||||||
|
constructor(message: string, options?: ErrorOptions) {
|
||||||
|
super(message, options);
|
||||||
|
this.name = 'CodingProviderCredentialRefreshError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isCodingProviderAuthenticationError(error: unknown): boolean {
|
export function isCodingProviderAuthenticationError(error: unknown): boolean {
|
||||||
return error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message);
|
return error instanceof CodingProviderCredentialRefreshError
|
||||||
|
|| (error instanceof Error && AUTHENTICATION_ERROR_PATTERN.test(error.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCodingProviderCredential(accountId: string): Promise<void> {
|
export async function refreshCodingProviderCredential(accountId: string): Promise<void> {
|
||||||
const providerService = getProviderService();
|
try {
|
||||||
const account = await providerService.getAccount(accountId);
|
const providerService = getProviderService();
|
||||||
if (!account?.enabled) throw new Error('Provider account is unavailable');
|
const account = await providerService.getAccount(accountId);
|
||||||
|
if (!account?.enabled) {
|
||||||
|
throw new CodingProviderCredentialRefreshError('Provider account is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
if (account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
if (account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
||||||
markWorksSquareAIGatewayCredentialExpired();
|
markWorksSquareAIGatewayCredentialExpired();
|
||||||
const credential = await getFreshWorksSquareAIGatewayCredential();
|
const credential = await getFreshWorksSquareAIGatewayCredential();
|
||||||
if (!credential) throw new Error('Provider credential refresh failed');
|
if (!credential) {
|
||||||
await providerService.updateAccount(account.id, {
|
throw new CodingProviderCredentialRefreshError('Provider credential refresh failed');
|
||||||
baseUrl: credential.oneApiBaseUrl,
|
}
|
||||||
metadata: {
|
await providerService.updateAccount(account.id, {
|
||||||
...account.metadata,
|
baseUrl: credential.oneApiBaseUrl,
|
||||||
worksSquareCredentialExpiresAt: credential.expiresAt === null
|
metadata: {
|
||||||
? undefined
|
...account.metadata,
|
||||||
: new Date(credential.expiresAt).toISOString(),
|
worksSquareCredentialExpiresAt: credential.expiresAt === null
|
||||||
},
|
? undefined
|
||||||
}, credential.accessToken);
|
: new Date(credential.expiresAt).toISOString(),
|
||||||
return;
|
},
|
||||||
}
|
}, credential.accessToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const current = await resolvePiProviderCredentialFromSecretStore(account);
|
const current = await resolvePiProviderCredentialFromSecretStore(account);
|
||||||
if (!current && account.authMode !== 'local') {
|
if (!current && account.authMode !== 'local') {
|
||||||
throw new Error('Provider credential is unavailable');
|
throw new CodingProviderCredentialRefreshError('Provider credential is unavailable');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodingProviderCredentialRefreshError) throw error;
|
||||||
|
throw new CodingProviderCredentialRefreshError(
|
||||||
|
'Provider credential refresh failed',
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,11 +339,24 @@ export class CodingConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
|
async setModel(conversationId: string, model: ProductModelRef): Promise<ConversationModelState> {
|
||||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
|
||||||
let selected: ProductModelRef;
|
let selected: ProductModelRef;
|
||||||
try {
|
try {
|
||||||
selected = await this.runtime.validateModel(model);
|
selected = await this.runtime.validateModel(model);
|
||||||
} catch (error) { runtimeError(error); }
|
} catch (error) { runtimeError(error); }
|
||||||
|
if (conversation.modelResolution === 'resolved' && conversation.model) {
|
||||||
|
await this.ensurePrepared(conversationId);
|
||||||
|
let state: ConversationModelState;
|
||||||
|
try {
|
||||||
|
state = await this.runtime.setModel({
|
||||||
|
conversationId,
|
||||||
|
accountId: selected.accountId,
|
||||||
|
modelId: selected.modelId,
|
||||||
|
});
|
||||||
|
} catch (error) { runtimeError(error); }
|
||||||
|
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
|
||||||
|
return state;
|
||||||
|
}
|
||||||
const state: ConversationModelState = {
|
const state: ConversationModelState = {
|
||||||
model: selected,
|
model: selected,
|
||||||
modelResolution: 'resolved',
|
modelResolution: 'resolved',
|
||||||
@@ -364,7 +377,7 @@ export class CodingConversationService {
|
|||||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||||
try {
|
try {
|
||||||
const state = await this.runtime.setThinking({ conversationId, thinkingLevel });
|
const state = await this.runtime.setThinking({ conversationId, thinkingLevel });
|
||||||
await this.projects.conversationStore(project.path).setModelState(conversationId, state);
|
await persist(() => this.projects.conversationStore(project.path).setModelState(conversationId, state));
|
||||||
return state;
|
return state;
|
||||||
} catch (error) { runtimeError(error); }
|
} catch (error) { runtimeError(error); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type {
|
|||||||
CodingConversationRuntime,
|
CodingConversationRuntime,
|
||||||
CodingRuntimeCommand,
|
CodingRuntimeCommand,
|
||||||
CodingRuntimeDiagnostics,
|
CodingRuntimeDiagnostics,
|
||||||
CodingRuntimeErrorCode,
|
|
||||||
CodingRuntimePublicError,
|
|
||||||
ConversationModelState,
|
ConversationModelState,
|
||||||
ConversationInteraction,
|
ConversationInteraction,
|
||||||
ConversationInteractionResponse,
|
ConversationInteractionResponse,
|
||||||
@@ -22,22 +20,14 @@ import type {
|
|||||||
SetConversationModelInput,
|
SetConversationModelInput,
|
||||||
SetThinkingLevelInput,
|
SetThinkingLevelInput,
|
||||||
} from './contracts';
|
} from './contracts';
|
||||||
|
import { CodingRuntimeContractError } from './runtime-errors';
|
||||||
|
export { CodingRuntimeContractError } from './runtime-errors';
|
||||||
import {
|
import {
|
||||||
createConversationReducerState,
|
createConversationReducerState,
|
||||||
reduceConversationPatch,
|
reduceConversationPatch,
|
||||||
type ConversationReducerState,
|
type ConversationReducerState,
|
||||||
} from './conversation-reducer';
|
} from './conversation-reducer';
|
||||||
|
|
||||||
export class CodingRuntimeContractError extends Error {
|
|
||||||
readonly publicError: CodingRuntimePublicError;
|
|
||||||
|
|
||||||
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CodingRuntimeContractError';
|
|
||||||
this.publicError = { code, message, recoverable };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InMemoryConversationRuntimeOptions {
|
export interface InMemoryConversationRuntimeOptions {
|
||||||
snapshots?: ConversationSnapshot[];
|
snapshots?: ConversationSnapshot[];
|
||||||
commands?: CodingRuntimeCommand[];
|
commands?: CodingRuntimeCommand[];
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
reduceConversationPatch,
|
reduceConversationPatch,
|
||||||
type ConversationReducerState,
|
type ConversationReducerState,
|
||||||
} from '../conversation-reducer';
|
} from '../conversation-reducer';
|
||||||
import { CodingRuntimeContractError } from '../in-memory-conversation-runtime';
|
import { CodingRuntimeContractError } from '../runtime-errors';
|
||||||
import {
|
import {
|
||||||
PiEventProjector,
|
PiEventProjector,
|
||||||
type PiEventProjectorOptions,
|
type PiEventProjectorOptions,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ConversationModelState,
|
ConversationModelState,
|
||||||
PrepareConversationInput,
|
PrepareConversationInput,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
|
import { CodingRuntimeContractError } from '../runtime-errors';
|
||||||
|
|
||||||
export interface PiRegisteredConversation {
|
export interface PiRegisteredConversation {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -22,6 +23,7 @@ export interface PiRegisteredConversation {
|
|||||||
|
|
||||||
export interface PiSessionRegistryOptions {
|
export interface PiSessionRegistryOptions {
|
||||||
projectStore: CodingProjectStore;
|
projectStore: CodingProjectStore;
|
||||||
|
createConversationStore?: typeof createCodingConversationStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegistryRecord extends PiRegisteredConversation {
|
interface RegistryRecord extends PiRegisteredConversation {
|
||||||
@@ -55,11 +57,13 @@ function sameModelState(left: ConversationModelState, right: ConversationModelSt
|
|||||||
|
|
||||||
export class PiSessionRegistry {
|
export class PiSessionRegistry {
|
||||||
private readonly projectStore: CodingProjectStore;
|
private readonly projectStore: CodingProjectStore;
|
||||||
|
private readonly createConversationStore: typeof createCodingConversationStore;
|
||||||
private readonly records = new Map<string, RegistryRecord>();
|
private readonly records = new Map<string, RegistryRecord>();
|
||||||
private readonly prepareFlights = new Map<string, Promise<RegistryRecord>>();
|
private readonly prepareFlights = new Map<string, Promise<RegistryRecord>>();
|
||||||
|
|
||||||
constructor(options: PiSessionRegistryOptions) {
|
constructor(options: PiSessionRegistryOptions) {
|
||||||
this.projectStore = options.projectStore;
|
this.projectStore = options.projectStore;
|
||||||
|
this.createConversationStore = options.createConversationStore ?? createCodingConversationStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepare(input: PrepareConversationInput): Promise<PiRegisteredConversation> {
|
async prepare(input: PrepareConversationInput): Promise<PiRegisteredConversation> {
|
||||||
@@ -71,7 +75,9 @@ export class PiSessionRegistry {
|
|||||||
createBinding: () => Promise<PiSessionBinding>,
|
createBinding: () => Promise<PiSessionBinding>,
|
||||||
): Promise<PiRegisteredConversation> {
|
): Promise<PiRegisteredConversation> {
|
||||||
const record = await this.prepareRecord(input);
|
const record = await this.prepareRecord(input);
|
||||||
const conversation = await record.store.ensureSessionBinding(input.conversationId, createBinding);
|
const conversation = await this.persistWrite(
|
||||||
|
() => record.store.ensureSessionBinding(input.conversationId, createBinding),
|
||||||
|
);
|
||||||
record.conversation = conversation;
|
record.conversation = conversation;
|
||||||
record.session = {
|
record.session = {
|
||||||
piSessionId: conversation.piSessionId as string,
|
piSessionId: conversation.piSessionId as string,
|
||||||
@@ -86,7 +92,9 @@ export class PiSessionRegistry {
|
|||||||
): Promise<ConversationModelState> {
|
): Promise<ConversationModelState> {
|
||||||
const record = this.records.get(conversationId);
|
const record = this.records.get(conversationId);
|
||||||
if (!record) throw new Error('Conversation is not registered');
|
if (!record) throw new Error('Conversation is not registered');
|
||||||
record.conversation = await record.store.setModelState(conversationId, model);
|
record.conversation = await this.persistWrite(
|
||||||
|
() => record.store.setModelState(conversationId, model),
|
||||||
|
);
|
||||||
return modelStateOf(record.conversation);
|
return modelStateOf(record.conversation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +126,7 @@ export class PiSessionRegistry {
|
|||||||
candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt
|
candidate.id === input.agentId && candidate.enabled && !candidate.archivedAt
|
||||||
));
|
));
|
||||||
if (!agent) throw new Error('Coding Agent does not exist');
|
if (!agent) throw new Error('Coding Agent does not exist');
|
||||||
const store = createCodingConversationStore(project.path);
|
const store = this.createConversationStore(project.path);
|
||||||
const conversation = await store.get(input.conversationId);
|
const conversation = await store.get(input.conversationId);
|
||||||
if (!conversation || conversation.agentId !== input.agentId) {
|
if (!conversation || conversation.agentId !== input.agentId) {
|
||||||
throw new Error('Coding Conversation does not exist for the selected Agent');
|
throw new Error('Coding Conversation does not exist for the selected Agent');
|
||||||
@@ -138,4 +146,17 @@ export class PiSessionRegistry {
|
|||||||
this.records.set(input.conversationId, record);
|
this.records.set(input.conversationId, record);
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async persistWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CodingRuntimeContractError) throw error;
|
||||||
|
throw new CodingRuntimeContractError(
|
||||||
|
'CODING_STORAGE_WRITE_FAILED',
|
||||||
|
'Coding Conversation state could not be persisted',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type {
|
||||||
|
CodingRuntimeErrorCode,
|
||||||
|
CodingRuntimePublicError,
|
||||||
|
} from './contracts';
|
||||||
|
|
||||||
|
export class CodingRuntimeContractError extends Error {
|
||||||
|
readonly publicError: CodingRuntimePublicError;
|
||||||
|
|
||||||
|
constructor(code: CodingRuntimeErrorCode, message: string, recoverable: boolean) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CodingRuntimeContractError';
|
||||||
|
this.publicError = { code, message, recoverable };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 () => {
|
it('disposes and moves a bound session to Main-owned trash before deleting metadata', async () => {
|
||||||
const result = await setup();
|
const result = await setup();
|
||||||
const conversation = await createConversation(result.conversations);
|
const conversation = await createConversation(result.conversations);
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
|
||||||
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
|
||||||
import {
|
import {
|
||||||
@@ -64,67 +68,70 @@ afterEach(async () => {
|
|||||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
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', () => {
|
describe('Pi runtime Provider authentication recovery', () => {
|
||||||
it('refreshes and reopens once, then exposes the second authentication failure without looping', async () => {
|
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 refreshCredential = vi.fn(async () => undefined);
|
||||||
const runtime = new PiConversationRuntime({
|
const { conversation, pool, runtime, workers } = await setupAuthRuntime(refreshCredential);
|
||||||
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 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(runtime.prompt({
|
await expect(runtime.prompt({
|
||||||
clientRequestId: 'request-auth',
|
clientRequestId: 'request-auth',
|
||||||
@@ -147,4 +154,25 @@ describe('Pi runtime Provider authentication recovery', () => {
|
|||||||
.toEqual([1, 1]);
|
.toEqual([1, 1]);
|
||||||
expect(pool.getState(conversation.id)).toMatchObject({ state: 'idle', generation: 2 });
|
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' },
|
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