fix(coding): recover provider context failures

This commit is contained in:
2026-08-26 14:42:01 +08:00
parent 9f05e2d7e1
commit a09826676e
8 changed files with 349 additions and 9 deletions

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 });