fix(pi): recover from Works prompt rejection
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
# Task: Fix Pi Works submit rejection and retry lock
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260825-pi-submit-rejected-hotfix-9f3c7a62
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260825-pi-submit-rejected-hotfix-9f3c7a62-pi-submit-rejected-hotfix
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore-pi-submit-rejected-hotfix-9f3c7a62
|
||||||
|
- Base commit: 4580568384f5391f2030bde57c63ff90891172b3
|
||||||
|
- Owner: codex-root
|
||||||
|
- Status: Verifying
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Fix the installed Windows Pi path where the Works Square OpenAI-completions
|
||||||
|
endpoint rejects Pi's leading `developer` message, by projecting the pinned
|
||||||
|
Pi `supportsDeveloperRole: false` compatibility only for the actual Works
|
||||||
|
gateway/proxy managed-model account modes.
|
||||||
|
- Make a definite prompt submission rejection restore the target Conversation's
|
||||||
|
draft and attachments as a retryable submission error that clears when the
|
||||||
|
user edits/retries, without poisoning persistent runtime preparation state.
|
||||||
|
- Add focused provider/Renderer isolation and no-replay regressions, then build
|
||||||
|
and verify a new Windows x64 NSIS through final packaged Main/UI proof.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Keep the Pi hard cutover, Pi `0.84.2`, Main-owned Provider/secret boundary,
|
||||||
|
existing Host proxy body forwarding, and one runtime path. Do not add an
|
||||||
|
OpenCode fallback, compatibility dual track, generic body rewrite, Provider
|
||||||
|
watchdog, or Pi upgrade.
|
||||||
|
- Verify the actual Provider catalog before selecting account kinds. Preserve
|
||||||
|
built-in/direct/custom OpenAI-compatible auto-detection and do not alter
|
||||||
|
OpenAI Responses, Anthropic, Google, local Provider, image, tool, session, or
|
||||||
|
child behavior.
|
||||||
|
- A definite rejection is target-scoped submission feedback. Preserve genuine
|
||||||
|
runtime/session/preparation errors and their Recover semantics; preserve
|
||||||
|
uncertain request reconciliation and never automatically replay an accepted
|
||||||
|
or uncertain prompt.
|
||||||
|
- Error text must be stable, actionable Chinese and must not expose upstream
|
||||||
|
body, prompt, headers, credentials, session content, or complete paths.
|
||||||
|
- Do not modify the read-only diagnostic worktree, dirty/occupied `main`, the
|
||||||
|
previous cumulative worktree, or the user's installed application under
|
||||||
|
`D:\Tools\泥土\niancode\Makelore`. Do not use subagents.
|
||||||
|
- Real external Provider verification remains `Explicitly Waived / Accepted
|
||||||
|
Risk` with `realTurnVerified=false`; the controlled Works-shaped endpoint is
|
||||||
|
not a full Provider-matrix Pass.
|
||||||
|
|
||||||
|
## Project Context Loaded
|
||||||
|
|
||||||
|
- Concurrent Task Gate passed: task ID, feature mode, branch, absolute worktree,
|
||||||
|
base commit, task record, ownership, and reservation match the local registry.
|
||||||
|
- Base `4580568384f5391f2030bde57c63ff90891172b3` is both the cumulative delivery
|
||||||
|
HEAD and the diagnostic HEAD, so the implementation loses no prior Pi hotfix.
|
||||||
|
- Planning Gate passed after reading entry/memory/current-state, positioning,
|
||||||
|
decisions, architecture/data-flow, domain/success, evidence/reflection,
|
||||||
|
commitments/stale records, the Pi cutover and resilience contracts, cumulative
|
||||||
|
Pi tasks, and planning peer scopes.
|
||||||
|
- Shared integrated memory predates the Pi chain and retains older OpenCode
|
||||||
|
wording. Current source, the cumulative Pi task/spec, and the read-only
|
||||||
|
diagnostic record are authoritative for this hotfix. The entry document's
|
||||||
|
shorthand paths for memory/current-state are stale; the actual files are under
|
||||||
|
`05-agent-entry` and `30-worklog`.
|
||||||
|
- The diagnostic task is the only directly overlapping peer and explicitly owns
|
||||||
|
evidence/red tests, not product implementation. Other planning tasks are
|
||||||
|
isolated, older, unrelated, or have undefined scope; no unresolved semantic
|
||||||
|
conflict changes this repair.
|
||||||
|
- Source verification confirmed exactly two managed Works credential modes:
|
||||||
|
`works_square_ai_gateway` and `works_square_ai_gateway_proxy`; both use
|
||||||
|
OpenAI Completions. Direct/custom accounts retain Pi auto-detection.
|
||||||
|
- The locked Pi `0.84.2` models schema accepts `supportsDeveloperRole`, and its
|
||||||
|
OpenAI Completions adapter selects `system` when the explicit value is false.
|
||||||
|
- Gate result: Passed.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Inspect the diagnostic red-test diff and actual Provider catalog, DTO,
|
||||||
|
managed-model projection, Renderer submission state, and packaged proof seam.
|
||||||
|
2. Recreate the two red regressions in this task and add direct/custom,
|
||||||
|
cross-Conversation, second-submit, and uncertain no-replay coverage.
|
||||||
|
3. Implement the smallest Works-only compat projection and target-scoped
|
||||||
|
definite-rejection cleanup; do not modify Host proxy bodies or runtime errors.
|
||||||
|
4. Run frozen install, focused tests, typecheck, lint, full unit, production
|
||||||
|
build, and Windows Electron E2E; fix only task-caused failures.
|
||||||
|
5. Commit a clean candidate, run formal Windows packaging and artifact closure
|
||||||
|
checks, then prove final-ASAR Works-shaped reject/system acceptance and real
|
||||||
|
UI reject/edit/retry/settle/cleanup with `realTurnVerified=false`.
|
||||||
|
6. Record exact commits, changed files, verification, installer size/hash,
|
||||||
|
packaged evidence, waivers, registry status, and clean state; complete the
|
||||||
|
Task Documentation Gate.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Implemented the Works-only managed-model compat override after imported model
|
||||||
|
capability merging, so both gateway paths materialize
|
||||||
|
`supportsDeveloperRole: false` without changing direct/custom or other APIs.
|
||||||
|
- Definite and uncertain submission failures remain request/submission state;
|
||||||
|
they restore the target draft without writing the Conversation entry's
|
||||||
|
persistent preparation error. Existing runtime/session recovery state remains
|
||||||
|
reserved for preparation, hydration, worker, and session failures.
|
||||||
|
- Extended the final packaged proof with a Works-shaped endpoint role canary and
|
||||||
|
a real Renderer reject/edit/retry/settle flow. The extension and proxy proof
|
||||||
|
provider modes are protected by a focused wiring regression.
|
||||||
|
- Packaging and final artifact/product proof remain pending.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `corepack pnpm install --frozen-lockfile` — Pass; pnpm `10.33.4`, Pi `0.84.2`.
|
||||||
|
- Initial diagnostic red command — Expected fail: 2 files / 2 tests failed,
|
||||||
|
23 passed; missing compat and permanently disabled retry reproduced.
|
||||||
|
- Focused regressions — Pass: 4 files / 49 tests, including proof wiring.
|
||||||
|
- `corepack pnpm run typecheck` — Pass (rerun after proof wiring correction).
|
||||||
|
- `corepack pnpm run lint:check` — Pass with five pre-existing warnings and no
|
||||||
|
errors.
|
||||||
|
- `corepack pnpm test` — Pass: 180 files; 1523 passed, 2 skipped.
|
||||||
|
- `corepack pnpm run build:vite` — Pass; Renderer/Main/Preload/utility built,
|
||||||
|
with existing chunk/dynamic-import warnings.
|
||||||
|
- `corepack pnpm run test:electron:windows` — Pass: 2 files / 4 tests.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- None recorded.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- None recorded.
|
||||||
@@ -43,6 +43,7 @@ export interface PiProviderModelDescriptor {
|
|||||||
compat?: {
|
compat?: {
|
||||||
thinkingFormat?: 'deepseek' | 'openrouter';
|
thinkingFormat?: 'deepseek' | 'openrouter';
|
||||||
sessionAffinityFormat?: 'openrouter';
|
sessionAffinityFormat?: 'openrouter';
|
||||||
|
supportsDeveloperRole?: boolean;
|
||||||
requiresReasoningContentOnAssistantMessages?: boolean;
|
requiresReasoningContentOnAssistantMessages?: boolean;
|
||||||
};
|
};
|
||||||
thinkingLevelMap?: Partial<Record<ProductModelRef['thinkingLevel'], string | null>>;
|
thinkingLevelMap?: Partial<Record<ProductModelRef['thinkingLevel'], string | null>>;
|
||||||
@@ -291,6 +292,7 @@ function modelDescriptor(
|
|||||||
summaries: readonly ModelSummary[],
|
summaries: readonly ModelSummary[],
|
||||||
compat: PiProviderModelDescriptor['compat'],
|
compat: PiProviderModelDescriptor['compat'],
|
||||||
backendModels: Map<string, ProviderModelEntry>,
|
backendModels: Map<string, ProviderModelEntry>,
|
||||||
|
enforcedCompat: PiProviderModelDescriptor['compat'],
|
||||||
): PiProviderModelDescriptor {
|
): PiProviderModelDescriptor {
|
||||||
const summary = summaries.find((candidate) => (
|
const summary = summaries.find((candidate) => (
|
||||||
candidate.id === modelId
|
candidate.id === modelId
|
||||||
@@ -320,8 +322,8 @@ function modelDescriptor(
|
|||||||
|| backend?.reasoning === true,
|
|| backend?.reasoning === true,
|
||||||
...(contextWindow ? { contextWindow } : {}),
|
...(contextWindow ? { contextWindow } : {}),
|
||||||
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
||||||
...(compat || profile?.pi?.compat
|
...(compat || profile?.pi?.compat || enforcedCompat
|
||||||
? { compat: { ...compat, ...profile?.pi?.compat } }
|
? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } }
|
||||||
: {}),
|
: {}),
|
||||||
...(profile?.pi?.thinkingLevelMap
|
...(profile?.pi?.thinkingLevelMap
|
||||||
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
|
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
|
||||||
@@ -367,12 +369,19 @@ function descriptorForAccount(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const apiKeyEnv = apiKeyEnvForAccount(account.id);
|
const apiKeyEnv = apiKeyEnvForAccount(account.id);
|
||||||
|
const credentialMode = account.metadata?.worksSquareCredentialMode;
|
||||||
|
const enforcedCompat: PiProviderModelDescriptor['compat'] = mapping.api === 'openai-completions'
|
||||||
|
&& (credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
|
||||||
|
|| credentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE)
|
||||||
|
? { supportsDeveloperRole: false }
|
||||||
|
: undefined;
|
||||||
const models = modelIdsForAccount(account).map((modelId) => modelDescriptor(
|
const models = modelIdsForAccount(account).map((modelId) => modelDescriptor(
|
||||||
account,
|
account,
|
||||||
modelId,
|
modelId,
|
||||||
summaries,
|
summaries,
|
||||||
mapping.compat,
|
mapping.compat,
|
||||||
backendModelEntries(account),
|
backendModelEntries(account),
|
||||||
|
enforcedCompat,
|
||||||
));
|
));
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
throw new PiProviderConfigError(
|
throw new PiProviderConfigError(
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { PiProcessBudget, PiWorkerPool, type PiWorkerPoolEvent } from './worker-
|
|||||||
import { PiProjectWriteLeaseCoordinator, type PiProjectWriteLease } from './write-lease';
|
import { PiProjectWriteLeaseCoordinator, type PiProjectWriteLease } from './write-lease';
|
||||||
|
|
||||||
type ProofWorkerRole = 'parent' | 'child';
|
type ProofWorkerRole = 'parent' | 'child';
|
||||||
type ProofProviderMode = 'subagent' | 'pressure' | 'resilience';
|
type ProofProviderMode = 'subagent' | 'pressure' | 'resilience' | 'submit-rejection';
|
||||||
type ProofMilestone = PiRuntimeTelemetryEvent['milestone'] | 'agent.start' | 'provider.first_event';
|
type ProofMilestone = PiRuntimeTelemetryEvent['milestone'] | 'agent.start' | 'provider.first_event';
|
||||||
type ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_message_start';
|
type ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_message_start';
|
||||||
|
|
||||||
@@ -56,6 +56,8 @@ type ProviderRequest = {
|
|||||||
role: ProofWorkerRole;
|
role: ProofWorkerRole;
|
||||||
toolNames: string[];
|
toolNames: string[];
|
||||||
hasToolResult: boolean;
|
hasToolResult: boolean;
|
||||||
|
firstMessageRole: string | null;
|
||||||
|
outcome: 'accepted' | 'controlled_rejection' | 'developer_role_rejection';
|
||||||
};
|
};
|
||||||
|
|
||||||
type HeldProviderResponse = {
|
type HeldProviderResponse = {
|
||||||
@@ -66,6 +68,7 @@ type HeldProviderResponse = {
|
|||||||
type LocalProofProvider = {
|
type LocalProofProvider = {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
requests: ProviderRequest[];
|
requests: ProviderRequest[];
|
||||||
|
roleContract: { developerRejected: boolean; systemAccepted: boolean } | null;
|
||||||
activeCounts(): { parent: number; child: number };
|
activeCounts(): { parent: number; child: number };
|
||||||
releaseChildren(): void;
|
releaseChildren(): void;
|
||||||
releaseParents(): void;
|
releaseParents(): void;
|
||||||
@@ -184,6 +187,13 @@ export interface PiReleaseProxyCompositionStatus {
|
|||||||
logs: boolean;
|
logs: boolean;
|
||||||
diagnostics: boolean;
|
diagnostics: boolean;
|
||||||
};
|
};
|
||||||
|
providerCompatibility: {
|
||||||
|
roleContract: { developerRejected: boolean; systemAccepted: boolean };
|
||||||
|
requestRoles: string[];
|
||||||
|
developerRoleRequests: number;
|
||||||
|
systemRoleRequests: number;
|
||||||
|
controlledDefiniteRejections: number;
|
||||||
|
};
|
||||||
realTurnVerified: false;
|
realTurnVerified: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +206,8 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
|
|||||||
runStatus: string;
|
runStatus: string;
|
||||||
userInputProjected: boolean;
|
userInputProjected: boolean;
|
||||||
parentResponseProjected: boolean;
|
parentResponseProjected: boolean;
|
||||||
|
definiteRejectionObserved: boolean;
|
||||||
|
retryAccepted: boolean;
|
||||||
};
|
};
|
||||||
currentHostTokenUsedBy: ProofWorkerRole[];
|
currentHostTokenUsedBy: ProofWorkerRole[];
|
||||||
released: { workers: number };
|
released: { workers: number };
|
||||||
@@ -247,6 +259,7 @@ export interface PiReleaseResilienceProof extends PiReleaseResilienceStatus {
|
|||||||
const PROOF_ACCOUNT_ID = 'release-proof-account';
|
const PROOF_ACCOUNT_ID = 'release-proof-account';
|
||||||
const PROOF_AGENT_ID = 'release-proof-agent';
|
const PROOF_AGENT_ID = 'release-proof-agent';
|
||||||
const PROOF_MODEL_ID = 'release-proof-model';
|
const PROOF_MODEL_ID = 'release-proof-model';
|
||||||
|
const PROXY_PROOF_MODEL_ID = 'deepseek-v4-pro';
|
||||||
const PROOF_PROVIDER_FIRST_EVENT_DELAY_MS = 75;
|
const PROOF_PROVIDER_FIRST_EVENT_DELAY_MS = 75;
|
||||||
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
|
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
|
||||||
'worker.queue_wait',
|
'worker.queue_wait',
|
||||||
@@ -327,6 +340,19 @@ function hasToolResult(body: Record<string, unknown>): boolean {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstMessageRole(body: Record<string, unknown>): string | null {
|
||||||
|
if (!Array.isArray(body.messages)) return null;
|
||||||
|
const first = body.messages[0];
|
||||||
|
if (!first || typeof first !== 'object' || Array.isArray(first)) return null;
|
||||||
|
const role = (first as { role?: unknown }).role;
|
||||||
|
return typeof role === 'string' ? role : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectProofRequest(response: ServerResponse, message: string): void {
|
||||||
|
response.writeHead(400, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ error: { message } }));
|
||||||
|
}
|
||||||
|
|
||||||
function writeChunk(
|
function writeChunk(
|
||||||
response: ServerResponse,
|
response: ServerResponse,
|
||||||
model: string,
|
model: string,
|
||||||
@@ -422,6 +448,7 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
const held = new Set<HeldProviderResponse>();
|
const held = new Set<HeldProviderResponse>();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let closeFlight: Promise<void> | null = null;
|
let closeFlight: Promise<void> | null = null;
|
||||||
|
let controlledSubmitRejectionIssued = false;
|
||||||
const server: Server = createServer(async (request, response) => {
|
const server: Server = createServer(async (request, response) => {
|
||||||
response.once('error', () => undefined);
|
response.once('error', () => undefined);
|
||||||
try {
|
try {
|
||||||
@@ -431,10 +458,43 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
}
|
}
|
||||||
const body = await readRequestBody(request);
|
const body = await readRequestBody(request);
|
||||||
const model = typeof body.model === 'string' ? body.model : PROOF_MODEL_ID;
|
const model = typeof body.model === 'string' ? body.model : PROOF_MODEL_ID;
|
||||||
|
const leadingRole = firstMessageRole(body);
|
||||||
|
if (request.headers['x-makelore-release-proof-role-canary'] === '1') {
|
||||||
|
if (leadingRole === 'developer') {
|
||||||
|
rejectProofRequest(response, 'developer role is unsupported by the controlled endpoint');
|
||||||
|
} else if (leadingRole === 'system') {
|
||||||
|
response.writeHead(204).end();
|
||||||
|
} else {
|
||||||
|
rejectProofRequest(response, 'role canary requires a leading system or developer message');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const toolNames = toolNamesFrom(body);
|
const toolNames = toolNamesFrom(body);
|
||||||
const role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child';
|
const role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child';
|
||||||
const toolResult = hasToolResult(body);
|
const toolResult = hasToolResult(body);
|
||||||
requests.push({ role, toolNames, hasToolResult: toolResult });
|
const providerRequest: ProviderRequest = {
|
||||||
|
role,
|
||||||
|
toolNames,
|
||||||
|
hasToolResult: toolResult,
|
||||||
|
firstMessageRole: leadingRole,
|
||||||
|
outcome: 'accepted',
|
||||||
|
};
|
||||||
|
requests.push(providerRequest);
|
||||||
|
|
||||||
|
if (mode === 'submit-rejection' && leadingRole === 'developer') {
|
||||||
|
providerRequest.outcome = 'developer_role_rejection';
|
||||||
|
rejectProofRequest(response, 'developer role is unsupported by the controlled endpoint');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === 'submit-rejection'
|
||||||
|
&& role === 'parent'
|
||||||
|
&& !toolResult
|
||||||
|
&& !controlledSubmitRejectionIssued) {
|
||||||
|
controlledSubmitRejectionIssued = true;
|
||||||
|
providerRequest.outcome = 'controlled_rejection';
|
||||||
|
rejectProofRequest(response, 'controlled definite model rejection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'resilience') {
|
if (mode === 'resilience') {
|
||||||
if (role === 'child') {
|
if (role === 'child') {
|
||||||
@@ -470,12 +530,12 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'subagent' && role === 'parent' && !toolResult) {
|
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent' && !toolResult) {
|
||||||
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||||
respondWithSubagentCall(response, model);
|
respondWithSubagentCall(response, model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'subagent' && role === 'parent') {
|
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent') {
|
||||||
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||||
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
|
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
|
||||||
return;
|
return;
|
||||||
@@ -506,6 +566,33 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
if (!address || typeof address === 'string') {
|
if (!address || typeof address === 'string') {
|
||||||
throw new Error('Release proof Provider did not bind a loopback port');
|
throw new Error('Release proof Provider did not bind a loopback port');
|
||||||
}
|
}
|
||||||
|
const baseUrl = `http://127.0.0.1:${address.port}/v1`;
|
||||||
|
let roleContract: LocalProofProvider['roleContract'] = null;
|
||||||
|
if (mode === 'submit-rejection') {
|
||||||
|
const probe = async (role: 'developer' | 'system'): Promise<number> => {
|
||||||
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-makelore-release-proof-role-canary': '1',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: PROXY_PROOF_MODEL_ID,
|
||||||
|
messages: [{ role, content: 'role canary' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await response.text();
|
||||||
|
return response.status;
|
||||||
|
};
|
||||||
|
const [developerStatus, systemStatus] = await Promise.all([
|
||||||
|
probe('developer'),
|
||||||
|
probe('system'),
|
||||||
|
]);
|
||||||
|
roleContract = {
|
||||||
|
developerRejected: developerStatus === 400,
|
||||||
|
systemAccepted: systemStatus === 204,
|
||||||
|
};
|
||||||
|
}
|
||||||
const release = (role?: ProofWorkerRole) => {
|
const release = (role?: ProofWorkerRole) => {
|
||||||
for (const entry of [...held]) {
|
for (const entry of [...held]) {
|
||||||
if (role && entry.role !== role) continue;
|
if (role && entry.role !== role) continue;
|
||||||
@@ -514,8 +601,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
baseUrl,
|
||||||
requests,
|
requests,
|
||||||
|
roleContract,
|
||||||
activeCounts: () => ({
|
activeCounts: () => ({
|
||||||
parent: [...held].filter(({ role }) => role === 'parent').length,
|
parent: [...held].filter(({ role }) => role === 'parent').length,
|
||||||
child: [...held].filter(({ role }) => role === 'child').length,
|
child: [...held].filter(({ role }) => role === 'child').length,
|
||||||
@@ -782,13 +870,14 @@ async function inspectWindowsPiProcesses(hostToken: string): Promise<{
|
|||||||
return { supported: true, processes };
|
return { supported: true, processes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function proxyProviderAccount(baseUrl: string): ProviderAccount {
|
function proxyProviderAccount(baseUrl: string, modelId = PROOF_MODEL_ID): ProviderAccount {
|
||||||
return {
|
return {
|
||||||
...providerAccount(baseUrl),
|
...providerAccount(baseUrl),
|
||||||
|
model: modelId,
|
||||||
label: 'Authenticated Host proxy release proof',
|
label: 'Authenticated Host proxy release proof',
|
||||||
metadata: {
|
metadata: {
|
||||||
worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
|
worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
|
||||||
customModels: [PROOF_MODEL_ID],
|
customModels: [modelId],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -811,6 +900,24 @@ async function proxyCompositionStatus(
|
|||||||
directoryContainsValue(logsDirectory, run.hostToken),
|
directoryContainsValue(logsDirectory, run.hostToken),
|
||||||
]);
|
]);
|
||||||
const providerRequests = providerRequestCounts(run.provider);
|
const providerRequests = providerRequestCounts(run.provider);
|
||||||
|
const providerCompatibility = {
|
||||||
|
roleContract: run.provider.roleContract ?? {
|
||||||
|
developerRejected: false,
|
||||||
|
systemAccepted: false,
|
||||||
|
},
|
||||||
|
requestRoles: run.provider.requests.flatMap(({ firstMessageRole }) => (
|
||||||
|
firstMessageRole ? [firstMessageRole] : []
|
||||||
|
)),
|
||||||
|
developerRoleRequests: run.provider.requests.filter(({ firstMessageRole }) => (
|
||||||
|
firstMessageRole === 'developer'
|
||||||
|
)).length,
|
||||||
|
systemRoleRequests: run.provider.requests.filter(({ firstMessageRole }) => (
|
||||||
|
firstMessageRole === 'system'
|
||||||
|
)).length,
|
||||||
|
controlledDefiniteRejections: run.provider.requests.filter(({ outcome }) => (
|
||||||
|
outcome === 'controlled_rejection'
|
||||||
|
)).length,
|
||||||
|
};
|
||||||
const parent = processInspection.processes
|
const parent = processInspection.processes
|
||||||
.filter(({ role }) => role === 'parent')
|
.filter(({ role }) => role === 'parent')
|
||||||
.map(({ processId }) => processId)
|
.map(({ processId }) => processId)
|
||||||
@@ -832,6 +939,7 @@ async function proxyCompositionStatus(
|
|||||||
logs: !logsContainToken,
|
logs: !logsContainToken,
|
||||||
diagnostics: !JSON.stringify(diagnostics).includes(run.hostToken),
|
diagnostics: !JSON.stringify(diagnostics).includes(run.hostToken),
|
||||||
},
|
},
|
||||||
|
providerCompatibility,
|
||||||
realTurnVerified: false,
|
realTurnVerified: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1256,7 +1364,7 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
|||||||
if (proxyCompositionRun) throw new Error('PI proxy composition proof is already running');
|
if (proxyCompositionRun) throw new Error('PI proxy composition proof is already running');
|
||||||
const hostToken = input.hostToken.trim();
|
const hostToken = input.hostToken.trim();
|
||||||
if (!hostToken) throw new Error('Current Main Host token is unavailable');
|
if (!hostToken) throw new Error('Current Main Host token is unavailable');
|
||||||
const provider = await startLocalProofProvider('subagent');
|
const provider = await startLocalProofProvider('submit-rejection');
|
||||||
let projectId: string | null = null;
|
let projectId: string | null = null;
|
||||||
try {
|
try {
|
||||||
seedWorksSquareAIGatewayCredential({
|
seedWorksSquareAIGatewayCredential({
|
||||||
@@ -1264,7 +1372,10 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
|||||||
oneApiBaseUrl: provider.baseUrl,
|
oneApiBaseUrl: provider.baseUrl,
|
||||||
});
|
});
|
||||||
const providerService = getProviderService();
|
const providerService = getProviderService();
|
||||||
await providerService.createAccount(proxyProviderAccount(input.hostProxyBaseUrl));
|
await providerService.createAccount(proxyProviderAccount(
|
||||||
|
input.hostProxyBaseUrl,
|
||||||
|
PROXY_PROOF_MODEL_ID,
|
||||||
|
));
|
||||||
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
|
await providerService.setDefaultAccount(PROOF_ACCOUNT_ID);
|
||||||
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
|
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
|
||||||
projectId = project.project.id;
|
projectId = project.project.id;
|
||||||
@@ -1275,8 +1386,8 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
|||||||
name: 'Packaged proxy proof agent',
|
name: 'Packaged proxy proof agent',
|
||||||
model: {
|
model: {
|
||||||
accountId: PROOF_ACCOUNT_ID,
|
accountId: PROOF_ACCOUNT_ID,
|
||||||
modelId: PROOF_MODEL_ID,
|
modelId: PROXY_PROOF_MODEL_ID,
|
||||||
thinkingLevel: 'medium',
|
thinkingLevel: 'high',
|
||||||
},
|
},
|
||||||
modelResolution: 'resolved',
|
modelResolution: 'resolved',
|
||||||
responsibility: {
|
responsibility: {
|
||||||
@@ -1366,6 +1477,15 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
|||||||
if (!binding?.piSessionId || !binding.sessionKey) {
|
if (!binding?.piSessionId || !binding.sessionKey) {
|
||||||
throw new Error('First proxy Conversation did not persist its Pi session binding');
|
throw new Error('First proxy Conversation did not persist its Pi session binding');
|
||||||
}
|
}
|
||||||
|
if (!finalStatus.providerCompatibility.roleContract.developerRejected
|
||||||
|
|| !finalStatus.providerCompatibility.roleContract.systemAccepted
|
||||||
|
|| finalStatus.providerCompatibility.developerRoleRequests !== 0
|
||||||
|
|| finalStatus.providerCompatibility.systemRoleRequests < 2
|
||||||
|
|| finalStatus.providerCompatibility.controlledDefiniteRejections !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Works Provider role compatibility proof failed: ${JSON.stringify(finalStatus.providerCompatibility)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const tokenSafety = {
|
const tokenSafety = {
|
||||||
argv: activeStatus.tokenSafety.argv && finalStatus.tokenSafety.argv,
|
argv: activeStatus.tokenSafety.argv && finalStatus.tokenSafety.argv,
|
||||||
modelsJson: activeStatus.tokenSafety.modelsJson && finalStatus.tokenSafety.modelsJson,
|
modelsJson: activeStatus.tokenSafety.modelsJson && finalStatus.tokenSafety.modelsJson,
|
||||||
@@ -1387,8 +1507,10 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
|||||||
bindingEstablished: true,
|
bindingEstablished: true,
|
||||||
workerStatus: snapshot.worker.status,
|
workerStatus: snapshot.worker.status,
|
||||||
runStatus: snapshot.run.status,
|
runStatus: snapshot.run.status,
|
||||||
userInputProjected: serializedNodes.includes('Exercise the packaged Main proxy Conversation'),
|
userInputProjected: serializedNodes.includes('Retry the packaged Main proxy Conversation'),
|
||||||
parentResponseProjected: serializedNodes.includes('REAL_PARENT_COMPLETE'),
|
parentResponseProjected: serializedNodes.includes('REAL_PARENT_COMPLETE'),
|
||||||
|
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
|
||||||
|
retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 2,
|
||||||
},
|
},
|
||||||
currentHostTokenUsedBy,
|
currentHostTokenUsedBy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ function assertProxyStatus(status) {
|
|||||||
|| status?.processes?.supported !== true
|
|| status?.processes?.supported !== true
|
||||||
|| status?.processes?.parent?.length !== 1
|
|| status?.processes?.parent?.length !== 1
|
||||||
|| status?.processes?.child?.length !== 1
|
|| status?.processes?.child?.length !== 1
|
||||||
|
|| status?.providerCompatibility?.roleContract?.developerRejected !== true
|
||||||
|
|| status?.providerCompatibility?.roleContract?.systemAccepted !== true
|
||||||
|
|| status?.providerCompatibility?.developerRoleRequests !== 0
|
||||||
|
|| status?.providerCompatibility?.systemRoleRequests < 2
|
||||||
|
|| status?.providerCompatibility?.controlledDefiniteRejections !== 1
|
||||||
|| Object.values(status?.tokenSafety ?? {}).some((safe) => safe !== true)) {
|
|| Object.values(status?.tokenSafety ?? {}).some((safe) => safe !== true)) {
|
||||||
throw new Error(`Packaged Main proxy status is incomplete: ${JSON.stringify(status)}`);
|
throw new Error(`Packaged Main proxy status is incomplete: ${JSON.stringify(status)}`);
|
||||||
}
|
}
|
||||||
@@ -157,6 +162,10 @@ function assertProxyProof(proof) {
|
|||||||
|| proof?.firstConversation?.runStatus !== 'idle'
|
|| proof?.firstConversation?.runStatus !== 'idle'
|
||||||
|| proof?.firstConversation?.userInputProjected !== true
|
|| proof?.firstConversation?.userInputProjected !== true
|
||||||
|| proof?.firstConversation?.parentResponseProjected !== true
|
|| proof?.firstConversation?.parentResponseProjected !== true
|
||||||
|
|| proof?.firstConversation?.definiteRejectionObserved !== true
|
||||||
|
|| proof?.firstConversation?.retryAccepted !== true
|
||||||
|
|| proof?.providerCompatibility?.developerRoleRequests !== 0
|
||||||
|
|| proof?.providerCompatibility?.controlledDefiniteRejections !== 1
|
||||||
|| proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child'
|
|| proof?.currentHostTokenUsedBy?.join(',') !== 'parent,child'
|
||||||
|| proof?.released?.workers !== 0
|
|| proof?.released?.workers !== 0
|
||||||
|| Object.values(proof?.tokenSafety ?? {}).some((safe) => safe !== true)) {
|
|| Object.values(proof?.tokenSafety ?? {}).some((safe) => safe !== true)) {
|
||||||
@@ -164,19 +173,25 @@ function assertProxyProof(proof) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForActiveProxyProof(electronApplication) {
|
async function waitForProxyProof(electronApplication, predicate, message) {
|
||||||
const deadline = Date.now() + 30_000;
|
const deadline = Date.now() + 30_000;
|
||||||
let latest;
|
let latest;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
latest = await evaluateProof(electronApplication, 'proxy.status');
|
latest = await evaluateProof(electronApplication, 'proxy.status');
|
||||||
if (latest?.proxy?.activeProviderRequests?.child === 1
|
if (predicate(latest?.proxy)) return latest;
|
||||||
&& latest?.proxy?.processes?.parent?.length === 1
|
|
||||||
&& latest?.proxy?.processes?.child?.length === 1) {
|
|
||||||
return latest;
|
|
||||||
}
|
|
||||||
await new Promise((resolveWait) => setTimeout(resolveWait, 200));
|
await new Promise((resolveWait) => setTimeout(resolveWait, 200));
|
||||||
}
|
}
|
||||||
throw new Error(`Packaged Main proxy child did not become active: ${JSON.stringify(latest?.proxy)}`);
|
throw new Error(`${message}: ${JSON.stringify(latest?.proxy)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForActiveProxyProof(electronApplication) {
|
||||||
|
return await waitForProxyProof(
|
||||||
|
electronApplication,
|
||||||
|
(status) => status?.activeProviderRequests?.child === 1
|
||||||
|
&& status?.processes?.parent?.length === 1
|
||||||
|
&& status?.processes?.child?.length === 1,
|
||||||
|
'Packaged Main proxy child did not become active',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForResilienceProof(electronApplication, predicate, message) {
|
async function waitForResilienceProof(electronApplication, predicate, message) {
|
||||||
@@ -380,7 +395,9 @@ export async function runPackagedProductProof(options) {
|
|||||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||||
const composer = page.getByRole('textbox');
|
const composer = page.getByRole('textbox');
|
||||||
await composer.waitFor({ state: 'visible', timeout: 30_000 });
|
await composer.waitFor({ state: 'visible', timeout: 30_000 });
|
||||||
await composer.fill('Exercise the packaged Main proxy Conversation');
|
const rejectedPrompt = 'Exercise the packaged Main proxy rejection';
|
||||||
|
const retryPrompt = 'Retry the packaged Main proxy Conversation';
|
||||||
|
await composer.fill(rejectedPrompt);
|
||||||
if (!await composer.isEnabled()) throw new Error('Packaged proxy first Conversation composer is disabled');
|
if (!await composer.isEnabled()) throw new Error('Packaged proxy first Conversation composer is disabled');
|
||||||
const beforeSubmitText = await page.locator('body').innerText();
|
const beforeSubmitText = await page.locator('body').innerText();
|
||||||
if (beforeSubmitText.includes('本地编程运行时暂时不可用')
|
if (beforeSubmitText.includes('本地编程运行时暂时不可用')
|
||||||
@@ -389,6 +406,42 @@ export async function runPackagedProductProof(options) {
|
|||||||
}
|
}
|
||||||
await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 });
|
await page.getByRole('button', { name: '发送' }).click({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const rejectedProxy = await waitForProxyProof(
|
||||||
|
electronApplication,
|
||||||
|
(status) => status?.providerCompatibility?.controlledDefiniteRejections === 1,
|
||||||
|
'Packaged Main did not observe the controlled definite rejection',
|
||||||
|
);
|
||||||
|
const draftDeadline = Date.now() + 10_000;
|
||||||
|
while (Date.now() < draftDeadline && await composer.inputValue() !== rejectedPrompt) {
|
||||||
|
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
||||||
|
}
|
||||||
|
const draftRestored = await composer.inputValue() === rejectedPrompt;
|
||||||
|
const rejectionText = await page.locator('body').innerText();
|
||||||
|
const submissionErrorSafe = !rejectionText.includes('controlled definite model rejection')
|
||||||
|
&& !rejectionText.includes('developer role is unsupported')
|
||||||
|
&& !rejectionText.includes('X-Works-Square-AI-Token')
|
||||||
|
&& !rejectionText.includes('Authorization');
|
||||||
|
if (!draftRestored || !submissionErrorSafe) {
|
||||||
|
throw new Error(
|
||||||
|
`Packaged definite rejection did not restore a safe draft: ${JSON.stringify({ draftRestored, submissionErrorSafe })}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await page.getByRole('button', { name: '重试' }).count()) {
|
||||||
|
throw new Error('Packaged definite submission rejection was exposed as a runtime recovery error');
|
||||||
|
}
|
||||||
|
await composer.fill(retryPrompt);
|
||||||
|
const sendButton = page.getByRole('button', { name: '发送' });
|
||||||
|
const retryDeadline = Date.now() + 10_000;
|
||||||
|
while (Date.now() < retryDeadline && !await sendButton.isEnabled()) {
|
||||||
|
await new Promise((resolveWait) => setTimeout(resolveWait, 100));
|
||||||
|
}
|
||||||
|
const retryAfterEditEnabled = await sendButton.isEnabled();
|
||||||
|
if (!retryAfterEditEnabled) {
|
||||||
|
const bodyText = await page.locator('body').innerText();
|
||||||
|
throw new Error(`Packaged proxy retry stayed disabled after editing: ${bodyText}`);
|
||||||
|
}
|
||||||
|
await sendButton.click({ timeout: 30_000 });
|
||||||
|
|
||||||
let proxyStatus;
|
let proxyStatus;
|
||||||
try {
|
try {
|
||||||
proxyStatus = await waitForActiveProxyProof(electronApplication);
|
proxyStatus = await waitForActiveProxyProof(electronApplication);
|
||||||
@@ -590,11 +643,16 @@ export async function runPackagedProductProof(options) {
|
|||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
setup: proxyStart.proxy,
|
setup: proxyStart.proxy,
|
||||||
|
rejected: rejectedProxy.proxy,
|
||||||
active: proxyStatus.proxy,
|
active: proxyStatus.proxy,
|
||||||
completed: proxyFinish.proxy,
|
completed: proxyFinish.proxy,
|
||||||
ui: {
|
ui: {
|
||||||
firstConversationEditable: true,
|
firstConversationEditable: true,
|
||||||
inputSubmitted: true,
|
definiteRejectionObserved: true,
|
||||||
|
draftRestored,
|
||||||
|
submissionErrorSafe,
|
||||||
|
retryAfterEditEnabled,
|
||||||
|
retrySubmitted: true,
|
||||||
runtimeUnavailableBanner: false,
|
runtimeUnavailableBanner: false,
|
||||||
permanentRecovering: false,
|
permanentRecovering: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -739,12 +739,10 @@ export function createCodingConversationStore(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let attachmentsPrepared = !input.prepareAttachments;
|
|
||||||
try {
|
try {
|
||||||
if (input.prepareAttachments) {
|
if (input.prepareAttachments) {
|
||||||
submittedDraft.attachments = (await input.prepareAttachments())
|
submittedDraft.attachments = (await input.prepareAttachments())
|
||||||
.map((attachment) => ({ ...attachment }));
|
.map((attachment) => ({ ...attachment }));
|
||||||
attachmentsPrepared = true;
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
||||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||||
@@ -804,7 +802,6 @@ export function createCodingConversationStore(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const failure = errorDetails(error);
|
const failure = errorDetails(error);
|
||||||
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
|
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
|
||||||
const preSubmitFailure = !attachmentsPrepared;
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
||||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||||
@@ -819,7 +816,6 @@ export function createCodingConversationStore(
|
|||||||
const nextEntry = {
|
const nextEntry = {
|
||||||
...currentEntry,
|
...currentEntry,
|
||||||
reducer,
|
reducer,
|
||||||
error: preSubmitFailure ? currentEntry.error : failure.message,
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
entriesByConversationId: {
|
entriesByConversationId: {
|
||||||
|
|||||||
@@ -519,6 +519,49 @@ describe('CodingChatPanel first Conversation', () => {
|
|||||||
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reenables the same Conversation after a definite prompt rejection', async () => {
|
||||||
|
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||||
|
projectApi.config.mockResolvedValue({ project, config });
|
||||||
|
projectApi.conversations.mockResolvedValue([conversation]);
|
||||||
|
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||||
|
conversationApi.recover.mockResolvedValue(undefined);
|
||||||
|
conversationApi.submit
|
||||||
|
.mockRejectedValueOnce(new Error('所选模型当前不可用,请重新选择。'))
|
||||||
|
.mockImplementationOnce(async (input: {
|
||||||
|
conversationId: string;
|
||||||
|
clientRequestId: string;
|
||||||
|
mode: 'prompt';
|
||||||
|
}) => ({
|
||||||
|
accepted: true,
|
||||||
|
conversationId: input.conversationId,
|
||||||
|
clientRequestId: input.clientRequestId,
|
||||||
|
runId: 'run-retry',
|
||||||
|
mode: input.mode,
|
||||||
|
}));
|
||||||
|
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||||
|
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||||
|
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||||
|
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||||
|
render(<CodingChatPanel />);
|
||||||
|
|
||||||
|
const textbox = await screen.findByRole('textbox');
|
||||||
|
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId)
|
||||||
|
.toBe(conversation.id));
|
||||||
|
fireEvent.change(textbox, { target: { value: 'First prompt' } });
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(textbox).toHaveValue('First prompt'));
|
||||||
|
expect(await screen.findByText('所选模型当前不可用,请重新选择。')).toBeInTheDocument();
|
||||||
|
expect(codingConversationStore.getState().entriesByConversationId[conversation.id]?.error)
|
||||||
|
.toBeNull();
|
||||||
|
expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument();
|
||||||
|
fireEvent.change(textbox, { target: { value: 'Retry prompt' } });
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeEnabled());
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||||
|
await waitFor(() => expect(conversationApi.submit).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
it('caps one message at 16 images and uploads at most four concurrently', async () => {
|
it('caps one message at 16 images and uploads at most four concurrently', async () => {
|
||||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||||
projectApi.config.mockResolvedValue({ project, config });
|
projectApi.config.mockResolvedValue({ project, config });
|
||||||
|
|||||||
@@ -465,11 +465,14 @@ describe('coding Conversation store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('restores draft attachments and marks the optimistic node on definite rejection', async () => {
|
it('restores draft attachments and marks the optimistic node on definite rejection', async () => {
|
||||||
const submitPrompt = vi.fn(async () => {
|
const submitPrompt = vi.fn()
|
||||||
throw new AppError('RUNTIME', 'Model unavailable', undefined, {
|
.mockRejectedValueOnce(new AppError('RUNTIME', 'Model unavailable', undefined, {
|
||||||
backendCode: 'CODING_MODEL_UNAVAILABLE',
|
backendCode: 'CODING_MODEL_UNAVAILABLE',
|
||||||
});
|
}))
|
||||||
});
|
.mockImplementationOnce(async (input) => acceptance(
|
||||||
|
input.conversationId,
|
||||||
|
input.clientRequestId,
|
||||||
|
));
|
||||||
const store = createCodingConversationStore({
|
const store = createCodingConversationStore({
|
||||||
getSnapshot: vi.fn(),
|
getSnapshot: vi.fn(),
|
||||||
openEvents: vi.fn(),
|
openEvents: vi.fn(),
|
||||||
@@ -477,11 +480,13 @@ describe('coding Conversation store', () => {
|
|||||||
createId: ids(),
|
createId: ids(),
|
||||||
});
|
});
|
||||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||||
|
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
|
||||||
store.getState().setDraft('conversation-a', 'Try again', [{
|
store.getState().setDraft('conversation-a', 'Try again', [{
|
||||||
attachmentId: 'attachment-1',
|
attachmentId: 'attachment-1',
|
||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
previewUrl: 'blob:preview',
|
previewUrl: 'blob:preview',
|
||||||
}]);
|
}]);
|
||||||
|
store.getState().setDraft('conversation-b', 'Other conversation draft');
|
||||||
|
|
||||||
await expect(store.getState().submitPrompt({
|
await expect(store.getState().submitPrompt({
|
||||||
conversationId: 'conversation-a',
|
conversationId: 'conversation-a',
|
||||||
@@ -494,8 +499,21 @@ describe('coding Conversation store', () => {
|
|||||||
});
|
});
|
||||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||||
.toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' });
|
.toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' });
|
||||||
|
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
|
||||||
|
expect(store.getState().entriesByConversationId['conversation-b']).toMatchObject({
|
||||||
|
loadState: 'live',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
expect(store.getState().draftsByConversationId['conversation-b'].text)
|
||||||
|
.toBe('Other conversation draft');
|
||||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||||
.toMatchObject({ id: 'node-1', status: 'error' });
|
.toMatchObject({ id: 'node-1', status: 'error' });
|
||||||
|
|
||||||
|
store.getState().setDraft('conversation-a', 'Retry now');
|
||||||
|
await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
|
||||||
|
expect(submitPrompt).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.getState().requestsByConversationId['conversation-a']['request-2'])
|
||||||
|
.toMatchObject({ status: 'accepted' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => {
|
it('keeps attachment preparation failures out of runtime recovery and permits a direct retry', async () => {
|
||||||
@@ -574,6 +592,7 @@ describe('coding Conversation store', () => {
|
|||||||
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
|
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
|
||||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||||
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
|
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
|
||||||
|
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
|
||||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||||
.toMatchObject({ id: 'node-1', status: 'optimistic' });
|
.toMatchObject({ id: 'node-1', status: 'optimistic' });
|
||||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ describe('Pi Provider catalog', () => {
|
|||||||
'google-generative-ai',
|
'google-generative-ai',
|
||||||
'openai-completions',
|
'openai-completions',
|
||||||
]);
|
]);
|
||||||
|
expect(catalog.descriptors.slice(0, 4).map((descriptor) => (
|
||||||
|
descriptor.models[0]?.compat?.supportsDeveloperRole
|
||||||
|
))).toEqual([undefined, undefined, undefined, undefined]);
|
||||||
|
expect(catalog.descriptors.at(-1)?.models[0]?.compat?.supportsDeveloperRole).toBeUndefined();
|
||||||
expect(catalog.descriptors.at(-1)?.models[0]?.compat).toEqual({
|
expect(catalog.descriptors.at(-1)?.models[0]?.compat).toEqual({
|
||||||
thinkingFormat: 'openrouter',
|
thinkingFormat: 'openrouter',
|
||||||
sessionAffinityFormat: 'openrouter',
|
sessionAffinityFormat: 'openrouter',
|
||||||
@@ -105,7 +109,10 @@ describe('Pi Provider catalog', () => {
|
|||||||
input: ['text', 'image'],
|
input: ['text', 'image'],
|
||||||
contextWindow: 1_000_000,
|
contextWindow: 1_000_000,
|
||||||
maxOutputTokens: 65_536,
|
maxOutputTokens: 65_536,
|
||||||
|
compat: { supportsDeveloperRole: false },
|
||||||
});
|
});
|
||||||
|
expect(catalog.modelsFile.providers[descriptor.runtimeProviderId]?.models[0]?.compat)
|
||||||
|
.toMatchObject({ supportsDeveloperRole: false });
|
||||||
expect(serialized).not.toContain('private-tenant-header');
|
expect(serialized).not.toContain('private-tenant-header');
|
||||||
expect(serialized).not.toContain('OLD_GATEWAY_TOKEN');
|
expect(serialized).not.toContain('OLD_GATEWAY_TOKEN');
|
||||||
|
|
||||||
@@ -146,6 +153,7 @@ describe('Pi Provider catalog', () => {
|
|||||||
expect(resolveCredential).not.toHaveBeenCalled();
|
expect(resolveCredential).not.toHaveBeenCalled();
|
||||||
expect(Object.values(projection.env)).toContain('current-worker-host-token');
|
expect(Object.values(projection.env)).toContain('current-worker-host-token');
|
||||||
expect(Object.values(projection.env)).not.toContain('stale-stored-host-token');
|
expect(Object.values(projection.env)).not.toContain('stale-stored-host-token');
|
||||||
|
expect(descriptor.models[0]?.compat?.supportsDeveloperRole).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses account-scoped model capability metadata and rejects unavailable models', () => {
|
it('uses account-scoped model capability metadata and rejects unavailable models', () => {
|
||||||
|
|||||||
28
tests/unit/pi-release-proof-wiring.test.ts
Normal file
28
tests/unit/pi-release-proof-wiring.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('Pi packaged release proof wiring', () => {
|
||||||
|
it('keeps extension and proxy Provider scenarios assigned to their own proof entrypoints', async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
resolve('electron/coding-runtime/pi/release-proof.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const extensionStart = source.indexOf('export async function runFinalAsarExtensionProof');
|
||||||
|
const proxyStart = source.indexOf('export async function startFinalAsarProxyCompositionProof');
|
||||||
|
const resilienceStart = source.indexOf('export async function startFinalAsarResilienceProof');
|
||||||
|
|
||||||
|
expect(extensionStart).toBeGreaterThan(-1);
|
||||||
|
expect(proxyStart).toBeGreaterThan(extensionStart);
|
||||||
|
expect(resilienceStart).toBeGreaterThan(proxyStart);
|
||||||
|
expect(source.slice(extensionStart, proxyStart))
|
||||||
|
.toContain("startLocalProofProvider('subagent')");
|
||||||
|
expect(source.slice(extensionStart, proxyStart))
|
||||||
|
.not.toContain("startLocalProofProvider('submit-rejection')");
|
||||||
|
expect(source.slice(proxyStart, resilienceStart))
|
||||||
|
.toContain("startLocalProofProvider('submit-rejection')");
|
||||||
|
expect(source.slice(proxyStart, resilienceStart))
|
||||||
|
.not.toContain("startLocalProofProvider('subagent')");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user