fix(coding): recover provider context failures
This commit is contained in:
1 parent
9f05e2d7e1
commit
a09826676e
8 files changed
+349
-9
No files matched your search
@@ -0,0 +1,170 @@
|
|||||||
|
# Task: Fix Pi first-model and Provider context failures
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260826-fix-pi-model-provider-8d4c2a71
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260826-fix-pi-model-provider-8d4c2a71-fix-pi-model-provider
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore-fix-pi-model-provider-8d4c2a71
|
||||||
|
- Base commit: 9f05e2d7e194b3ae3507c019de53e437aaed9458
|
||||||
|
- Owner: codex-root
|
||||||
|
- Status: In Progress
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Fix the production ordering for an unresolved Conversation's first model
|
||||||
|
selection: validate the selected managed model, persist resolved product
|
||||||
|
metadata, and only then prepare the Pi worker/session. Preserve the existing
|
||||||
|
active-run-safe `runtime.setModel()` path for already-resolved Conversations.
|
||||||
|
- Add a production-shaped regression whose runtime rejects every unresolved
|
||||||
|
prepare, so the InMemory runtime can no longer hide the ordering defect.
|
||||||
|
- Classify the exact captured Works Square gateway failure "did not return
|
||||||
|
one-api user context" at the Main-owned local AI proxy boundary as a
|
||||||
|
Provider credential/context failure. Preserve the upstream body, avoid
|
||||||
|
generic Pi automatic retry of the deterministic `502`, and reuse the
|
||||||
|
existing single credential refresh/reopen boundary without replaying a
|
||||||
|
mutation.
|
||||||
|
- Replace the misleading generic English "local Agent retry failed" projection
|
||||||
|
with a fixed Provider-neutral Chinese retry failure message while retaining
|
||||||
|
content redaction.
|
||||||
|
- Rebuild and verify the Windows package and final packaged proof after the
|
||||||
|
product-code correction; durably bind the candidate/artifact/runtime/test
|
||||||
|
identity required by the prior planner review.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- The installed incident is confirmed to be a Works/one-api user-context
|
||||||
|
rejection, not a Pi worker crash and not the unresolved-first-model defect.
|
||||||
|
These are two independent defects fixed in one bounded correction because
|
||||||
|
both block acceptance of the same candidate.
|
||||||
|
- Do not invent a new Provider authentication protocol or forward the local
|
||||||
|
Host `Authorization` token upstream. Existing evidence shows the same proxy
|
||||||
|
contract worked before the incident; only the exact observed structured
|
||||||
|
failure may be reclassified unless tests or source contracts prove more.
|
||||||
|
- Keep Provider refresh bounded to the existing one refresh/reopen attempt.
|
||||||
|
Never replay an accepted or uncertain mutation, and never add another Pi
|
||||||
|
restart/watchdog/fallback path.
|
||||||
|
- Preserve Pi `0.84.2`, the hard cutover, Conversation target isolation,
|
||||||
|
selected-account secret scoping, redaction, and Main ownership of Provider
|
||||||
|
and runtime lifecycle.
|
||||||
|
- Real Provider remains `Explicitly Waived / Accepted Risk` with
|
||||||
|
`realTurnVerified=false`. A loopback or captured-response regression is not
|
||||||
|
a real Provider Pass. macOS and native non-WSL Linux remain unverified.
|
||||||
|
- No external Works account, gateway mapping, installed user data, or running
|
||||||
|
application is mutated by this feature task. If the server mapping remains
|
||||||
|
invalid, the client must expose an actionable Provider failure but cannot
|
||||||
|
manufacture a successful turn.
|
||||||
|
|
||||||
|
## Project Context Loaded
|
||||||
|
|
||||||
|
- `.project-docs/10-overview/read-before-planning.md`
|
||||||
|
- `.project-docs/10-overview/project-positioning.md`
|
||||||
|
- `.project-docs/10-overview/current-state.md`
|
||||||
|
- `.project-docs/20-architecture/system-overview.md`
|
||||||
|
- `.project-docs/20-architecture/module-map.md`
|
||||||
|
- `.project-docs/20-architecture/data-flow.md`
|
||||||
|
- `.project-docs/40-rules/business-rules.md`
|
||||||
|
- `.project-docs/40-rules/success-criteria.md`
|
||||||
|
- `.project-docs/60-contracts/commitments.md`
|
||||||
|
- `.project-docs/60-contracts/stale-items.md`
|
||||||
|
- `.project-docs/00-governance/memory-index.md`
|
||||||
|
- `.project-docs/00-governance/decision-index.md`
|
||||||
|
- `.project-docs/00-governance/evidence-index.md`
|
||||||
|
- `.project-docs/00-governance/reflection-index.md`
|
||||||
|
- `.project-docs/00-governance/planning-gate.md`
|
||||||
|
- `.project-docs/30-worklog/proposals/20260825-pi-runtime-resilience-proposal.md`
|
||||||
|
- Peer scopes inspected: the completed background-run implementation, its
|
||||||
|
planner review, and the installed-package diagnosis. This task owns only the
|
||||||
|
two accepted corrections and their regenerated Windows proof; it does not
|
||||||
|
reopen retained-correlation/background-lease design or modify the read-only
|
||||||
|
diagnosis records.
|
||||||
|
- All registry peers still marked `planning` were inspected through their task
|
||||||
|
records. Eight retain placeholder/undefined scope and therefore provide
|
||||||
|
unknown coordination state, but none claims this worktree or a concrete Pi
|
||||||
|
Provider/conversation-service code path. The one concrete E2E peer is scoped
|
||||||
|
to an AI Design test. The existing `main` integration task owns only the old
|
||||||
|
OpenCode model-switch reconciliation and the canonical integration lock; it
|
||||||
|
does not conflict with feature implementation here, but it must be resolved
|
||||||
|
before any later merge to `main`.
|
||||||
|
- Planning Gate: **Passed**. Ownership matches this task's feature worktree and
|
||||||
|
branch; no concrete semantic conflict blocks the bounded correction.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing regressions for strict unresolved preparation and the exact
|
||||||
|
captured Works user-context response; confirm both fail for the expected
|
||||||
|
contract reason.
|
||||||
|
2. Make the minimal ordering and Provider-classification changes, plus the
|
||||||
|
redacted Provider-neutral retry message, then rerun the focused feedback
|
||||||
|
loop.
|
||||||
|
3. Run typecheck, scoped/full lint and unit tests, `build:vite`, applicable
|
||||||
|
Windows Electron E2E, Windows package/artifact/Pi verifiers, and final
|
||||||
|
packaged proof. Record exact candidate and artifact identity.
|
||||||
|
4. Complete the task documentation gate and commit only owned product/tests/
|
||||||
|
task-record changes. Integration to `main` remains a separate Integration
|
||||||
|
Gate after the feature candidate is accepted.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- `CodingConversationService.setModel()` now keeps the already-resolved path
|
||||||
|
unchanged (`prepare -> authoritative Snapshot guard -> runtime.setModel ->
|
||||||
|
persist`) while the unresolved path performs `validate -> persist resolved
|
||||||
|
metadata -> initial prepare`. A strict runtime that rejects unresolved input
|
||||||
|
reproduces the old `CODING_MIGRATION_MODEL_REQUIRED` failure and passes only
|
||||||
|
after the ordering correction.
|
||||||
|
- The Main-owned AI proxy recognizes only the captured Works Square
|
||||||
|
user-context failure shape. It expires the cached gateway credential,
|
||||||
|
preserves the upstream JSON body, maps the retryable upstream `502` to a
|
||||||
|
non-retryable `401`, and does not replay the current Provider mutation. The
|
||||||
|
next user retry obtains a fresh gateway credential through the existing
|
||||||
|
credential service.
|
||||||
|
- Pi projection now terminalizes a non-retrying assistant Provider error on
|
||||||
|
`agent_end`, preserves the failure through `agent_settled`, and classifies
|
||||||
|
both fail-fast and exhausted-retry forms of the observed Works failure as
|
||||||
|
`CODING_PROVIDER_AUTH_REQUIRED`. Raw Provider/request text is not projected.
|
||||||
|
Other exhausted Provider retries use a fixed Chinese Provider-neutral
|
||||||
|
message instead of blaming a local Agent crash.
|
||||||
|
- This client correction cannot repair a persistently invalid Works backend
|
||||||
|
account mapping. It makes one fresh-credential retry possible and attributes
|
||||||
|
any continuing failure to the correct Provider boundary.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Regression red phase: the three new production-shaped tests failed on the
|
||||||
|
old implementation with unresolved prepare, forwarded `502`, and an
|
||||||
|
incorrectly completed run.
|
||||||
|
- Focused related tests: 5 files / 54 tests passed before the exhausted-retry
|
||||||
|
addition; final projector file 12/12 passed.
|
||||||
|
- `pnpm run typecheck`: passed.
|
||||||
|
- Scoped ESLint: passed.
|
||||||
|
- `pnpm test`: 181 functional files / 1540 passed / 2 skipped, plus REN-008
|
||||||
|
pressure 1/1 passed. The final added projector assertion is covered by a
|
||||||
|
subsequent isolated 12/12 pass and will be included in the clean-candidate
|
||||||
|
rerun.
|
||||||
|
- `pnpm run lint:check`: 0 errors / 5 pre-existing warnings.
|
||||||
|
- `pnpm run build:vite`: passed.
|
||||||
|
- `pnpm run test:electron:windows`: 2 files / 4 tests passed.
|
||||||
|
- A dirty-worktree staging/package smoke completed after the GitHub UV download
|
||||||
|
timed out twice; exact verified uv 0.10.0 binaries were reused from the prior
|
||||||
|
accepted Windows candidate. This smoke is not final artifact evidence because
|
||||||
|
packaged Git metadata still points at the base commit. A clean committed
|
||||||
|
rebuild and all artifact/final-ASAR verifiers remain required.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Commit the implementation candidate, rebuild from that clean commit, and run
|
||||||
|
Windows artifact/Pi/final-ASAR verification with a self-bound structured
|
||||||
|
report before completing this feature task.
|
||||||
|
- After feature acceptance, use a separate Integration Gate to resolve the old
|
||||||
|
`main` integration lock, reconcile canonical project memory, and merge. Do
|
||||||
|
not conflate this feature branch with integration ownership.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target canonical document: Pi runtime/provider failure contract.
|
||||||
|
Proposal: record that deterministic Works user-context failures expire the
|
||||||
|
cached gateway credential, fail fast without replay, and remain Provider
|
||||||
|
failures through Renderer projection rather than being relabeled as local
|
||||||
|
worker crashes. Evidence: captured installed-package trace plus strict proxy
|
||||||
|
and projector regressions. Human confirmation required during Integration
|
||||||
|
Gate: yes.
|
||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind';
|
import { getAIGatewayErrorKind } from '../../../shared/ai-gateway-error-kind';
|
||||||
import { isAIGatewayUpstreamSaturated } from '../../../shared/ai-gateway-error-details';
|
import {
|
||||||
|
isAIGatewayUpstreamSaturated,
|
||||||
|
isAIGatewayUserContextMissing,
|
||||||
|
} from '../../../shared/ai-gateway-error-details';
|
||||||
|
|
||||||
const AI_PROXY_PREFIX = '/api/ai-proxy/v1';
|
const AI_PROXY_PREFIX = '/api/ai-proxy/v1';
|
||||||
const MAX_ERROR_LOG_MESSAGE_LENGTH = 600;
|
const MAX_ERROR_LOG_MESSAGE_LENGTH = 600;
|
||||||
@@ -80,6 +83,9 @@ function isExpiredGatewayTokenResponse(status: number, bodyText: string): boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getForwardedOneApiStatus(status: number, bodyText: string): number {
|
function getForwardedOneApiStatus(status: number, bodyText: string): number {
|
||||||
|
if (status === 502 && isAIGatewayUserContextMissing(bodyText)) {
|
||||||
|
return 401;
|
||||||
|
}
|
||||||
if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') {
|
if (status === 429 && getAIGatewayErrorKind(bodyText) === 'quota_exhausted') {
|
||||||
return 402;
|
return 402;
|
||||||
}
|
}
|
||||||
@@ -282,6 +288,9 @@ export async function handleAiProxyRoutes(
|
|||||||
upstream = upstreamResult.response;
|
upstream = upstreamResult.response;
|
||||||
targetUrl = upstreamResult.targetUrl;
|
targetUrl = upstreamResult.targetUrl;
|
||||||
} else {
|
} else {
|
||||||
|
if (isAIGatewayUserContextMissing(bodyText)) {
|
||||||
|
markWorksSquareAIGatewayCredentialExpired();
|
||||||
|
}
|
||||||
logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText);
|
logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText);
|
||||||
await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText));
|
await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText));
|
||||||
return true;
|
return true;
|
||||||
@@ -290,6 +299,9 @@ export async function handleAiProxyRoutes(
|
|||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const bodyText = await upstream.text();
|
const bodyText = await upstream.text();
|
||||||
|
if (isAIGatewayUserContextMissing(bodyText)) {
|
||||||
|
markWorksSquareAIGatewayCredentialExpired();
|
||||||
|
}
|
||||||
logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText);
|
logNonSuccessOneApiResponseFromBody(upstream, targetUrl, bodyText);
|
||||||
await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText));
|
await writeBufferedResponse(res, upstream, bodyText, getForwardedOneApiStatus(upstream.status, bodyText));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -443,13 +443,13 @@ export class CodingConversationService {
|
|||||||
try {
|
try {
|
||||||
selected = await this.runtime.validateModel(model);
|
selected = await this.runtime.validateModel(model);
|
||||||
} catch (error) { runtimeError(error); }
|
} catch (error) { runtimeError(error); }
|
||||||
await this.ensurePrepared(conversationId);
|
|
||||||
let snapshot: ConversationSnapshot;
|
|
||||||
try {
|
|
||||||
snapshot = await this.runtime.getSnapshot(conversationId);
|
|
||||||
} catch (error) { runtimeError(error); }
|
|
||||||
this.assertSnapshotAllowsMutation(snapshot);
|
|
||||||
if (conversation.modelResolution === 'resolved' && conversation.model) {
|
if (conversation.modelResolution === 'resolved' && conversation.model) {
|
||||||
|
await this.ensurePrepared(conversationId);
|
||||||
|
let snapshot: ConversationSnapshot;
|
||||||
|
try {
|
||||||
|
snapshot = await this.runtime.getSnapshot(conversationId);
|
||||||
|
} catch (error) { runtimeError(error); }
|
||||||
|
this.assertSnapshotAllowsMutation(snapshot);
|
||||||
let state: ConversationModelState;
|
let state: ConversationModelState;
|
||||||
try {
|
try {
|
||||||
state = await this.runtime.setModel({
|
state = await this.runtime.setModel({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ConversationToolNode,
|
ConversationToolNode,
|
||||||
PublicUsage,
|
PublicUsage,
|
||||||
} from '../contracts';
|
} from '../contracts';
|
||||||
|
import { isAIGatewayUserContextMissing } from '../../../shared/ai-gateway-error-details';
|
||||||
import type { PiRpcEvent } from './rpc-client';
|
import type { PiRpcEvent } from './rpc-client';
|
||||||
import { subagentDetailsOfResult } from '../subagent-protocol';
|
import { subagentDetailsOfResult } from '../subagent-protocol';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +45,17 @@ function retryFailure(message: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerFailure(message: unknown) {
|
||||||
|
if (typeof message === 'string' && isAIGatewayUserContextMissing(message)) {
|
||||||
|
return {
|
||||||
|
code: 'CODING_PROVIDER_AUTH_REQUIRED' as const,
|
||||||
|
message: '模型服务身份上下文无效,请重试;若仍失败请重新登录。',
|
||||||
|
recoverable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return retryFailure('模型服务请求失败,请稍后重试。');
|
||||||
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
@@ -282,7 +294,7 @@ export class PiEventProjector {
|
|||||||
...run,
|
...run,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
terminalReason: 'failed',
|
terminalReason: 'failed',
|
||||||
error: retryFailure('The local Agent retry failed'),
|
error: providerFailure(event.finalError),
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@@ -459,6 +471,25 @@ export class PiEventProjector {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.type === 'agent_end' && event.willRetry === false && Array.isArray(event.messages)) {
|
||||||
|
const assistant = [...event.messages]
|
||||||
|
.reverse()
|
||||||
|
.map(asRecord)
|
||||||
|
.find((message) => message?.role === 'assistant');
|
||||||
|
if (assistant?.stopReason === 'error') {
|
||||||
|
return [{
|
||||||
|
op: 'run.state',
|
||||||
|
run: {
|
||||||
|
...snapshot.run,
|
||||||
|
status: 'error',
|
||||||
|
terminalReason: 'failed',
|
||||||
|
error: providerFailure(assistant.errorMessage),
|
||||||
|
},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === 'extension_ui_request'
|
if (event.type === 'extension_ui_request'
|
||||||
&& typeof event.id === 'string'
|
&& typeof event.id === 'string'
|
||||||
&& typeof event.method === 'string'
|
&& typeof event.method === 'string'
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ export function isAIGatewayUpstreamSaturated(message: string | null | undefined)
|
|||||||
return normalized.includes('当前分组上游负载已饱和')
|
return normalized.includes('当前分组上游负载已饱和')
|
||||||
|| (normalized.includes('upstream') && normalized.includes('saturat'));
|
|| (normalized.includes('upstream') && normalized.includes('saturat'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAIGatewayUserContextMissing(message: string | null | undefined): boolean {
|
||||||
|
const normalized = message?.trim().toLowerCase() ?? '';
|
||||||
|
return normalized.includes('works square ai gateway did not return one-api user context');
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { handleAiProxyRoutes } from '@electron/api/routes/ai-proxy';
|
import { handleAiProxyRoutes } from '@electron/api/routes/ai-proxy';
|
||||||
import {
|
import {
|
||||||
clearWorksSquareAIGatewayCredential,
|
clearWorksSquareAIGatewayCredential,
|
||||||
|
getWorksSquareAIGatewaySnapshot,
|
||||||
seedWorksSquareAIGatewayCredential,
|
seedWorksSquareAIGatewayCredential,
|
||||||
} from '@electron/services/works-square-ai-gateway';
|
} from '@electron/services/works-square-ai-gateway';
|
||||||
import {
|
import {
|
||||||
@@ -339,6 +340,42 @@ describe('ai proxy routes', () => {
|
|||||||
expect(response.body()).toContain('works_square_gateway_authorize_failed');
|
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 () => {
|
it('maps explicit upstream group saturation to a non-retryable response status', async () => {
|
||||||
seedWorksSquareAIGatewayCredential({
|
seedWorksSquareAIGatewayCredential({
|
||||||
accessToken: 'ws-ai-token',
|
accessToken: 'ws-ai-token',
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ConversationSnapshot,
|
ConversationSnapshot,
|
||||||
ConversationPatchEnvelope,
|
ConversationPatchEnvelope,
|
||||||
|
PrepareConversationInput,
|
||||||
PromptConversationInput,
|
PromptConversationInput,
|
||||||
} from '../../electron/coding-runtime/contracts';
|
} from '../../electron/coding-runtime/contracts';
|
||||||
import { archivePiConversationSession } from '../../electron/coding-runtime/pi/resource-loader';
|
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: [],
|
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 projects = new CodingProjectService(store);
|
||||||
const conversations = new CodingConversationService(projects, runtime);
|
const conversations = new CodingConversationService(projects, runtime);
|
||||||
const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' });
|
const conversation = await conversations.createConversation({ agentId: 'builder', title: 'Resolve me' });
|
||||||
@@ -189,6 +205,10 @@ describe('PI-100 coding core Host contract', () => {
|
|||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
model: { model: MODEL, modelResolution: 'resolved' },
|
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({
|
await expect(projects.conversationStore(root).get(conversation.id)).resolves.toMatchObject({
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
modelResolution: 'resolved',
|
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 () => {
|
it('maps all summary-retry events and keeps exhausted compaction failure redacted', async () => {
|
||||||
const ids = ['compaction-a', 'summary-retry-boundary-a'];
|
const ids = ['compaction-a', 'summary-retry-boundary-a'];
|
||||||
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
|
const projector = new PiEventProjector({ createId: () => ids.shift() as string });
|
||||||
|
|||||||
Reference in new issue
Block a user