fix(coding): recover provider context failures
This commit is contained in:
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleAiProxyRoutes } from '@electron/api/routes/ai-proxy';
|
||||
import {
|
||||
clearWorksSquareAIGatewayCredential,
|
||||
getWorksSquareAIGatewaySnapshot,
|
||||
seedWorksSquareAIGatewayCredential,
|
||||
} from '@electron/services/works-square-ai-gateway';
|
||||
import {
|
||||
@@ -339,6 +340,42 @@ describe('ai proxy routes', () => {
|
||||
expect(response.body()).toContain('works_square_gateway_authorize_failed');
|
||||
});
|
||||
|
||||
it('fails fast and expires the gateway credential when Works cannot resolve one-api user context', async () => {
|
||||
seedWorksSquareAIGatewayCredential({
|
||||
accessToken: 'ws-ai-token',
|
||||
expiresIn: 3600,
|
||||
oneApiBaseUrl: 'https://one-api.example.com/v1',
|
||||
});
|
||||
const body = JSON.stringify({
|
||||
error: {
|
||||
message: 'works square AI gateway did not return one-api user context',
|
||||
type: 'one_api_error',
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(body, {
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAiProxyRoutes(
|
||||
createRequest('POST', { model: 'qwen3.7-max', messages: [{ role: 'user', content: 'secret prompt' }] }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(response.statusCode).toBe(401);
|
||||
expect(response.body()).toBe(body);
|
||||
expect(getWorksSquareAIGatewaySnapshot()).toMatchObject({ expiresAt: 0 });
|
||||
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('secret prompt');
|
||||
expect(JSON.stringify(loggerWarnMock.mock.calls)).not.toContain('ws-ai-token');
|
||||
});
|
||||
|
||||
it('maps explicit upstream group saturation to a non-retryable response status', async () => {
|
||||
seedWorksSquareAIGatewayCredential({
|
||||
accessToken: 'ws-ai-token',
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import type {
|
||||
ConversationSnapshot,
|
||||
ConversationPatchEnvelope,
|
||||
PrepareConversationInput,
|
||||
PromptConversationInput,
|
||||
} from '../../electron/coding-runtime/contracts';
|
||||
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
|
||||
@@ -175,7 +176,22 @@ describe('PI-100 coding core Host contract', () => {
|
||||
mission: 'Implement', owns: [], boundaries: [], collaborators: [], principles: [],
|
||||
},
|
||||
});
|
||||
const runtime = new InMemoryConversationRuntime();
|
||||
class StrictResolvedModelRuntime extends InMemoryConversationRuntime {
|
||||
readonly prepareInputs: PrepareConversationInput[] = [];
|
||||
|
||||
override async prepare(input: PrepareConversationInput) {
|
||||
this.prepareInputs.push(structuredClone(input));
|
||||
if (input.model.modelResolution !== 'resolved' || !input.model.model) {
|
||||
throw new CodingRuntimeContractError(
|
||||
'CODING_MIGRATION_MODEL_REQUIRED',
|
||||
'A resolved model is required before preparation',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return await super.prepare(input);
|
||||
}
|
||||
}
|
||||
const runtime = new StrictResolvedModelRuntime();
|
||||
const projects = new CodingProjectService(store);
|
||||
const conversations = new CodingConversationService(projects, runtime);
|
||||
const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' });
|
||||
@@ -189,6 +205,10 @@ describe('PI-100 coding core Host contract', () => {
|
||||
conversationId: conversation.id,
|
||||
model: { model: MODEL, modelResolution: 'resolved' },
|
||||
}));
|
||||
expect(runtime.prepareInputs).toHaveLength(1);
|
||||
expect(runtime.prepareInputs.every(({ model }) => (
|
||||
model.modelResolution === 'resolved' && model.model !== null
|
||||
))).toBe(true);
|
||||
await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({
|
||||
model: MODEL,
|
||||
modelResolution: 'resolved',
|
||||
|
||||
@@ -435,6 +435,71 @@ describe('Pi event projector', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('projects a non-retryable Works user-context failure as Provider auth required', async () => {
|
||||
const projector = new PiEventProjector({ createId: () => 'provider-error-a' });
|
||||
let snapshot = emptySnapshot();
|
||||
const providerFailure = {
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
stopReason: 'error',
|
||||
errorMessage: '401: works square AI gateway did not return one-api user context; secret request detail',
|
||||
timestamp: 10,
|
||||
};
|
||||
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'message_end',
|
||||
message: providerFailure,
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'agent_end',
|
||||
messages: [providerFailure],
|
||||
willRetry: false,
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' }));
|
||||
|
||||
expect(snapshot.run).toMatchObject({
|
||||
status: 'idle',
|
||||
terminalReason: 'failed',
|
||||
error: {
|
||||
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret request detail');
|
||||
});
|
||||
|
||||
it('keeps an exhausted Works user-context retry redacted and Provider-owned', async () => {
|
||||
const projector = new PiEventProjector({ createId: () => 'provider-retry-error-a' });
|
||||
let snapshot = emptySnapshot();
|
||||
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'auto_retry_start',
|
||||
attempt: 3,
|
||||
maxAttempts: 3,
|
||||
delayMs: 1_000,
|
||||
errorMessage: 'sensitive earlier failure',
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, {
|
||||
type: 'auto_retry_end',
|
||||
success: false,
|
||||
attempt: 3,
|
||||
finalError: '502: works square AI gateway did not return one-api user context; secret final detail',
|
||||
}));
|
||||
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'agent_settled' }));
|
||||
|
||||
expect(snapshot.run).toMatchObject({
|
||||
status: 'idle',
|
||||
terminalReason: 'failed',
|
||||
error: {
|
||||
code: 'CODING_PROVIDER_AUTH_REQUIRED',
|
||||
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
|
||||
recoverable: true,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => {
|
||||
const ids = ['compaction-a', 'summary-retry-boundary-a'];
|
||||
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
|
||||
|
||||
Reference in New Issue
Block a user