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?: {
|
||||
thinkingFormat?: 'deepseek' | 'openrouter';
|
||||
sessionAffinityFormat?: 'openrouter';
|
||||
supportsDeveloperRole?: boolean;
|
||||
requiresReasoningContentOnAssistantMessages?: boolean;
|
||||
};
|
||||
thinkingLevelMap?: Partial<Record<ProductModelRef['thinkingLevel'], string | null>>;
|
||||
@@ -291,6 +292,7 @@ function modelDescriptor(
|
||||
summaries: readonly ModelSummary[],
|
||||
compat: PiProviderModelDescriptor['compat'],
|
||||
backendModels: Map<string, ProviderModelEntry>,
|
||||
enforcedCompat: PiProviderModelDescriptor['compat'],
|
||||
): PiProviderModelDescriptor {
|
||||
const summary = summaries.find((candidate) => (
|
||||
candidate.id === modelId
|
||||
@@ -320,8 +322,8 @@ function modelDescriptor(
|
||||
|| backend?.reasoning === true,
|
||||
...(contextWindow ? { contextWindow } : {}),
|
||||
...(maxOutputTokens ? { maxOutputTokens } : {}),
|
||||
...(compat || profile?.pi?.compat
|
||||
? { compat: { ...compat, ...profile?.pi?.compat } }
|
||||
...(compat || profile?.pi?.compat || enforcedCompat
|
||||
? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } }
|
||||
: {}),
|
||||
...(profile?.pi?.thinkingLevelMap
|
||||
? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } }
|
||||
@@ -367,12 +369,19 @@ function descriptorForAccount(
|
||||
);
|
||||
}
|
||||
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(
|
||||
account,
|
||||
modelId,
|
||||
summaries,
|
||||
mapping.compat,
|
||||
backendModelEntries(account),
|
||||
enforcedCompat,
|
||||
));
|
||||
if (models.length === 0) {
|
||||
throw new PiProviderConfigError(
|
||||
|
||||
@@ -36,7 +36,7 @@ import { PiProcessBudget, PiWorkerPool, type PiWorkerPoolEvent } from './worker-
|
||||
import { PiProjectWriteLeaseCoordinator, type PiProjectWriteLease } from './write-lease';
|
||||
|
||||
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 ProofMilestoneSource = 'main.telemetry' | 'pi.agent_start' | 'pi.assistant_message_start';
|
||||
|
||||
@@ -56,6 +56,8 @@ type ProviderRequest = {
|
||||
role: ProofWorkerRole;
|
||||
toolNames: string[];
|
||||
hasToolResult: boolean;
|
||||
firstMessageRole: string | null;
|
||||
outcome: 'accepted' | 'controlled_rejection' | 'developer_role_rejection';
|
||||
};
|
||||
|
||||
type HeldProviderResponse = {
|
||||
@@ -66,6 +68,7 @@ type HeldProviderResponse = {
|
||||
type LocalProofProvider = {
|
||||
baseUrl: string;
|
||||
requests: ProviderRequest[];
|
||||
roleContract: { developerRejected: boolean; systemAccepted: boolean } | null;
|
||||
activeCounts(): { parent: number; child: number };
|
||||
releaseChildren(): void;
|
||||
releaseParents(): void;
|
||||
@@ -184,6 +187,13 @@ export interface PiReleaseProxyCompositionStatus {
|
||||
logs: boolean;
|
||||
diagnostics: boolean;
|
||||
};
|
||||
providerCompatibility: {
|
||||
roleContract: { developerRejected: boolean; systemAccepted: boolean };
|
||||
requestRoles: string[];
|
||||
developerRoleRequests: number;
|
||||
systemRoleRequests: number;
|
||||
controlledDefiniteRejections: number;
|
||||
};
|
||||
realTurnVerified: false;
|
||||
}
|
||||
|
||||
@@ -196,6 +206,8 @@ export interface PiReleaseProxyCompositionProof extends PiReleaseProxyCompositio
|
||||
runStatus: string;
|
||||
userInputProjected: boolean;
|
||||
parentResponseProjected: boolean;
|
||||
definiteRejectionObserved: boolean;
|
||||
retryAccepted: boolean;
|
||||
};
|
||||
currentHostTokenUsedBy: ProofWorkerRole[];
|
||||
released: { workers: number };
|
||||
@@ -247,6 +259,7 @@ export interface PiReleaseResilienceProof extends PiReleaseResilienceStatus {
|
||||
const PROOF_ACCOUNT_ID = 'release-proof-account';
|
||||
const PROOF_AGENT_ID = 'release-proof-agent';
|
||||
const PROOF_MODEL_ID = 'release-proof-model';
|
||||
const PROXY_PROOF_MODEL_ID = 'deepseek-v4-pro';
|
||||
const PROOF_PROVIDER_FIRST_EVENT_DELAY_MS = 75;
|
||||
const EXPECTED_TURN_MILESTONES: readonly ProofMilestone[] = [
|
||||
'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(
|
||||
response: ServerResponse,
|
||||
model: string,
|
||||
@@ -422,6 +448,7 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
||||
const held = new Set<HeldProviderResponse>();
|
||||
let closed = false;
|
||||
let closeFlight: Promise<void> | null = null;
|
||||
let controlledSubmitRejectionIssued = false;
|
||||
const server: Server = createServer(async (request, response) => {
|
||||
response.once('error', () => undefined);
|
||||
try {
|
||||
@@ -431,10 +458,43 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
||||
}
|
||||
const body = await readRequestBody(request);
|
||||
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 role: ProofWorkerRole = toolNames.includes('subagent') ? 'parent' : 'child';
|
||||
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 (role === 'child') {
|
||||
@@ -470,12 +530,12 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'subagent' && role === 'parent' && !toolResult) {
|
||||
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent' && !toolResult) {
|
||||
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||
respondWithSubagentCall(response, model);
|
||||
return;
|
||||
}
|
||||
if (mode === 'subagent' && role === 'parent') {
|
||||
if ((mode === 'subagent' || mode === 'submit-rejection') && role === 'parent') {
|
||||
await delay(PROOF_PROVIDER_FIRST_EVENT_DELAY_MS);
|
||||
respondWithText(response, model, 'REAL_PARENT_COMPLETE');
|
||||
return;
|
||||
@@ -506,6 +566,33 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
||||
if (!address || typeof address === 'string') {
|
||||
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) => {
|
||||
for (const entry of [...held]) {
|
||||
if (role && entry.role !== role) continue;
|
||||
@@ -514,8 +601,9 @@ async function startLocalProofProvider(mode: ProofProviderMode): Promise<LocalPr
|
||||
}
|
||||
};
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
baseUrl,
|
||||
requests,
|
||||
roleContract,
|
||||
activeCounts: () => ({
|
||||
parent: [...held].filter(({ role }) => role === 'parent').length,
|
||||
child: [...held].filter(({ role }) => role === 'child').length,
|
||||
@@ -782,13 +870,14 @@ async function inspectWindowsPiProcesses(hostToken: string): Promise<{
|
||||
return { supported: true, processes };
|
||||
}
|
||||
|
||||
function proxyProviderAccount(baseUrl: string): ProviderAccount {
|
||||
function proxyProviderAccount(baseUrl: string, modelId = PROOF_MODEL_ID): ProviderAccount {
|
||||
return {
|
||||
...providerAccount(baseUrl),
|
||||
model: modelId,
|
||||
label: 'Authenticated Host proxy release proof',
|
||||
metadata: {
|
||||
worksSquareCredentialMode: 'works_square_ai_gateway_proxy',
|
||||
customModels: [PROOF_MODEL_ID],
|
||||
customModels: [modelId],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -811,6 +900,24 @@ async function proxyCompositionStatus(
|
||||
directoryContainsValue(logsDirectory, run.hostToken),
|
||||
]);
|
||||
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
|
||||
.filter(({ role }) => role === 'parent')
|
||||
.map(({ processId }) => processId)
|
||||
@@ -832,6 +939,7 @@ async function proxyCompositionStatus(
|
||||
logs: !logsContainToken,
|
||||
diagnostics: !JSON.stringify(diagnostics).includes(run.hostToken),
|
||||
},
|
||||
providerCompatibility,
|
||||
realTurnVerified: false,
|
||||
};
|
||||
}
|
||||
@@ -1256,7 +1364,7 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
||||
if (proxyCompositionRun) throw new Error('PI proxy composition proof is already running');
|
||||
const hostToken = input.hostToken.trim();
|
||||
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;
|
||||
try {
|
||||
seedWorksSquareAIGatewayCredential({
|
||||
@@ -1264,7 +1372,10 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
||||
oneApiBaseUrl: provider.baseUrl,
|
||||
});
|
||||
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);
|
||||
const project = await input.composition.projects.createProject({ projectPath: input.projectPath });
|
||||
projectId = project.project.id;
|
||||
@@ -1275,8 +1386,8 @@ export async function startFinalAsarProxyCompositionProof(input: {
|
||||
name: 'Packaged proxy proof agent',
|
||||
model: {
|
||||
accountId: PROOF_ACCOUNT_ID,
|
||||
modelId: PROOF_MODEL_ID,
|
||||
thinkingLevel: 'medium',
|
||||
modelId: PROXY_PROOF_MODEL_ID,
|
||||
thinkingLevel: 'high',
|
||||
},
|
||||
modelResolution: 'resolved',
|
||||
responsibility: {
|
||||
@@ -1366,6 +1477,15 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
||||
if (!binding?.piSessionId || !binding.sessionKey) {
|
||||
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 = {
|
||||
argv: activeStatus.tokenSafety.argv && finalStatus.tokenSafety.argv,
|
||||
modelsJson: activeStatus.tokenSafety.modelsJson && finalStatus.tokenSafety.modelsJson,
|
||||
@@ -1387,8 +1507,10 @@ export async function finishFinalAsarProxyCompositionProof(): Promise<PiReleaseP
|
||||
bindingEstablished: true,
|
||||
workerStatus: snapshot.worker.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'),
|
||||
definiteRejectionObserved: finalStatus.providerCompatibility.controlledDefiniteRejections === 1,
|
||||
retryAccepted: finalStatus.providerCompatibility.systemRoleRequests >= 2,
|
||||
},
|
||||
currentHostTokenUsedBy,
|
||||
};
|
||||
|
||||
@@ -143,6 +143,11 @@ function assertProxyStatus(status) {
|
||||
|| status?.processes?.supported !== true
|
||||
|| status?.processes?.parent?.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)) {
|
||||
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?.userInputProjected !== 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?.released?.workers !== 0
|
||||
|| 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;
|
||||
let latest;
|
||||
while (Date.now() < deadline) {
|
||||
latest = await evaluateProof(electronApplication, 'proxy.status');
|
||||
if (latest?.proxy?.activeProviderRequests?.child === 1
|
||||
&& latest?.proxy?.processes?.parent?.length === 1
|
||||
&& latest?.proxy?.processes?.child?.length === 1) {
|
||||
return latest;
|
||||
}
|
||||
if (predicate(latest?.proxy)) return latest;
|
||||
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) {
|
||||
@@ -380,7 +395,9 @@ export async function runPackagedProductProof(options) {
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const composer = page.getByRole('textbox');
|
||||
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');
|
||||
const beforeSubmitText = await page.locator('body').innerText();
|
||||
if (beforeSubmitText.includes('本地编程运行时暂时不可用')
|
||||
@@ -389,6 +406,42 @@ export async function runPackagedProductProof(options) {
|
||||
}
|
||||
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;
|
||||
try {
|
||||
proxyStatus = await waitForActiveProxyProof(electronApplication);
|
||||
@@ -590,11 +643,16 @@ export async function runPackagedProductProof(options) {
|
||||
},
|
||||
proxy: {
|
||||
setup: proxyStart.proxy,
|
||||
rejected: rejectedProxy.proxy,
|
||||
active: proxyStatus.proxy,
|
||||
completed: proxyFinish.proxy,
|
||||
ui: {
|
||||
firstConversationEditable: true,
|
||||
inputSubmitted: true,
|
||||
definiteRejectionObserved: true,
|
||||
draftRestored,
|
||||
submissionErrorSafe,
|
||||
retryAfterEditEnabled,
|
||||
retrySubmitted: true,
|
||||
runtimeUnavailableBanner: false,
|
||||
permanentRecovering: false,
|
||||
},
|
||||
|
||||
@@ -739,12 +739,10 @@ export function createCodingConversationStore(
|
||||
};
|
||||
});
|
||||
|
||||
let attachmentsPrepared = !input.prepareAttachments;
|
||||
try {
|
||||
if (input.prepareAttachments) {
|
||||
submittedDraft.attachments = (await input.prepareAttachments())
|
||||
.map((attachment) => ({ ...attachment }));
|
||||
attachmentsPrepared = true;
|
||||
set((state) => {
|
||||
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||
@@ -804,7 +802,6 @@ export function createCodingConversationStore(
|
||||
} catch (error) {
|
||||
const failure = errorDetails(error);
|
||||
const uncertain = failure.code === 'CODING_REQUEST_UNCERTAIN';
|
||||
const preSubmitFailure = !attachmentsPrepared;
|
||||
set((state) => {
|
||||
const currentEntry = state.entriesByConversationId[input.conversationId] ?? emptyEntry();
|
||||
const requests = state.requestsByConversationId[input.conversationId] ?? {};
|
||||
@@ -819,7 +816,6 @@ export function createCodingConversationStore(
|
||||
const nextEntry = {
|
||||
...currentEntry,
|
||||
reducer,
|
||||
error: preSubmitFailure ? currentEntry.error : failure.message,
|
||||
};
|
||||
return {
|
||||
entriesByConversationId: {
|
||||
|
||||
@@ -519,6 +519,49 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
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 () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
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 () => {
|
||||
const submitPrompt = vi.fn(async () => {
|
||||
throw new AppError('RUNTIME', 'Model unavailable', undefined, {
|
||||
const submitPrompt = vi.fn()
|
||||
.mockRejectedValueOnce(new AppError('RUNTIME', 'Model unavailable', undefined, {
|
||||
backendCode: 'CODING_MODEL_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
}))
|
||||
.mockImplementationOnce(async (input) => acceptance(
|
||||
input.conversationId,
|
||||
input.clientRequestId,
|
||||
));
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
@@ -477,11 +480,13 @@ describe('coding Conversation store', () => {
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
|
||||
store.getState().setDraft('conversation-a', 'Try again', [{
|
||||
attachmentId: 'attachment-1',
|
||||
mime: 'image/png',
|
||||
previewUrl: 'blob:preview',
|
||||
}]);
|
||||
store.getState().setDraft('conversation-b', 'Other conversation draft');
|
||||
|
||||
await expect(store.getState().submitPrompt({
|
||||
conversationId: 'conversation-a',
|
||||
@@ -494,8 +499,21 @@ describe('coding Conversation store', () => {
|
||||
});
|
||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||
.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])
|
||||
.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 () => {
|
||||
@@ -574,6 +592,7 @@ describe('coding Conversation store', () => {
|
||||
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
|
||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
|
||||
expect(store.getState().entriesByConversationId['conversation-a'].error).toBeNull();
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||
.toMatchObject({ id: 'node-1', status: 'optimistic' });
|
||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -54,6 +54,10 @@ describe('Pi Provider catalog', () => {
|
||||
'google-generative-ai',
|
||||
'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({
|
||||
thinkingFormat: 'openrouter',
|
||||
sessionAffinityFormat: 'openrouter',
|
||||
@@ -105,7 +109,10 @@ describe('Pi Provider catalog', () => {
|
||||
input: ['text', 'image'],
|
||||
contextWindow: 1_000_000,
|
||||
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('OLD_GATEWAY_TOKEN');
|
||||
|
||||
@@ -146,6 +153,7 @@ describe('Pi Provider catalog', () => {
|
||||
expect(resolveCredential).not.toHaveBeenCalled();
|
||||
expect(Object.values(projection.env)).toContain('current-worker-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', () => {
|
||||
|
||||
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