diff --git a/apps/desktop/electron/main/plugin-runtime.ts b/apps/desktop/electron/main/plugin-runtime.ts index 43e64bb870..b296941534 100644 --- a/apps/desktop/electron/main/plugin-runtime.ts +++ b/apps/desktop/electron/main/plugin-runtime.ts @@ -391,8 +391,6 @@ export type PluginHostServices = { exitCode: number; /** Hex form for Windows hard-fault codes, when applicable. */ exitCodeHex?: string; - /** The plugin's own last output line, when it printed one before dying. */ - lastOutput?: string; }) => void; /** Fired when a resident service changes supervision state. */ onServiceChange?: (status: PluginServiceStatus) => void; @@ -633,14 +631,6 @@ const SERVICE_RESTART_MAX_DELAY_MS = 30_000; const MAX_SERVICE_RESTARTS = 5; /** A host process that stays up this long is healthy; the backoff resets. */ const SERVICE_HEALTHY_MS = 60_000; -/** Trailing plugin-output lines kept for a crash report, newest last. */ -const PLUGIN_LOG_TAIL_LINES = 3; -/** Per-line cap, so a plugin that printed a megabyte cannot fill the record. */ -const PLUGIN_LOG_TAIL_LINE_CHARS = 400; - -/** One line a plugin host process wrote to its own stdout/stderr. */ -type PluginLogLine = { level: string; message: string }; - /** Convert Electron's signed Windows status into the process's unsigned code. */ function childExitUnsigned(code: number): number { if (!Number.isFinite(code)) return code; @@ -669,59 +659,6 @@ function childExitLabel(code: number): string { return `exit code ${unsigned}${hex ? ` (${hex})` : ""}`; } -function rememberPluginLogLine( - tail: PluginLogLine[], - level: string, - message: string, -): void { - if (!message.trim()) return; - tail.push({ level, message: message.slice(0, PLUGIN_LOG_TAIL_LINE_CHARS) }); - if (tail.length > PLUGIN_LOG_TAIL_LINES) tail.shift(); -} - -/** Append arbitrary stream chunks while retaining complete logical lines. */ -function appendPluginLogChunk( - tail: PluginLogLine[], - fragments: Map, - level: string, - chunk: string, -): void { - const normalized = `${fragments.get(level) ?? ""}${chunk}`.replace(/\r\n?/g, "\n"); - const lines = normalized.split("\n"); - fragments.set(level, lines.pop() ?? ""); - for (const line of lines) rememberPluginLogLine(tail, level, line); -} - -/** Include a final unterminated line before a crash report snapshots the tail. */ -function flushPluginLogTail( - tail: PluginLogLine[], - fragments: Map, -): void { - for (const [level, fragment] of fragments) { - rememberPluginLogLine(tail, level, fragment); - } - fragments.clear(); -} - -/** The newest plugin-output line, flattened for a one-line report. */ -function lastLogLine( - tail: readonly PluginLogLine[] | undefined, -): string | undefined { - const newest = tail?.[tail.length - 1]; - if (!newest?.message) return undefined; - const text = newest.message.replace(/\s+/g, " ").trim(); - return text ? `${newest.level}: ${text}` : undefined; -} - -/** The exit code plus the plugin's last words, when it left any. */ -function childExitDetail( - code: number, - tail: readonly PluginLogLine[] | undefined, -): string { - const label = childExitLabel(code); - const lastOutput = lastLogLine(tail); - return lastOutput ? `${label}; last output: ${lastOutput}` : label; -} /** Bus payloads are messages, not file transfers. */ const MAX_BUS_PAYLOAD_BYTES = 64 * 1024; /** A plugin may hold at most this many live subscriptions. */ @@ -780,10 +717,6 @@ type LoadedPlugin = { pending: Map; nextCallId: number; disposing: boolean; - /** Newest host-process output lines, so a crash report can quote them. */ - logTail: PluginLogLine[]; - /** Unterminated stdout/stderr fragments waiting for their newline. */ - logFragments: Map; }; type PluginApiError = Error & { code?: string }; @@ -1803,8 +1736,6 @@ export class PluginRuntime { pending: new Map(), nextCallId: 1, disposing: false, - logTail: [], - logFragments: new Map(), }; this.loaded.set(manifest.id, loaded); @@ -1812,9 +1743,6 @@ export class PluginRuntime { child.onExit((code) => this.handleChildExit(loaded, code)); child.onLog?.((level, message) => { if (!message) return; - // Stream data events are arbitrary chunks, not logical lines. Keep the - // audit shape unchanged, but only put complete lines in the crash tail. - appendPluginLogChunk(loaded.logTail, loaded.logFragments, level, message); this.services.audit?.({ pluginId: manifest.id, api: "plugin.stdio", @@ -2827,15 +2755,11 @@ export class PluginRuntime { if (loaded.disposing) return; if (this.loaded.get(loaded.manifest.id) !== loaded) return; const pluginId = loaded.manifest.id; - // The exit code is the whole diagnosis for a crash report: a Windows hard - // fault (0xC0000005 and friends) and a plugin's own `process.exit(1)` are - // different bugs, and only this number tells them apart. The plugin's last - // output line rides along because a plugin that died on a thrown error - // usually printed the reason first, and the report a user can paste is the - // one place that evidence has to survive. - flushPluginLogTail(loaded.logTail, loaded.logFragments); - const detail = childExitDetail(code, loaded.logTail); - const lastOutput = lastLogLine(loaded.logTail); + // The exit code is the diagnosis for a crash report: a Windows hard fault + // (0xC0000005 and friends) and a plugin's own process.exit(1) are different + // bugs. Do not copy plugin stdout/stderr into user-visible errors or crash + // audit records because plugin output may contain workspace data or secrets. + const detail = childExitLabel(code); const exitCode = childExitUnsigned(code); const exitCodeHex = childExitHex(code); this.rejectPending( @@ -2855,7 +2779,6 @@ export class PluginRuntime { errorCode: "PLUGIN_CRASHED", exitCode, ...(exitCodeHex ? { exitCodeHex } : {}), - ...(lastOutput ? { message: lastOutput } : {}), ts: Date.now(), }); this.services.showToast(`Plugin stopped unexpectedly: ${loaded.manifest.name}`, "error"); @@ -2864,7 +2787,6 @@ export class PluginRuntime { name: loaded.manifest.name, exitCode, ...(exitCodeHex ? { exitCodeHex } : {}), - ...(lastOutput ? { lastOutput } : {}), }); this.superviseCrash(loaded, code); } diff --git a/apps/desktop/electron/main/services/plugin-services.ts b/apps/desktop/electron/main/services/plugin-services.ts index 4cfdadf2fb..376dad7920 100644 --- a/apps/desktop/electron/main/services/plugin-services.ts +++ b/apps/desktop/electron/main/services/plugin-services.ts @@ -411,14 +411,13 @@ export function createPluginServices({ }, // A plugin host process dying is contained: contributions are already // deregistered by the runtime, we only have to tell the user and the UI. - onPluginCrash: ({ pluginId, exitCode, exitCodeHex, lastOutput }) => { + onPluginCrash: ({ pluginId, exitCode, exitCodeHex }) => { logger.app("plugin", "error", "plugin host process crashed", { pluginId, code: "PLUGIN_CRASHED", data: { exitCode, ...(exitCodeHex ? { exitCodeHex } : {}), - ...(lastOutput ? { lastOutput } : {}), }, }); // No toast here: the runtime already raised one through `showToast` on the diff --git a/apps/desktop/src/components/ContextMenu.tsx b/apps/desktop/src/components/ContextMenu.tsx index e6d2493c9d..748d9abd33 100644 --- a/apps/desktop/src/components/ContextMenu.tsx +++ b/apps/desktop/src/components/ContextMenu.tsx @@ -85,6 +85,15 @@ function pointForEvent(event: ReactMouseEvent): ContextMenuPoint { * starts in this row still belongs to Copy here. */ function snapshotSelection(root: EventTarget): string { + // Textarea ranges are not represented by the document Selection. + const editor = document.activeElement; + if ( + root instanceof Node && + editor instanceof HTMLTextAreaElement && + root.contains(editor) + ) { + return editor.value.slice(editor.selectionStart, editor.selectionEnd); + } const live = window.getSelection(); if (!live || live.rangeCount === 0 || live.isCollapsed) return ""; const text = live.toString(); diff --git a/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts b/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts index 4be4493820..39069fd6b1 100644 --- a/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts +++ b/apps/desktop/src/features/chat/composer/hooks/useComposerDraft.ts @@ -164,6 +164,38 @@ export function useComposerDraft({ } return map; }, [activeFileReferences]); + // Native undo restores chip DOM, but deletion has already removed its metadata. + const deletedReferencesRef = useRef(new Map()); + useEffect(() => { + deletedReferencesRef.current.clear(); + // Observe every transition, including A -> B -> A in one React batch. + return useAppStore.subscribe((state, previous) => { + if ((state.workspace?.path ?? "") !== (previous.workspace?.path ?? "")) { + deletedReferencesRef.current.clear(); + } + }); + }, [draftKey]); + + const reconcileEditorReferences = (text: string) => { + const current = fileReferencesRef.current; + const next = current.filter((reference) => { + if (!reference.token || text.includes(reference.token)) return true; + deletedReferencesRef.current.set(reference.token, reference); + return false; + }); + // Only recover an actual restored chip, never a pasted private-use character. + for (const chip of ref.current?.querySelectorAll(".composer-chip") ?? []) { + const token = chip.dataset.token ?? ""; + const reference = deletedReferencesRef.current.get(token); + if (!reference || !text.includes(token)) continue; + if (!next.some((item) => item.token === token)) next.push(reference); + deletedReferencesRef.current.delete(token); + } + if (next.length === current.length && next.every((reference, index) => reference === current[index])) return; + fileReferencesRef.current = next; + setFileReferences(next); + }; + const referenceByTokenRef = useRef(referenceByToken); referenceByTokenRef.current = referenceByToken; const imagePreview = useComposerImagePreview({ references: fileReferences, value, sessionId: referenceSessionId, editorRef: ref }); @@ -273,13 +305,7 @@ export function useComposerDraft({ } valueRef.current = nextValue; setValue(nextValue); - setFileReferences((current) => { - const next = current.filter( - (fileReference) => - !fileReference.token || nextValue.includes(fileReference.token), - ); - return next.length === current.length ? current : next; - }); + reconcileEditorReferences(nextValue); updateCursor(caret); return nextValue; }; @@ -294,13 +320,7 @@ export function useComposerDraft({ editorValueRef.current = nextValue; valueRef.current = nextValue; setValue(nextValue); - setFileReferences((current) => { - const next = current.filter( - (fileReference) => - !fileReference.token || nextValue.includes(fileReference.token), - ); - return next.length === current.length ? current : next; - }); + reconcileEditorReferences(nextValue); updateCursor(start); }; @@ -557,6 +577,7 @@ export function useComposerDraft({ deleteComposerDraft(key); const currentKey = draftKeyForSession(useAppStore.getState().activeSessionId); if (currentKey !== key) return; + deletedReferencesRef.current.clear(); valueRef.current = ""; if (ref.current) paintCurrentDraft(ref.current, ""); setValue(""); diff --git a/apps/desktop/src/features/chat/transcript/MessageRow.tsx b/apps/desktop/src/features/chat/transcript/MessageRow.tsx index 82fc0f9d6a..7902c378ba 100644 --- a/apps/desktop/src/features/chat/transcript/MessageRow.tsx +++ b/apps/desktop/src/features/chat/transcript/MessageRow.tsx @@ -99,12 +99,13 @@ export const MessageRow = memo(function MessageRow({ label: t("chat.messageMenu"), items: userMessageMenuItems({ t, - text: message.content || "", - selectTarget: - event.currentTarget.querySelector(".message-bubble"), - editable: editableUserMessage, + text: editing ? editValue : message.content || "", + selectTarget: event.currentTarget.querySelector( + editing ? ".message-edit-input" : ".message-bubble", + ), + editable: editableUserMessage && !editing, running: isRunning, - revision: showRevisionPager + revision: !editing && showRevisionPager ? { count: revisionCount, active: activeRevision } : null, actions: { copyText, selectText }, diff --git a/apps/desktop/src/features/chat/transcript/TranscriptMenu.tsx b/apps/desktop/src/features/chat/transcript/TranscriptMenu.tsx index 169c28efdf..8edf5fea9a 100644 --- a/apps/desktop/src/features/chat/transcript/TranscriptMenu.tsx +++ b/apps/desktop/src/features/chat/transcript/TranscriptMenu.tsx @@ -85,6 +85,11 @@ export function useChatTextActions() { and the row is already on screen, so nothing needs measuring. */ const selectText = useCallback((element: HTMLElement | null) => { + if (element instanceof HTMLTextAreaElement) { + element.focus(); + element.select(); + return; + } const selection = window.getSelection(); if (!element || !selection) return; const range = document.createRange(); diff --git a/apps/desktop/test/plugin-services.test.mjs b/apps/desktop/test/plugin-services.test.mjs index 02a2063543..2a47798ed2 100644 --- a/apps/desktop/test/plugin-services.test.mjs +++ b/apps/desktop/test/plugin-services.test.mjs @@ -33,12 +33,6 @@ function forkPluginProcess({ entry }) { }, onMessage: (handler) => child.on("message", handler), onExit: (handler) => child.on("exit", (code) => handler(code ?? 0)), - // Mirrors the real spawner: the plugin's own stdout/stderr is what a crash - // report has to be able to quote. - onLog: (handler) => { - child.stdout?.on("data", (chunk) => handler("info", String(chunk).trimEnd())); - child.stderr?.on("data", (chunk) => handler("error", String(chunk).trimEnd())); - }, kill: () => child.kill(), }; } @@ -344,9 +338,6 @@ test("a crashed host process is restarted with backoff and the restart is counte id: "worker", start: async () => { // Die once, right after the broker was told the service is up. - // Die once, right after the broker was told the service is up. - // The line on stderr is the fixture's own "last words", which the - // crash report has to carry (issue #747). if (countStart() === 1) { setTimeout(() => { process.stderr.write("fixture older line\\nfixture service worker died"); @@ -365,14 +356,15 @@ test("a crashed host process is restarted with backoff and the restart is counte const failed = await waitFor(() => runtime.getServiceStates().find((s) => s.state === "failed"), ); - // The exit code is the diagnosis: without it a report says only "it died". + // The exit code is the safe diagnosis: plugin output is not copied into + // user-visible errors or crash audit records. assert.equal(failed.message, "plugin host process exited (exit code 7)"); const crash = await waitFor(() => audits.find((a) => a.api === "plugin.crash"), ); assert.equal(crash.exitCode, 7); - assert.equal(crash.message, "error: fixture service worker died"); + assert.equal("message" in crash, false); const scheduled = await waitFor(() => audits.find((a) => a.api === "plugin.service.restart.scheduled"), diff --git a/docs/adr/0206-ten-provider-retries-and-progress-status.md b/docs/adr/0206-ten-provider-retries-and-progress-status.md index d7220b17a8..3707ff50bf 100644 --- a/docs/adr/0206-ten-provider-retries-and-progress-status.md +++ b/docs/adr/0206-ten-provider-retries-and-progress-status.md @@ -7,6 +7,19 @@ ## Context +### Amendment: successful response boundary (issue #699, 2026-09-20) + +Desktop fault injection reproduced a long task stopping on its eleventh +independent network failure after ten successful recoveries and tool calls. +The budget previously survived successful model responses for the whole user +turn. Both retry classes now reset after a complete successful model response, +including a tool-call response, in the main runtime and builtin subagents. +Partial output, response headers, and phase changes do not reset the counters. +The ten-retry bound, separate classes, cancellation, and failed-request-only +replay remain unchanged. Exhaustion diagnostics read the appropriate counter, +not temporary activity state. This narrows the budget scope in decision 1 +below without introducing a new setting or changing persisted contracts. + PI-Desktop already owns provider retries so request setup and mid-stream failures share one counter and pi-ai does not multiply attempts through a nested retry loop. The current budgets of five rate-limit retries and four diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md index 6cbca8b4da..eaa9d10f19 100644 --- a/docs/spec/03-runtime/02-agent-runtime.md +++ b/docs/spec/03-runtime/02-agent-runtime.md @@ -155,7 +155,7 @@ host-confirmed transition. ### 5d. Bounded provider recovery and diagnostics (D186, D245, D259, D378, ADR 0091, ADR 0128, ADR 0206) Provider request setup and stream delivery are separate failure phases, but -HTTP 429 handling is one logical-turn policy. pi-ai's nested adapter retry is +HTTP 429 handling is one response-recovery policy. pi-ai's nested adapter retry is disabled for this path so the runtime can share one budget across both phases. `PROVIDER_RATE_LIMITED` receives at most ten retries after the initial @@ -184,7 +184,7 @@ server or calculated value is capped at 30 seconds. The runtime captures the failed response status and headers from fetch because pi-ai's ordinary response callback only covers an established response. -Non-429 transient failures share their own bounded logical-turn budget of ten +Non-429 transient failures share their own bounded response-recovery budget of ten retries after the initial attempt, for eleven provider attempts total. The budget is shared by request setup and stream delivery, so a fault that moves between phases cannot reset or multiply it, and it is separate from the 429 budget. It @@ -195,6 +195,15 @@ context, and other non-retryable errors do not enter either provider replay path, and a non-retryable `PROVIDER_ERROR` from a malformed 400/422 request stays terminal. +Both budgets reset after a complete, non-error, non-aborted model response, +including a response that requests tools. The next model request starts with +fresh counters and backoff, even within the same user turn. Receiving HTTP +headers, partial text, or changing failure phase does not reset either budget. +This rule applies to the main session and builtin subagents: a long task with +independent recovered outages must not eventually stop because earlier tool +rounds consumed the budget. One-shot completions still use one bounded budget +for their single response. Persistent failures remain bounded and abortable. + Before surfacing a pre-stream `PROVIDER_ERROR` for HTTP 400/422 whose message ends in `(no body)`, the runtime makes at most one silent repair attempt with the generated output-limit fields removed: `max_tokens`, @@ -241,7 +250,8 @@ When the retry budget is exhausted, the final assistant error and lifecycle `networkSyscall`, `networkHost`, `networkRoute`) and the request correlation (`requestMessages`, `requestBytes`, `compactionGeneration`). For a persistent 429 or non-429 transient failure, -`retryAttempt` is `10`. Credentials and unrestricted response bodies never +`retryAttempt` is `10`, derived from the exhausted error class's budget rather +than temporary retry activity state. Credentials and unrestricted response bodies never enter the event or log. The active-turn status shows the remaining backoff and the retry budget as `Retrying in 0s · attempt 9/10` in English. diff --git a/docs/spec/03-runtime/08-error-codes.md b/docs/spec/03-runtime/08-error-codes.md index 05d449b2ca..ffd638b5ea 100644 --- a/docs/spec/03-runtime/08-error-codes.md +++ b/docs/spec/03-runtime/08-error-codes.md @@ -322,7 +322,11 @@ transient failures — `STREAM_FAILED`, `NETWORK_ERROR`, `TIMEOUT`, and retryabl `PROVIDER_ERROR` such as an upstream gateway 502/503/504 — share their own bounded budget of ten retries after the initial attempt, also counted together across setup and stream, and separate from the 429 budget. Both budgets are -abortable. The 429 path honors `retry-after-ms`, `retry-after` seconds, and +abortable and reset after a complete successful model response, including a +tool-call response, in both the main session and builtin subagents. Headers, +partial output, and phase changes do not replenish them. Terminal exhaustion +reports `retryAttempt: 10` from the applicable budget even after retry activity +cleanup. The 429 path honors `retry-after-ms`, `retry-after` seconds, and HTTP-date headers before client backoff and caps a wait at 30 seconds; the non-429 path applies the same precedence with an 8-second cap and otherwise waits 1, 2, 4, then remains at 8 seconds for later retries. Only the failed diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index dcbb67c58b..65b153c7c1 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -1850,7 +1850,10 @@ message its checkpoint covers. Home / End navigation, Escape / Tab / outside-press / scroll-behind dismissal, and an accessible name (`chat.messageMenu` or `chat.conversationMenu`). Focus returns to whatever the right-click - interrupted. + interrupted. While editing a user message, Copy uses the selected draft text + (or the whole draft if the caret is collapsed), and Select message text selects + the draft. Saved-message Edit, Delete, and revision actions are not offered + until editing ends. ### 8.6 MVP constraints @@ -3797,7 +3800,10 @@ remove a newly created BR only when removing that exact node makes the draft match the requested deletion. Never trim leading newlines or normalize all BRs. Remember proven placeholder nodes weakly so native redo cannot restore them. Explicit line breaks, IME composition, file references, and chip deletion retain -their normal behavior. The input owns and disposes the native event listeners. +their normal behavior. Native undo of chip deletion restores its file-reference metadata +as well as its DOM, so submission and draft caching retain the path. Deleted +reference history is local to the current draft/workspace and is cleared on send; +pasting a private-use character alone must not restore an attachment. The input owns and disposes the native event listeners. ### Dialog long-text containment diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 1f9a9f6b7a..010f2ee41d 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -1470,6 +1470,16 @@ identify the platform validation still needed. `session-switch-performance.test.mjs`); full UI scenario Draft +#### Composer file-reference undo regression + +- Add a file reference to an unsent draft, select the chip, delete it, then + undo and redo with the native editor shortcuts. Undo must restore both the + chip and the file path used by submission; redo must remove both. +- Switch to another chat and back after undo: the other draft stays empty and + the source retains the restored reference. Typing a removed chip's private-use + token as plain text must not restore the attachment. +- Automated by `pnpm test:e2e:composer-paste` (`fileReferenceUndoRedo`). + ### Conversation Top Bar #### E2E-087: Conversation top bar renders on the chat route @@ -3532,7 +3542,9 @@ identify the platform validation still needed. choose Copy. 4) Right-click the assistant turn and choose Copy. 5) Right-click empty space below the last turn and choose Copy conversation. 6) Press Escape on an open menu, then Tab. 7) Right-click - a markdown link in the answer. + a markdown link in the answer. 8) Edit the user message, replace its content, + select a phrase, and right-click Copy. Repeat with a collapsed caret and + Select message text, then cancel editing and copy the saved message. - **Expected**: The user menu lists Copy, Select message text, Edit, and a separated Delete; the assistant menu lists Copy, Select message text, Regenerate, and Branch. Copy writes the live selection in the @@ -3544,7 +3556,11 @@ identify the platform validation still needed. A link still offers Open in default browser, Open in work panel, and Copy link address. Quote, Annotate, and Open side chat are absent (ADR 0268). The surface is a viewport-fixed body-level layer and does - not resize the transcript. + not resize the transcript. During editing, Copy uses the selected draft text + or the full draft with a collapsed caret; Select message text selects the + draft. Edit, Delete, and revision actions are absent until editing ends. + Cancel preserves the original message. Automated Chromium component check: + `node scripts/e2e-message-edit-copy.mjs`. - **Specs linked**: `04-ux/08-component-spec.md` §8.3 / §8.5, `04-ux/09-interaction-patterns.md` (floating dropdown surfaces), ADR 0268 @@ -5487,6 +5503,13 @@ identify the platform validation still needed. a network fixture that fails beyond ten attempts before recovering. Stop the turn during backoff and verify no later request starts; disable the setting and verify a fresh persistent outage stops after ten retries. + 11. Alternate one socket failure and one successful tool-call response for + eleven actual Read calls, then fail once and return a final answer. + Verify all twelve independent failures recover in the same user turn, + each beginning at retry 1, with no repeated tool execution. + 12. After a recovered tool response, keep the next response failing. Verify + it receives ten fresh retries and terminates once with `retryAttempt: 10`. + Restore the fixture, click Continue, and verify completion in the UI. - **Expected**: - `terminated` is classified as `STREAM_FAILED`, and an upstream gateway `502`/`503`/`504` as retryable `PROVIDER_ERROR`. @@ -5497,7 +5520,9 @@ identify the platform validation still needed. duplicate assistant bubble or terminal error notification. - A mid-stream 502 is retried rather than surfacing immediately. The mixed-phase fixture spends one counter across both phases and makes eleven - attempts in total, not one retry per phase. Observed waits without a + attempts in total, not one retry per phase. A complete successful response + replenishes both retry budgets for the next tool round. Headers and partial + output do not. Observed waits without a `Retry-After` header are 1, 2, 4, then 8 seconds for every later retry, identical in both phases. - Only the failed request is replayed: the session, its transcript, and any @@ -5533,8 +5558,13 @@ identify the platform validation still needed. `08-meta/decisions-log.md` (D186, D259, D378), ADR 0050, ADR 0128, ADR 0206 - **Acceptance**: C (chat & stream), F (persistence), H (diagnostics), Quality - **Milestone**: M5 -- **Status**: Unit-covered (`agent-errors.test.ts`, `provider-retry.test.ts`, - `runtime.test.ts`, `subagent.test.ts`); full provider/UI journey Draft +- **Status**: Retry and successful-response budget boundaries covered by + `provider-recovery-flow.test.ts` through real agent loops. Desktop socket + failure, partial-stream recovery, Responses recovery, exhaustion, Continue, + and eleven-tool-round recovery run in `scripts/e2e-provider-recovery.mjs`. + Existing classification coverage: `agent-errors.test.ts`, + `provider-retry.test.ts`, `runtime.test.ts`, `subagent.test.ts`. + Other scenario variants remain Draft. #### E2E-149: Recover provider rate limits (429) silently in place @@ -5566,6 +5596,10 @@ identify the platform validation still needed. attempts, never multiplies attempts through nested pi-ai retries, and emits no intermediate assistant error, lifecycle `error`, `turn_end`, or `agent_end`. + A complete successful response, including a tool-call response, resets both + budgets before the next model request. Independent recovered rate limits + across more than ten tool rounds must not terminate the user turn or a + builtin subagent; partial output alone must not replenish either budget. - A recovered attempt removes the failed assistant from model context and reuses its visible assistant message id. The transcript has one assistant bubble and one terminal lifecycle; bounded retry diagnostics retain the @@ -14033,16 +14067,16 @@ the latest destination. These assertions measure work counts, not device FPS. log channel. Repeat with a hard fault (a Windows `0xC0000005`-class exit) if one is available, and then quit the app while a plugin host is alive. - **Expected:** Every one of those surfaces names the exit code (`exit code 7`, - and `exit code 3221225477 (0xC0000005)` for the fault), and the newest plugin - output line travels with the load error and the audit record. A clean quit + and `exit code 3221225477 (0xC0000005)` for the fault), while the fixture's + stderr line is absent from the load error and crash audit record. A clean quit reports no crash at all: quitting is a shutdown, not a crash. - **Specs:** `07-plugins/05-plugin-lifecycle.md` §3.1 / §8, `08-meta/decisions-log.md` D607. - **Acceptance:** G (plugin host lifecycle), Quality (diagnosability). - **Milestone:** Post-MVP regression coverage. - **Automation:** `apps/desktop/test/plugin-services.test.mjs` forks a real host - process, kills it with a fixture exit code and asserts the code and the stderr - line on the service state and the audit record; `plugin-isolation.test.mjs` + process, kills it with a fixture exit code and asserts the code plus the + absence of raw stderr in the crash audit record; `plugin-isolation.test.mjs` and the shutdown cases cover the "quit is not a crash" half. - **Status:** Automated at the runtime level; no UI driver reads the plugin page's error text. diff --git a/docs/spec/07-plugins/05-plugin-lifecycle.md b/docs/spec/07-plugins/05-plugin-lifecycle.md index 1b3262dabc..e447388a07 100644 --- a/docs/spec/07-plugins/05-plugin-lifecycle.md +++ b/docs/spec/07-plugins/05-plugin-lifecycle.md @@ -104,15 +104,13 @@ unexpectedly" toast, and a supervisor scheduling restarts into an app that is closing. None of that may happen on a clean quit. **A crash report carries the diagnosis.** The crash path reports the host -process's exit code and its newest output line, because that is the only -evidence a user can quote in a bug report: on Windows a hard fault -(`0xC0000005` and friends, which Electron hands over as a negative signed int) -and a plugin's own `process.exit(1)` are different bugs, and a plugin that died -on a thrown error usually printed the reason first. The exit code reaches the -load error, the `failed` service state, the `plugin.crash` audit record and the -`plugin` log channel; the newest output line (the last three are kept, each -bounded) rides with the error and the audit record. Nothing new is persisted, -and no new permission or API surface is involved. +process's exit code because on Windows a hard fault (`0xC0000005` and friends, +which Electron hands over as a negative signed int) and a plugin's own +`process.exit(1)` are different bugs. The exit code reaches the load error, the +`failed` service state, the `plugin.crash` audit record and the `plugin` log +channel. Plugin stdout/stderr is not copied into the crash report because it +may contain workspace data or secrets. Nothing new is persisted, and no new +permission or API surface is involved. The sequence is bounded — `onUnload` gets 1.5s per plugin and teardown 3s in total, after which the children are killed outright. A plugin's cleanup must @@ -202,7 +200,7 @@ Fields: - ts - errorCode? - attempt? / delayMs? (service restarts) -- exitCode? (crash), exitCodeHex? (Windows hard fault), message? (the plugin's newest output line) +- exitCode? (crash), exitCodeHex? (Windows hard fault) ## 9. Uninstall strategy diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md index 35f4f1a49b..6886755f60 100644 --- a/docs/spec/08-meta/decisions-log.md +++ b/docs/spec/08-meta/decisions-log.md @@ -6471,7 +6471,7 @@ that was sitting at the bottom — including after the turn had finished. state, protocol, persistence, theme schema, or permission change. See `04-ux/08-component-spec.md` and E2E-CHAT-opaque-floating-decision-and-retry-surfaces. -## 2026-09-21 — A plugin crash reports its exit code and its last words (D607, issue #747) +## 2026-09-21 — A plugin crash reports its exit code without copying raw output (D607, issue #747) - The host-process crash path reported only the plugin id, so a report read `plugin host process exited: ` and carried nothing else — the reporter in @@ -6481,10 +6481,8 @@ that was sitting at the bottom — including after the turn had finished. fault (`0xC0000005` and friends, delivered as a negative signed int, printed alongside its unsigned hex form) and a plugin's own `process.exit(1)` are different bugs and this is the only field that tells them apart. -- The plugin's newest output line rides with it. The runtime keeps the last - three stdout/stderr lines per plugin, each bounded, and quotes the newest in - the load error and the audit record (`message`), because a plugin that died on - a thrown error usually printed the reason first. Nothing new is persisted and - no permission or API surface changes; the audit thread already recorded every - line as `plugin.stdio`. +- Plugin stdout/stderr is not copied into the load error, crash audit record, or + crash log payload because it may contain workspace data or secrets. Nothing + new is persisted and no permission or API surface changes; the existing + `plugin.stdio` audit stream is otherwise unchanged. - See `07-plugins/05-plugin-lifecycle.md` §3.1. diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md index d80bc2e15c..1fd2d92e7f 100644 --- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md +++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md @@ -98,8 +98,8 @@ pi 消费排队输入时保留渲染器提供的消息 id;即使补充输入 之内的图片才会被读进内存;更大的图片走流式哈希/复制以及既有的安全路径回退 7. 为本回合快照有效的 shell ID 与方言 8. 用解析出的会话配置和有效思考级别启动 pi 回合;HTTP 429 的建连与流式失败 - 使用运行时自有的静默五次重试预算,其他瞬时的 transport/provider 失败则在 - 建连与流式两个阶段之间共享一份运行时自有的四次重试有界预算 + 使用运行时自有的静默 10 次重试预算,其他瞬时的 transport/provider 失败则在 + 建连与流式两个阶段之间共享一份运行时自有的 10 次重试有界预算 (D127、D186、D245、D258) 9. 将规范化的回答与思考事件流式传输到 UI 10. 工具调用时,携带持久的 `sessionId` 委托给 Rust 主机桥;由主机解析会话 @@ -126,10 +126,10 @@ pi 消费排队输入时保留渲染器提供的消息 id;即使补充输入 ### 5d。有界提供商流恢复和诊断(D186、D245、D259、ADR 0091、ADR 0128) 提供程序请求设置和流式传输交付是两个独立的故障阶段,但 -HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 +HTTP 429 处理是一个响应恢复策略。此路径禁用了 pi-ai 的嵌套 适配器重试,因此运行时可以在两个阶段之间共享一个预算。 -`PROVIDER_RATE_LIMITED` 在初始尝试之后最多重试五次,总共六次 +`PROVIDER_RATE_LIMITED` 在初始尝试之后最多重试 10 次,总共 11 次 提供程序尝试。设置阶段的 429 在提供程序流适配器内部重试。 流中的 429 会从下一个模型上下文中删除失败的助手,并在同一 回合中调用 `continue()`。两个阶段占用同一个计数器,因此设置阶段的 @@ -151,8 +151,8 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 运行时从 fetch 捕获失败的响应状态和标头,因为 pi-ai 的普通响应 回调仅涵盖已建立的响应。 -非 429 瞬时故障共享它们自己的有界逻辑回合预算:在初始尝试之后 -最多重试四次,总共五次提供程序尝试。该预算由请求设置和流式 +非 429 瞬时故障共享它们自己的有界响应恢复预算:在初始尝试之后 +最多重试 10 次,总共 11 次提供程序尝试。该预算由请求设置和流式 传输交付共享,因此在两个阶段之间移动的故障无法重置或倍增它, 并且它与 429 预算相互独立。它只接受 `NETWORK_ERROR`、`TIMEOUT`、 `STREAM_FAILED` 和可重试的 `PROVIDER_ERROR`——包括在标头到达之前 @@ -161,6 +161,13 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 并且来自格式错误的 400/422 请求的不可重试 `PROVIDER_ERROR` 仍然是 终止的。 +**Synchronized update (#699):** Both budgets reset after a complete, non-error, +non-aborted model response, including tool-call responses, in the main session +and builtin subagents. Headers, partial output, and phase changes never reset +them. New requests start at retry 1; persistent outages remain bounded at ten +retries per class. Exhaustion diagnostics use the applicable budget counter, +not temporary retry activity. See the English source section 5d and ADR 0206. + 在把 HTTP 400/422 那种消息以 `(no body)` 结尾的流前 `PROVIDER_ERROR` 抛给上层 之前,运行时最多做一次静默的修复尝试:移除生成的输出上限字段 `max_tokens`、`max_completion_tokens` 和 `max_output_tokens`。这次修复不消耗 @@ -196,7 +203,7 @@ HTTP 429 处理是一个逻辑回合策略。此路径禁用了 pi-ai 的嵌套 `networkRoute`)以及请求关联字段(`requestMessages`、`requestBytes`、 `compactionGeneration`)。 对于持续的 429, -`retryAttempt` 为 `5`;对于持续的非 429 瞬时故障,它为 `4`。凭据与不受限制的 +`retryAttempt` 为 `10`;对于持续的非 429 瞬时故障,它也为 `10`。凭据与不受限制的 响应正文永远不会进入事件或日志。每次重试都会新建请求、流和 `AbortController`; 重试唯一共享的状态是进程级 undici dispatcher。当同一来源在一轮内连续两次没有 任何响应、且新尝试仍无法到达它时,下一次尝试前会重建一次传输(每 30 秒最多一次, diff --git a/docs/zh-CN/spec/03-runtime/08-error-codes.md b/docs/zh-CN/spec/03-runtime/08-error-codes.md index 9ea39ce05a..c6ca4d98dd 100644 --- a/docs/zh-CN/spec/03-runtime/08-error-codes.md +++ b/docs/zh-CN/spec/03-runtime/08-error-codes.md @@ -312,10 +312,10 @@ Node sidecar 将提供商 SDK 错误映射到: 精确的 `terminated` 提供商消息和等效的过早流关闭 消息映射到 `STREAM_FAILED`。请求设置阶段或响应后的 -`PROVIDER_RATE_LIMITED` 使用共享的运行时预算:初始尝试之后最多五次重试, +`PROVIDER_RATE_LIMITED` 使用共享的运行时预算:初始尝试之后最多 10 次重试, 且设置和流式传输失败一起计数。非 429 瞬时故障——`STREAM_FAILED`、 `NETWORK_ERROR`、`TIMEOUT` 以及可重试的 `PROVIDER_ERROR`(例如上游网关 -502/503/504)——共享它们自己的有界预算:初始尝试之后最多四次重试,同样 +502/503/504)——共享它们自己的有界预算:初始尝试之后最多 10 次重试,同样 跨请求设置和流式传输一起计数,并且与 429 预算相互独立。两个预算都是 可中止的。429 路径在客户端退避之前先遵循 `retry-after-ms`、`retry-after` 秒和 HTTP 日期标头,并将等待上限设为 30 秒;非 429 路径应用相同的优先级, @@ -325,6 +325,11 @@ Node sidecar 将提供商 SDK 错误映射到: 致命的。设置 `infiniteProviderRetry` 默认关闭;开启后只移除上述可重试网络/瞬时类别的次数上限, 不会改变退避、`Retry-After`、取消或终止分类,并可能在用户停止回合前持续消耗 API 用量。 +**Synchronized update (#699):** A complete successful model response resets +both budgets, including a tool-call response, in the main session and builtin +subagents. Headers, partial output, and phase changes do not replenish them. +Exhaustion reports `retryAttempt: 10` from the relevant budget counter. + `NETWORK_ERROR` 以有界的 `details` 携带真正失败的传输层: `networkCategory`(`dns`、`tls`、`timeout`、`refused`、`unreachable`、 `reset`、`proxy`,或在没有留下任何线索时为 `unknown`)、`networkCode` diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index dd447e675c..520c7143fe 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -1328,7 +1328,7 @@ row 而不是在转录本中添加树镶边。 因此一旦弹框伸入面板列就会被覆盖,与其 z-index 无关。当会话面板比弹框 窄时,弹框随面板收窄,而不是越过该边界。 - 时间戳:`aria-label` 带有完整时间字符串,视觉显示相对时间 -- 右键菜单为 `role="menu"`、行项为 `role="menuitem"`,支持方向键 / Home / End,Escape / Tab / 外部按下 / 背后滚动关闭,并带有可访问名称(`chat.messageMenu` 或 `chat.conversationMenu`)。焦点回到被右键打断的控件。 +- 右键菜单为 `role="menu"`、行项为 `role="menuitem"`,支持方向键 / Home / End,Escape / Tab / 外部按下 / 背后滚动关闭,并带有可访问名称(`chat.messageMenu` 或 `chat.conversationMenu`)。焦点回到被右键打断的控件。编辑用户消息时,复制读取草稿选区(无选区时复制整份草稿),选中消息文本会选中草稿;结束编辑前不提供针对已保存消息的编辑、删除或版本切换操作。 ### 8.6 MVP 约束 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 94a4a91e01..767aaba189 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -2214,7 +2214,7 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的 #### E2E-CHAT-transcript-context-menu:右键消息或记录 - **前提条件**:会话中有一条完成的用户提示和一条完成的助手回答;对话面板已聚焦。 -- **步骤**:1)右键用户板。2)选择复制,再选择选中消息文本。3)在用户板里选中一段文字,再右键该板并选择复制;收起选区后再右键复制一次。4)右键助手回合并选择复制。5)右键最后一回合下方空白处,选择复制整个对话。6)在打开的菜单上按 Escape,再按 Tab。7)右键回答里的 markdown 链接。 +- **步骤**:1)右键用户板。2)选择复制,再选择选中消息文本。3)在用户板里选中一段文字,再右键该板并选择复制;收起选区后再右键复制一次。4)右键助手回合并选择复制。5)右键最后一回合下方空白处,选择复制整个对话。6)在打开的菜单上按 Escape,再按 Tab。7)右键回答里的 markdown 链接。8)编辑用户消息、替换内容、选中一段草稿并右键复制;再验证无选区复制、选中消息文本和取消编辑后的复制。 - **预期**:用户菜单列出复制、选中消息文本、编辑,以及分隔后的删除;助手菜单列出复制、选中消息文本、重新生成和分叉。复制写入打开菜单时该行内的选中文本;光标折叠或选区在行外时退回整段。复制整个对话仍写入带说话人标签的整段对话。两者都显示 toast。选中文本会高亮气泡。Escape 和 Tab 关闭菜单且不执行项。链接仍提供在默认浏览器打开、在工作面板打开、复制链接地址。引用、批注、打开侧边聊天不出现(ADR 0268)。表面是视口固定的文档级层,不会改变记录高度。 - **链接规格**:`04-ux/08-component-spec.md` §8.3 / §8.5, `04-ux/09-interaction-patterns.md`(浮动下拉表面), @@ -2225,6 +2225,8 @@ MainChat 弥补了缺口。 Maximized/fullscreen 调用保留最新的 `chat-context-menu-items.test.mjs`、`chat-context-menu-surface.test.mjs`); 完整 UI 场景草稿 +- 编辑时复制应使用草稿选区,无选区时复制整份草稿;选中消息文本应选中草稿。菜单不提供编辑、删除或版本切换;取消后原消息及其菜单保持不变。自动化验证:`node scripts/e2e-message-edit-copy.mjs`。 + #### E2E-060b:镀铬中性灰色调 - **先决条件**:应用程序在深色和浅色主题中运行;插件页面和 @@ -3658,21 +3660,21 @@ IPC 请求无法关闭。 - **先决条件**:项目绑定的 Agent 会话使用确定性 发出部分辅助流的提供商装置,终止一次, - 然后下一个请求成功;第二场比赛可以终止五次; + 然后下一个请求成功;第二场比赛可以终止 11 次; 第三个装置在一次尝试中于标头之前返回 `OpenAI API error (502)`,并在下一次尝试中于流中返回它; - 第四个装置返回连续六个 502;第五个装置返回带 + 第四个装置返回连续 11 个 502;第五个装置返回带 `Retry-After` 的 503。 - **步骤**: 1. 使用单端接夹具开始 Agent 转动,并观察 部分助理回应。 2. 等待有界重试并检查成绩单、会话状态和 恢复后的终端诊断。 - 3. 对五端夹具重复并检查端子错误 + 3. 对 11 次终止夹具重复并检查端子错误 message/event 及其诊断详细信息。 4. 运行混合阶段 502 装置,并针对标头之前和流中的 502 检查请求计数与终端诊断。 - 5. 运行持续六次 502 的装置并检查终端错误。 + 5. 运行持续 11 次 502 的装置并检查终端错误。 6. 运行 503 `Retry-After` 装置并检查观察到的等待。 7. 重新加载会话并验证是否只有已完成的响应或 单终端故障助手依然耐用。 @@ -3681,12 +3683,12 @@ IPC 请求无法关闭。 - `terminated` 被分类为 `STREAM_FAILED`,上游网关 `502`/`503`/`504` 被分类为可重试的 `PROVIDER_ERROR`。 - 非 429 瞬时故障共享一个有界预算:在初始尝试之后最多 - 重试四次,总共五次提供程序尝试,由请求设置和流式传输 + 重试 10 次,总共 11 次提供程序尝试,由请求设置和流式传输 交付共享。每次重试都等待一个可中止的有界退避,从模型 上下文中删除失败的助手,并且不产生重复的助手气泡或 终端错误通知。 - 流中的 502 会被重试,而不是立即显现。混合阶段装置在两个 - 阶段之间花费同一个计数器,总共进行五次尝试,而不是每个 + 阶段之间花费同一个计数器,总共进行 11 次尝试,而不是每个 阶段各重试一次。在没有 `Retry-After` 标头时,观察到的等待 依次为 1 秒、2 秒、4 秒,然后是 8 秒,并且在两个阶段中完全 相同。 @@ -3695,14 +3697,14 @@ IPC 请求无法关闭。 - 恢复的回合发出一个终端生命周期并保持相同的可见 助理消息 ID。时序日志为每次重试记录 `outcome=retry` 及其尝试编号,以及最终结果。 - - 第五次终止发出一个终端 `STREAM_FAILED` 辅助错误和 + - 第 11 次终止发出一个终端 `STREAM_FAILED` 辅助错误和 生命周期事件;持续 502 的装置发出一个终端 - `PROVIDER_ERROR`。两者都携带 `retryAttempt: 4`。可用的详细 + `PROVIDER_ERROR`。两者都携带 `retryAttempt: 10`。可用的详细 信息包括阶段、流计时和提供商状态,无需凭据或不受限制的 提供商正文。 - 503 装置等待服务器的 `Retry-After`,而不是客户端退避。非 429 的服务器等待和回退等待都以 8 秒为上限。 - - 流中的 HTTP 429 由 429 预算单独的五次重试路径覆盖;两个 + - 流中的 HTTP 429 由 429 预算单独的 10 次重试路径覆盖;两个 预算互不占用。 - 身份验证、模型选择、上下文和格式错误的请求失败 不进入任何提供程序重播路径,包括来自格式错误的 400/422 @@ -3716,6 +3718,20 @@ IPC 请求无法关闭。 - **状态**:单位覆盖(`agent-errors.test.ts`、`provider-retry.test.ts`、 `runtime.test.ts`、`subagent.test.ts`);完整 provider/UI 旅程草案 +**Synchronized update (#699, E2E-096 / E2E-149):** Alternate one network +failure with each of eleven successful Read responses, then fail once before +the final answer. All twelve independent failures must recover, starting at +retry 1 each time, with no duplicate tool execution. Complete successful +responses replenish both budgets; headers, partial output, and phase changes +do not. A new persistent outage after recovery still gets ten retries and +reports `retryAttempt: 10`. Restore the provider and verify Continue succeeds. +The same reset rule applies to rate limits and builtin subagents. + +The socket-failure, interrupted-stream, Responses, exhaustion, Continue, and +eleven-tool-round desktop paths are verified by +`scripts/e2e-provider-recovery.mjs`; real agent-loop coverage is in +`provider-recovery-flow.test.ts`. Other scenario variants remain Draft. + ## 7A。 M6 Plan 和 shell 场景 #### E2E-104:旧合约值迁移到架构 v11 @@ -8342,10 +8358,10 @@ the latest destination. These assertions measure work counts, not device FPS. 隔离的桌面配置;不访问市场或网络。 - **步骤:** 加载插件并让宿主进程死亡。读取加载错误、`failed` 服务状态、`plugin.crash` 审计记录与 `plugin` 日志通道。若有条件, 再用一次硬故障(Windows `0xC0000005` 一类退出)重复;最后在插件宿主存活时退出应用。 -- **预期:** 上述每一处都给出退出码(`exit code 7`;硬故障为 `exit code 3221225477 (0xC0000005)`),且插件最新一行输出随加载错误与 - 审计记录一同出现。干净退出完全不上报崩溃:退出是关闭而不是崩溃。 +- **预期:** 上述每一处都给出退出码(`exit code 7`;硬故障为 `exit code 3221225477 (0xC0000005)`),而夹具的 stderr 行不会出现在 + 加载错误或崩溃审计记录中。干净退出完全不上报崩溃:退出是关闭而不是崩溃。 - **规格:** 07-plugins/05-plugin-lifecycle §3.1 / §8、08-meta/decisions-log D607。 - **验收:** G(插件宿主生命周期)、品质(可诊断性)。**里程碑:** Post-MVP 回归覆盖。 - **自动化:** `apps/desktop/test/plugin-services.test.mjs` 真实 fork 宿主进程、以夹具退出码杀死它,并断言服务状态与审计记录上的 - 退出码与 stderr 行;`plugin-isolation.test.mjs` 与关闭用例覆盖"退出不是崩溃"那一半。 + 退出码及原始 stderr 缺失;`plugin-isolation.test.mjs` 与关闭用例覆盖"退出不是崩溃"那一半。 - **状态:** 运行时层已自动化;无 UI 驱动读取插件页的错误文本。 diff --git a/docs/zh-CN/spec/07-plugins/05-plugin-lifecycle.md b/docs/zh-CN/spec/07-plugins/05-plugin-lifecycle.md index fec1759a49..78cc96dd14 100644 --- a/docs/zh-CN/spec/07-plugins/05-plugin-lifecycle.md +++ b/docs/zh-CN/spec/07-plugins/05-plugin-lifecycle.md @@ -104,10 +104,9 @@ discovered 是一条错误日志、一个"意外停止"提示,以及主管把重启排进一个正在关闭的应用。 干净退出时这些都不允许发生。 -**崩溃上报必须带上诊断。** 崩溃路径会报出宿主进程的**退出码**与它**最新的一行输出**——这是用户唯一能在报障里引用的证据: -Windows 上的硬故障(`0xC0000005` 一类,Electron 以负的有符号整数交付)与插件自己调用 `process.exit(1)` 是两类完全不同的 -问题,而因抛错而死的插件通常已经把原因打印出来了。退出码会出现在加载错误、`failed` 服务状态、`plugin.crash` 审计记录与 -`plugin` 日志通道里;最新一行输出(只保留最近三行,每行有长度上限)随错误与审计记录一起走。不新增任何持久化,也不涉及 +**崩溃上报必须带上诊断。** 崩溃路径会报出宿主进程的**退出码**:Windows 上的硬故障(`0xC0000005` 一类,Electron 以负的有 +符号整数交付)与插件自己调用 `process.exit(1)` 是两类完全不同的问题。退出码会出现在加载错误、`failed` 服务状态、`plugin.crash` +审计记录与 `plugin` 日志通道里。插件 stdout/stderr 可能包含工作区数据或敏感信息,因此不会复制进崩溃上报。不新增任何持久化,也不涉及 新的权限或 API 面。 整个过程是有界的——每个插件的 `onUnload` 有 1.5s,整体拆除有 3s,超时后直接 @@ -197,7 +196,7 @@ rollback all registrations from this plugin - TS - 错误代码? - 尝试? / 延迟女士? (服务重新启动) -- exitCode?(崩溃)、exitCodeHex?(Windows 硬故障)、message?(插件最新一行输出) +- exitCode?(崩溃)、exitCodeHex?(Windows 硬故障) ## 9. 卸载策略 diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md index 6fabf46c56..0244a9617e 100644 --- a/docs/zh-CN/spec/08-meta/decisions-log.md +++ b/docs/zh-CN/spec/08-meta/decisions-log.md @@ -4600,13 +4600,12 @@ that amendment are retired by ADR 0268; the upstream work-panel lifecycle stays. 或权限。见 `04-ux/08-component-spec.md` 与 E2E-CHAT-opaque-floating-decision-and-retry-surfaces。 -## 2026-09-21 —— 插件崩溃上报带上退出码与最后一行输出(D607,issue #747) +## 2026-09-21 —— 插件崩溃上报带上退出码但不复制原始输出(D607,issue #747) - 宿主进程崩溃路径此前只报插件 id,于是报障里只有 `plugin host process exited: ` 一句话——issue #747 的报告者手里 就是这么一句。现在运行时代码本来就已经拿到的**退出码**会出现在消息、`failed` 服务状态、`plugin.crash` 审计记录与 `plugin` 日志通道里;Windows 上的硬故障(`0xC0000005` 一类,以负的有符号整数交付,同时打印其无符号十六进制形式)与 插件自己调用 `process.exit(1)` 是两类不同的问题,而这个字段是唯一能区分它们的。 -- 插件**最新一行输出**随之一同上报:运行时按插件保留最近三行 stdout/stderr(每行有长度上限),并把最新一行引用进 - 加载错误与审计记录(`message`)——因抛错而死的插件通常先打印了原因。不新增任何持久化,权限与 API 面不变; - 每行原本已作为 `plugin.stdio` 进入审计流。 +- 插件 stdout/stderr 不复制进加载错误、崩溃审计记录或崩溃日志负载,因为其中可能包含工作区数据或敏感信息。不新增任何持久化,权限与 API 面不变; + 既有的 `plugin.stdio` 审计流保持不变。 - 见 `07-plugins/05-plugin-lifecycle.md` §3.1。 diff --git a/packages/agent-runtime/src/provider-recovery-flow.test.ts b/packages/agent-runtime/src/provider-recovery-flow.test.ts new file mode 100644 index 0000000000..81dc0ec832 --- /dev/null +++ b/packages/agent-runtime/src/provider-recovery-flow.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Type } from "typebox"; +import type { AgentEventEnvelope } from "@pi-desktop/shared"; +import { DesktopAgentRuntime } from "./runtime.js"; +import { SubagentRun } from "./subagent.js"; +import type { RuntimeProviderConfig } from "./provider-binding.js"; + +const provider: RuntimeProviderConfig = { + id: "fixture", + name: "Fixture", + modelId: "fixture-model", + baseUrl: "http://provider.invalid/v1", + apiKey: "fixture-only", + apiStyle: "chat_completions", + supportsReasoning: false, + supportedThinkingLevels: ["off"], +}; + +type Failure = "network" | "rate-limit" | "stream"; +type Step = Failure | "tool" | "success"; + +/** Real provider adapter and agent loop; replace only fetch and the host edge. */ +function fixture(steps: Step[]) { + let requests = 0; + let reads = 0; + const events: AgentEventEnvelope[] = []; + const fetch = vi.fn(async (): Promise => { + const step = steps[requests++]; + if (!step) throw new Error("Unexpected provider request"); + if (step === "network") throw new TypeError("fetch failed"); + if (step === "rate-limit") + return new Response("Rate limited", { + status: 429, + headers: { "retry-after-ms": "1" }, + }); + const delta = + step === "tool" + ? { + role: "assistant", + tool_calls: [ + { + index: 0, + id: `call_${requests}`, + type: "function", + function: { name: "Read", arguments: '{"path":"fixture.txt"}' }, + }, + ], + } + : { role: "assistant", content: step === "stream" ? "partial" : "Recovered" }; + const chunk = (value: unknown, finish: string | null) => + `data: ${JSON.stringify({ id: "completion", object: "chat.completion.chunk", created: 1, model: "fixture-model", choices: [{ index: 0, delta: value, finish_reason: finish }] })}\n\n`; + if (step === "stream") { + let sent = false; + return new Response( + new ReadableStream({ + pull(controller) { + if (!sent) { + sent = true; + controller.enqueue(new TextEncoder().encode(chunk(delta, null))); + } else controller.error(new Error("terminated")); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ); + } + return new Response( + chunk(delta, null) + + chunk({}, step === "tool" ? "tool_calls" : "stop") + + "data: [DONE]\n\n", + { headers: { "content-type": "text/event-stream" } }, + ); + }); + vi.stubGlobal("fetch", fetch); + const runtime = new DesktopAgentRuntime({ + sessionId: "fixture-session", + mode: "agent", + provider, + thinkingLevel: "off", + commandShell: { + id: "bash", + label: "Bash", + dialect: "posix", + available: true, + isDefault: true, + }, + host: { + async call(method: string): Promise { + if (method !== "tools.execute") + throw new Error(`Unexpected host method: ${method}`); + reads++; + return { ok: true, content: "fixture contents" } as T; + }, + }, + onEvent: (event) => events.push(event), + }); + const subagent = new SubagentRun({ + definition: { + name: "fixture", + description: "Read fixture", + prompt: "Read fixture", + source: "builtin", + tools: ["Read"], + }, + sessionId: "fixture-session", + parentToolCallId: "task", + task: "Read fixture", + provider, + thinkingLevel: "off", + systemPrompt: "Read and report", + tools: [ + { + name: "Read", + label: "Read", + description: "Read fixture", + parameters: Type.Object({ path: Type.String() }), + execute: async () => { + reads++; + return { content: [{ type: "text", text: "fixture contents" }], details: {} }; + }, + }, + ], + onEvent: (event) => events.push(event), + }); + return { runtime, subagent, events, requests: () => requests, reads: () => reads }; +} + +async function settle(promise: Promise): Promise { + let settled = false; + // Attach handlers before advancing clocks so expected terminal rejections + // never become unhandled rejections in the test process. + const observed = promise.then( + (value) => ({ value }), + (error) => ({ error }), + ); + void observed.then(() => { + settled = true; + }); + for (let tick = 0; tick < 180 && !settled; tick++) + await vi.advanceTimersByTimeAsync(1000); + expect(settled, "run settles inside the bounded recovery window").toBe(true); + const result = await observed; + if ("error" in result) throw result.error; + return result.value; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("provider recovery through real agent loops (#699)", () => { + for (const owner of ["session", "subagent"] as const) { + for (const failure of ["network", "rate-limit", "stream"] as const) { + it(`${owner} recovers independent ${failure} outages across eleven successful tool rounds`, async () => { + vi.useFakeTimers(); + const f = fixture([ + ...Array.from({ length: 11 }, (): Step[] => [failure, "tool"]).flat(), + failure, + "success", + ]); + try { + if (owner === "session") + await settle(f.runtime.prompt("Read repeatedly, then report.")); + else expect((await settle(f.subagent.run())).status).toBe("completed"); + expect(f.reads()).toBe(11); + expect(f.requests()).toBe(24); + expect(f.events.filter((e) => e.event.type === "error")).toHaveLength(0); + expect( + f.events.some( + (e) => + e.event.type === "message_end" && + e.event.message.content === "Recovered" && + e.event.message.status === "complete", + ), + ).toBe(true); + if (owner === "session") { + const retryAttempts = f.events.flatMap((e) => + e.event.type === "status" && e.event.status.activity?.phase === "retrying" + ? [e.event.status.activity.attempt] + : [], + ); + expect(retryAttempts).toEqual(Array(12).fill(1)); + } + } finally { + await f.runtime.dispose(); + } + }); + } + } + + it("keeps failures bounded after a recovered tool response and reports the exhausted budget", async () => { + vi.useFakeTimers(); + const f = fixture(["network", "tool", ...Array(11).fill("network")]); + try { + await settle(f.runtime.prompt("Read, then report.")); + expect(f.reads()).toBe(1); + expect(f.requests()).toBe(13); + const errors = f.events.filter((e) => e.event.type === "error"); + expect(errors).toHaveLength(1); + expect(errors[0]?.event).toMatchObject({ + type: "error", + error: { code: "NETWORK_ERROR", details: { retryAttempt: 10 } }, + }); + expect(f.events.filter((e) => e.event.type === "agent_end")).toHaveLength(1); + } finally { + await f.runtime.dispose(); + } + }); + + for (const owner of ["session", "subagent"] as const) { + it(`${owner} does not replenish the budget on partial output or a phase change`, async () => { + vi.useFakeTimers(); + const f = fixture( + Array.from({ length: 11 }, (_, i): Step => (i % 2 ? "stream" : "network")), + ); + try { + if (owner === "session") { + await settle(f.runtime.prompt("Report.")); + expect(f.events.filter((e) => e.event.type === "error")).toEqual([ + expect.objectContaining({ + event: expect.objectContaining({ + error: expect.objectContaining({ + details: expect.objectContaining({ retryAttempt: 10 }), + }), + }), + }), + ]); + } else expect((await settle(f.subagent.run())).status).toBe("failed"); + expect(f.requests()).toBe(11); + expect(f.reads()).toBe(0); + } finally { + await f.runtime.dispose(); + } + }); + } + + it("cancels the retry wait without opening another provider request", async () => { + vi.useFakeTimers(); + const f = fixture(["network"]); + try { + const prompt = f.runtime.prompt("Report.").catch((error) => error); + await vi.waitFor(() => + expect( + f.events.some( + (e) => + e.event.type === "status" && e.event.status.activity?.phase === "retrying", + ), + ).toBe(true), + ); + await f.runtime.abort(); + await settle(prompt); + await vi.advanceTimersByTimeAsync(10000); + expect(f.requests()).toBe(1); + expect(f.events.filter((e) => e.event.type === "agent_end")).toHaveLength(1); + expect( + f.events.some( + (e) => e.event.type === "error" && e.event.error.code === "NETWORK_ERROR", + ), + ).toBe(false); + } finally { + await f.runtime.dispose(); + } + }); +}); diff --git a/packages/agent-runtime/src/provider-retry.ts b/packages/agent-runtime/src/provider-retry.ts index f29b8075d7..0685b14d12 100644 --- a/packages/agent-runtime/src/provider-retry.ts +++ b/packages/agent-runtime/src/provider-retry.ts @@ -35,7 +35,7 @@ export const PROVIDER_SETUP_MAX_RETRY_DELAY_MS = 8_000; * Retries allowed after the first non-rate-limit transient failure. Upstream * gateway faults (502/503/504, dropped * sockets) routinely need more than one attempt, so they share one bounded - * logical-turn budget the way rate limits do instead of getting a single retry + * response-recovery budget the way rate limits do instead of getting a single retry * per phase. */ export const PROVIDER_TRANSIENT_MAX_RETRIES = PROVIDER_RETRY_MAX_RETRIES; @@ -128,7 +128,7 @@ export type ProviderResponseSnapshot = { }; export type ProviderRetryController = { - /** Claim one retry in the shared logical-turn budget. */ + /** Claim one retry across setup/stream failures of the current response. */ claim: ( error: ClassifiedAgentError, phase: ProviderRetryPhase, diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index a6d30872d1..60f6a8014f 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -1576,10 +1576,7 @@ export class DesktopAgentRuntime { private readonly providerTransportHealth: ProviderTransportHealth = createProviderTransportHealth(); private pendingProviderRetry?: ReturnType; - /** - * Shared bounded retry count for non-rate-limit transient failures, counted - * across the request-setup and stream phases (D259). - */ + /** Non-429 setup + stream failures since the last successful response. */ private providerTransientRetryAttempt = 0; /** Shared OpenCode-style 429 retry count across setup and stream phases. */ private providerRateLimitRetryAttempt = 0; @@ -5387,6 +5384,9 @@ Delegation rules: ): ReturnType { const explained = withProviderFetchFailure(error, this.providerFetchFailure); const existingDetails = explained.details ?? {}; + const retryAttempt = error.code === "PROVIDER_RATE_LIMITED" + ? this.providerRateLimitRetryAttempt + : isTransientProviderRetryCode(error.code) ? this.providerTransientRetryAttempt : 0; // A capture exists only for an attempt that rejected before any response, so // it is also the honest phase: whatever the message lifecycle that surfaced // the failure looks like, this request never reached the provider, and @@ -5425,9 +5425,7 @@ Delegation rules: existingDetails.providerStatus === undefined ? { providerStatus: this.providerResponseStatus } : {}), - ...(this.activeProviderRetryAttempt > 0 - ? { retryAttempt: this.activeProviderRetryAttempt } - : {}), + ...(retryAttempt > 0 ? { retryAttempt } : {}), }, }; } @@ -5513,7 +5511,10 @@ Delegation rules: if (messages.at(-1)?.role !== "assistant") { throw new Error("Cannot retry a provider stream without its failed assistant message"); } - messages.pop(); + // A failed stream can be represented by more than one trailing assistant + // message after a tool round. Remove the whole failed suffix before + // continuing; pi-agent-core rejects any assistant-terminated transcript. + while (messages.at(-1)?.role === "assistant") messages.pop(); this.setAgentMessages(messages); this.providerRetryInProgress = true; @@ -6913,10 +6914,13 @@ Delegation rules: streamMs, ); } - // A turn with no tool call and no visible text ends the run while - // leaving the user with nothing: the reasoning that may hold the - // answer is never rendered. Re-run once with a nudge before letting - // that surface as a finished turn. + // Only a completed response replenishes both budgets. Headers and + // partial output must not let a repeatedly broken stream retry forever. + if (!failed && !aborted) { + this.providerTransientRetryAttempt = 0; + this.providerRateLimitRetryAttempt = 0; + } + // Re-run an invisible answer once before surfacing a finished turn. const silence = !failed && !aborted && diff --git a/packages/agent-runtime/src/subagent.ts b/packages/agent-runtime/src/subagent.ts index edd44e0520..323a316fdc 100644 --- a/packages/agent-runtime/src/subagent.ts +++ b/packages/agent-runtime/src/subagent.ts @@ -721,6 +721,10 @@ export class SubagentRun { } } } + if (!failed && stopReason !== "aborted") { + this.providerTransientRetryAttempt = 0; + this.providerRateLimitRetryAttempt = 0; + } const messageUsage = usageFromPi(message.usage); this.usage = addUsage(this.usage, messageUsage); // The report is the last assistant text; a call-only turn has none and diff --git a/scripts/README.md b/scripts/README.md index e932cf6dc7..f0853564fa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -52,6 +52,7 @@ they cover are specified in | `e2e-plan.mjs` | `pnpm test:e2e:plan` | Plan state, checkpoint artifact, and approval transitions | | `e2e-plan-ui.mjs` | `pnpm test:e2e:plan-ui` | Plan approval through the rendered UI | | `e2e-electron-boot.mjs` | `pnpm test:e2e:boot` | Electron boot probe | +| `e2e-provider-recovery.mjs` | `node scripts/e2e-provider-recovery.mjs` | Isolated desktop with a localhost fault-injection provider: socket failures, interrupted streams, Responses recovery, exhausted retries, Continue, and recovery across eleven real Read calls. Requires a built desktop/runtime and host binary (`PI_DESKTOP_HOST_BIN` when outside the checkout); retains screenshots and JSON under `.artifacts/issue-699/` | | `e2e-supervision.mjs` | `pnpm test:e2e:supervision` | Process supervision and restart behavior | | `e2e-subagents.mjs` | `pnpm test:e2e:subagents` | Subagent registry over RPC, then through the real loader (D202) | | `e2e-agent-live.mjs` | `node scripts/e2e-agent-live.mjs` | Live streaming chat through agent-runtime + host-core. Requires `PI_DESKTOP_TEST_API_KEY`, `PI_DESKTOP_TEST_BASE_URL`, and `PI_DESKTOP_TEST_MODEL` (no defaults), so it has no `pnpm` alias | diff --git a/scripts/e2e-composer-paste.mjs b/scripts/e2e-composer-paste.mjs index 5bd8d38522..70e44a7363 100644 --- a/scripts/e2e-composer-paste.mjs +++ b/scripts/e2e-composer-paste.mjs @@ -120,9 +120,8 @@ app.whenReady().then(async () => { assert(["Escape", "Tab"].includes(key)); window.webContents.sendInputEvent({ type: "keyDown", keyCode: key }); window.webContents.sendInputEvent({ type: "keyUp", keyCode: key }); - await window.webContents.executeJavaScript("globalThis.composerPreviewKeyDone()"); }); - await window.webContents.executeJavaScript('globalThis.composerPreviewPressKey = key => new Promise(resolve => { globalThis.composerPreviewKeyDone = resolve; console.log("PI_PREVIEW_KEY:" + key); }); void 0'); + await window.webContents.executeJavaScript('globalThis.composerPreviewPressKey = key => new Promise(resolve => { document.addEventListener("keyup", () => requestAnimationFrame(resolve), { once: true }); console.log("PI_PREVIEW_KEY:" + key); }); void 0'); if (process.env.PI_COMPOSER_PREVIEW_SCREENSHOT) { window.webContents.on("console-message", async (event) => { if (event.message !== "PI_PREVIEW_CAPTURE") return; diff --git a/scripts/e2e-message-edit-copy.mjs b/scripts/e2e-message-edit-copy.mjs new file mode 100644 index 0000000000..475dd2858f --- /dev/null +++ b/scripts/e2e-message-edit-copy.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Real Chromium selection and production message components; no host or provider. +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const desktop = join(root, "apps/desktop"); +const require = createRequire(join(root, "packages/agent-runtime/package.json")); +const cache = join(root, ".cache/message-edit-copy"); +await mkdir(cache, { recursive: true }); +const temp = await mkdtemp(join(cache, "run-")); +await require("esbuild").build({ + entryPoints: [join(root, "scripts/e2e/message-edit-copy.tsx")], + outfile: join(temp, "renderer.js"), bundle: true, platform: "browser", format: "iife", + loader: { ".css": "empty" }, jsx: "automatic", define: { "process.env.NODE_ENV": '"production"' }, + alias: { react: join(desktop, "node_modules/react"), "react-dom": join(desktop, "node_modules/react-dom") }, + nodePaths: [join(desktop, "node_modules")], +}); +await writeFile(join(temp, "index.html"), ''); +await writeFile(join(temp, "main.cjs"), ` +const {app,BrowserWindow}=require('electron'); +app.setPath('userData',require('node:path').join(__dirname,'profile')); +app.disableHardwareAcceleration(); +app.whenReady().then(async()=>{ + try { + const win=new BrowserWindow({show:false,webPreferences:{backgroundThrottling:false,sandbox:true,contextIsolation:true}}); + await win.loadFile(require('node:path').join(__dirname,'index.html')); + console.log(await win.webContents.executeJavaScript('globalThis.messageEditCopyProbe()')); + app.exit(0); + } catch(error) { console.error(error); app.exit(1); } +});`); +const result = spawnSync(resolveElectronBinary(root).electronBinary, [join(temp, "main.cjs")], { stdio: "inherit", timeout: 30000 }); +if (result.error) throw result.error; +process.exitCode = result.status ?? 1; diff --git a/scripts/e2e-provider-recovery.mjs b/scripts/e2e-provider-recovery.mjs new file mode 100644 index 0000000000..f3fc0640d9 --- /dev/null +++ b/scripts/e2e-provider-recovery.mjs @@ -0,0 +1,440 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { resolveElectronBinary } from "./e2e/boot.mjs"; +import { resolveHostBinary } from "./e2e/host.mjs"; +import { verifyProviderRecovery } from "./e2e/provider-recovery-assertions.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const artifacts = join(root, ".artifacts", "issue-699", String(Date.now())); +await mkdir(join(artifacts, "workspace"), { recursive: true }); +await writeFile( + join(artifacts, "workspace", "fixture.txt"), + "Network retry test fixture.\n", +); +const results = []; +let scenario; +const server = createServer(async (req, res) => { + let raw = ""; + for await (const part of req) raw += part; + if (req.method !== "POST" || !scenario) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ data: [{ id: "network-fixture", object: "model" }] })); + return; + } + const body = JSON.parse(raw); + const nth = ++scenario.count; + const toolResults = body.messages?.filter((m) => m.role === "tool").length ?? 0; + scenario.requests.push({ nth, at: Date.now(), path: req.url, toolResults }); + console.log("REQUEST", scenario.name, nth, "tools", toolResults); + if ( + scenario.mode === "always" || + (scenario.mode === "recover" && nth <= 2) || + (scenario.mode === "cumulative" && nth % 2 === 1) + ) { + req.socket.destroy(); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + const chunk = (delta, finish_reason = null) => + res.write( + `data: ${JSON.stringify({ id: "chatcmpl-fixture", object: "chat.completion.chunk", created: 1, model: "network-fixture", choices: [{ index: 0, delta, finish_reason }] })}\n\n`, + ); + if (scenario.style === "responses") { + const response = { + id: "resp_fixture", + object: "response", + status: "in_progress", + model: "network-fixture", + output: [], + }; + const emit = (type, fields) => + res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...fields })}\n\n`); + const item = { + id: "msg_fixture", + type: "message", + role: "assistant", + status: "in_progress", + content: [], + }; + emit("response.created", { response }); + emit("response.output_item.added", { output_index: 0, item }); + emit("response.content_part.added", { + item_id: item.id, + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }); + emit("response.output_text.delta", { + item_id: item.id, + output_index: 0, + content_index: 0, + delta: "RECOVERED_699", + }); + item.status = "completed"; + item.content = [{ type: "output_text", text: "RECOVERED_699", annotations: [] }]; + emit("response.output_text.done", { + item_id: item.id, + output_index: 0, + content_index: 0, + text: "RECOVERED_699", + }); + emit("response.output_item.done", { output_index: 0, item }); + emit("response.completed", { + response: { + ...response, + status: "completed", + output: [item], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }, + }); + res.end(); + } else if (scenario.mode === "stream" && nth === 1) { + chunk({ role: "assistant", content: "PARTIAL_699" }); + // Wait for the screenshot observer to confirm partial content reached the UI. + scenario.breakStream = () => res.destroy(); + } else if (scenario.mode === "cumulative" && toolResults < 11) { + chunk({ + role: "assistant", + tool_calls: [ + { + index: 0, + id: `call_${toolResults}`, + type: "function", + function: { + name: "Read", + arguments: JSON.stringify({ + path: join(artifacts, "workspace", "fixture.txt"), + }), + }, + }, + ], + }); + chunk({}, "tool_calls"); + res.end("data: [DONE]\n\n"); + } else { + chunk({ role: "assistant", content: "RECOVERED_699" }); + chunk({}, "stop"); + res.end("data: [DONE]\n\n"); + } +}); +await new Promise((r) => server.listen(0, "127.0.0.1", r)); +const providerPort = server.address().port; +const debugServer = createServer(); +await new Promise((r) => debugServer.listen(0, "127.0.0.1", r)); +const debugPort = debugServer.address().port; +await new Promise((r) => debugServer.close(r)); +const { appDir, electronBinary } = resolveElectronBinary(root); +const env = { + ...process.env, + PI_DESKTOP_DATA_DIR: join(artifacts, "data"), + PI_DESKTOP_HOST_BIN: resolveHostBinary(), + ELECTRON_RENDERER_URL: "", + PI_DESKTOP_START_MAXIMIZED: "0", +}; +delete env.ELECTRON_RUN_AS_NODE; +for (const key of [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", +]) + delete env[key]; +env.NO_PROXY = "localhost,127.0.0.1"; +let output = ""; +const child = spawn( + electronBinary, + [ + `--remote-debugging-port=${debugPort}`, + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + `--user-data-dir=${join(artifacts, "profile")}`, + ".", + ], + { cwd: appDir, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true }, +); +child.stdout.on("data", (b) => (output += b)); +child.stderr.on("data", (b) => (output += b)); +let ws; +let seq = 0; +const pending = new Map(); +const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP timeout ${method}`)); + }, 30000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); +const evaluate = async (expression) => { + const retained = `globalThis.__cdpPromise699 = (async () => { return eval(${JSON.stringify(expression)}); })()`; + const result = await send("Runtime.evaluate", { + expression: retained, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; +}; +async function waitFor(fn, label, timeout = 45000) { + const start = Date.now(); + let last; + while (Date.now() - start < timeout) { + if (child.exitCode !== null) + throw new Error(`Electron exited: ${output.slice(-3000)}`); + try { + const value = await fn(); + if (value) return value; + } catch (e) { + last = e; + } + await delay(100); + } + throw new Error(`Timed out: ${label}; ${last ?? ""}`); +} +async function ipc(name, ...args) { + const result = await evaluate( + `window.piDesktop.invoke(window.piDesktop.channels.invoke[${JSON.stringify(name)}], ...${JSON.stringify(args)})`, + ); + assert.equal(result.ok, true, `${name}: ${JSON.stringify(result)}`); + return result.data; +} +async function screenshot(name) { + const shot = await send("Page.captureScreenshot", { format: "png" }); + await writeFile(join(artifacts, `${name}.png`), Buffer.from(shot.data, "base64")); +} +const bodyText = () => evaluate("document.body.innerText"); +async function run(name, mode, style = "chat_completions") { + scenario = { name, mode, style, count: 0, requests: [] }; + const provider = await ipc("providersCreate", { + name, + vendorKey: "custom", + type: "openai_compatible", + protocol: "openai_compatible", + baseUrl: `http://127.0.0.1:${providerPort}/v1`, + authKind: "api_key_and_base_url", + secretValue: "local-fixture-only", + defaultModelId: "network-fixture", + apiStyle: style, + supportsReasoning: false, + contextWindow: 128000, + maxOutputTokens: 4096, + }); + const { session } = await ipc("sessionCreate", { + title: name, + mode: "agent", + projectPath: join(artifacts, "workspace"), + providerId: provider.provider.id, + modelId: "network-fixture", + thinkingLevel: "off", + }); + await evaluate(`window.__PI_DESKTOP__.refreshProviders()`); + await evaluate(`window.__PI_DESKTOP__.selectSession(${JSON.stringify(session.id)})`); + await evaluate( + `window.__events699 = []; window.__off699?.(); window.__off699 = window.piDesktop.on(window.piDesktop.channels.event.agentMessage, e => window.__events699.push(e));`, + ); + await waitFor( + () => evaluate('Boolean(document.querySelector(".composer-input"))'), + "composer", + ); + await evaluate( + `(() => { const input = document.querySelector('.composer-input'); input.focus(); input.textContent = 'Test network recovery ${name}'; input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: input.textContent })); })()`, + ); + await waitFor( + () => + evaluate( + `Boolean(document.querySelector('.composer-shell .send-btn:not(:disabled)'))`, + ), + "send enabled", + ); + await evaluate(`document.querySelector('.composer-shell .send-btn').click()`); + let retryShot = false; + const statuses = []; + await waitFor( + async () => { + const text = await bodyText(); + if (scenario.breakStream && text.includes("PARTIAL_699")) { + await screenshot(`${name}-partial`); + scenario.breakStream(); + scenario.breakStream = undefined; + } + if (/重试|Retrying/.test(text) && !retryShot) { + retryShot = true; + statuses.push(text); + await screenshot(`${name}-retry`); + } + const events = await evaluate("window.__events699"); + return events.some( + (e) => e.sessionId === session.id && e.event?.type === "agent_end", + ); + }, + `${name} terminal lifecycle`, + 150000, + ); + await waitFor(async () => { + const detail = await ipc("sessionGet", { id: session.id }); + return detail.session?.messages?.some( + (m) => + m.role === "assistant" && + (m.status === "error" || m.content?.includes("RECOVERED_699")), + ); + }, "durable final assistant"); + // Windows may occlude the test window during a long outage. Bring the + // rendered surface forward before checking the final user-visible state. + await send("Page.bringToFront"); + await screenshot(`${name}-settled`); + await waitFor(async () => { + const text = await bodyText(); + return mode === "always" + ? text.includes("NETWORK_ERROR") + : text.includes("RECOVERED_699"); + }, "final UI"); + await screenshot(`${name}-final`); + const detail = await ipc("sessionGet", { id: session.id }); + const events = await evaluate("window.__events699"); + const text = await bodyText(); + const record = { + name, + mode, + style, + sessionId: session.id, + requests: [...scenario.requests], + retryShot, + statuses, + text, + detail, + events, + }; + results.push(record); + await writeFile(join(artifacts, "results.json"), JSON.stringify(results, null, 2)); + const errors = events.filter((e) => e.event?.type === "error"); + console.log( + "RESULT", + JSON.stringify({ + name, + requests: scenario.count, + retryShot, + errors: errors.map((e) => e.event.error), + text: text.slice(-450), + }), + ); + return record; +} + +async function continueAfterFailure(record) { + scenario.mode = "success"; + await evaluate("window.__events699 = []"); + const clicked = await evaluate(`(() => { + const button = [...document.querySelectorAll('button')].find(node => node.textContent.trim() === '继续'); + if (!button) return false; + button.click(); return true; + })()`); + assert(clicked, "Continue is reachable on the terminal error card"); + await waitFor(async () => { + const events = await evaluate("window.__events699"); + return ( + events.some( + (e) => e.sessionId === record.sessionId && e.event?.type === "agent_end", + ) && (await bodyText()).includes("RECOVERED_699") + ); + }, "Continue completes after provider recovery"); + await screenshot(`${record.name}-continued`); + record.continuation = { + requests: scenario.count - record.requests.length, + events: await evaluate("window.__events699"), + text: await bodyText(), + }; + assert.equal(record.continuation.requests, 1); + assert(!record.continuation.events.some((e) => e.event?.type === "error")); + await writeFile(join(artifacts, "results.json"), JSON.stringify(results, null, 2)); +} +try { + const target = await waitFor(async () => { + const list = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json(); + return list.find( + (t) => + t.type === "page" && + t.url.includes("index.html") && + !t.url.includes("launcher") && + !t.url.includes("plugin"), + ); + }, "desktop CDP"); + ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + const p = pending.get(data.id); + if (!p) return; + pending.delete(data.id); + clearTimeout(p.timer); + if (data.error) p.reject(new Error(JSON.stringify(data.error))); + else p.resolve(data.result); + }; + await waitFor( + () => + evaluate( + 'Boolean(window.__PI_DESKTOP__ && window.piDesktop && !document.querySelector(".app-shell.is-booting"))', + ), + "desktop ready", + ); + const settings = await ipc("settingsGet"); + await ipc("settingsSet", { ...settings, language: "zh-CN", autoGenerateTitle: false }); + console.log("ARTIFACTS", artifacts); + await run("01-network-recovery", "recover"); + await run("02-stream-recovery", "stream"); + await run("03-responses-recovery", "recover", "responses"); + await continueAfterFailure(await run("04-network-exhaustion", "always")); + await run("05-cumulative-budget", "cumulative"); + const summary = verifyProviderRecovery(results); + await writeFile( + join(artifacts, "verified-summary.json"), + JSON.stringify(summary, null, 2), + ); + console.log("VERIFIED", JSON.stringify(summary)); +} catch (error) { + console.error(error); + if (ws?.readyState === 1) { + await screenshot("failure").catch(() => {}); + console.error(await bodyText().catch(() => "")); + await writeFile( + join(artifacts, "failure-events.json"), + JSON.stringify( + (await evaluate("window.__events699").catch(() => null)) ?? null, + null, + 2, + ), + ); + } + process.exitCode = 1; +} finally { + await writeFile(join(artifacts, "electron.log"), output); + console.log("ARTIFACTS", artifacts); + ws?.close(); + if (child.exitCode === null) { + if (process.platform === "win32") + await new Promise((r) => { + const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + windowsHide: true, + stdio: "ignore", + }); + killer.on("exit", r); + }); + else child.kill("SIGTERM"); + } + server.closeAllConnections(); + await new Promise((r) => server.close(r)); +} diff --git a/scripts/e2e/composer-paste.tsx b/scripts/e2e/composer-paste.tsx index b690fe2288..7e2be85b46 100644 --- a/scripts/e2e/composer-paste.tsx +++ b/scripts/e2e/composer-paste.tsx @@ -1,3 +1,4 @@ +import { serializeInlineComposerFileReferences } from "@pi-desktop/shared"; import { useComposerSubmit } from "../../apps/desktop/src/features/chat/composer/hooks/useComposerSubmit"; import { verifyComposerSubmission } from "./composer-submission"; import { ComposerImageAttachments } from "../../apps/desktop/src/features/chat/composer/ComposerImageAttachments"; @@ -214,8 +215,6 @@ globalThis.composerPasteProbe = async () => { "changing workspace while the composer is unmounted must remove the previous workspace's chip"); assert(controller.fileReferences.length === 1 && controller.fileReferences[0].path === references[1].path, "changing workspace must preserve scratch references"); - flushSync(() => root.render(null)); - resetComposerDraftCache(); // Keep the source attachment snapshot when a paste finishes in another session. await reset("keep \uE010 ", 7, 7); @@ -706,12 +705,73 @@ globalThis.composerPasteProbe = async () => { await new Promise(requestAnimationFrame); assert(readEditorValue(controller.ref.current!) === "retry draft" && controller.fileReferences[0]?.path === imageReference.path, "fast rejection before React commits must restore the text and attachments"); + // Native undo must restore reference metadata as well as the visible chip. + await reset("inspect \uE050 please", 8, 9); + const undoReference = createFileReference("src/main.ts", "main.ts", "paste-a", { token: "\uE050" }); + flushSync(() => controller.applyEditorDraft("inspect \uE050 please", [undoReference], 9)); + await new Promise(requestAnimationFrame); + const undoEditor = controller.ref.current!; + undoEditor.focus(); + select(undoEditor, 8, 9); + assert(document.execCommand("delete"), "native chip deletion unavailable"); + await new Promise(requestAnimationFrame); + assert(document.execCommand("undo"), "native chip undo unavailable"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.some(r => r.path === "src/main.ts"), "Undo restored the chip without its file reference metadata"); + assert(serializeInlineComposerFileReferences(readEditorValue(undoEditor), controller.activeFileReferences) === "inspect @src/main.ts please", + "undo must restore the path used by submission"); + assert(document.execCommand("redo"), "native chip redo unavailable"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.length === 0, "redo retained a deleted attachment"); + assert(document.execCommand("undo"), "second native chip undo unavailable"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.length === 1, "repeated undo lost the attachment"); + render("paste-b"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.length === 0, "undo metadata leaked into another chat"); + render("paste-a"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.some(r => r.path === "src/main.ts"), + "the restored reference did not survive a chat round-trip"); + const restoredEditor = controller.ref.current!; + restoredEditor.focus(); + select(restoredEditor, 8, 9); + assert(document.execCommand("delete"), "second chip deletion unavailable"); + await new Promise(requestAnimationFrame); + assert(document.execCommand("insertText", false, "\uE050"), "private-use text insertion unavailable"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.length === 0, + "typing a removed chip's token must not resurrect an attachment"); + + // A batched away-and-back project change must invalidate deleted history, + // too: native undo must never attach the old relative path to a new context. + const priorWorkspace = useAppStore.getState().workspace; + await reset("inspect \uE050 please", 8, 9); + flushSync(() => controller.applyEditorDraft("inspect \uE050 please", [undoReference], 9)); + await new Promise(requestAnimationFrame); + const workspaceUndoEditor = controller.ref.current!; + workspaceUndoEditor.focus(); + select(workspaceUndoEditor, 8, 9); + assert(document.execCommand("delete"), "workspace undo deletion unavailable"); + await new Promise(requestAnimationFrame); + flushSync(() => { + useAppStore.setState({ workspace: { path: "/other-project", name: "Other" } }); + useAppStore.setState({ workspace: priorWorkspace }); + }); + assert(document.execCommand("undo"), "workspace native undo unavailable"); + await new Promise(requestAnimationFrame); + assert(controller.fileReferences.length === 0, + "undo resurrected a reference after a batched workspace round-trip"); + flushSync(() => root.render(null)); + resetComposerDraftCache(); + await verifyComposerSubmission(imageReference.path, i18n); return { ok: true, fullComposerSubmissionAndOverflow: true, mixedShortText: true, multilineAndUndoRedo: true, + fileReferenceUndoRedo: true, crossBreakAndChipSelection: true, mixedLongText: true, imageOnly: true, diff --git a/scripts/e2e/message-edit-copy.tsx b/scripts/e2e/message-edit-copy.tsx new file mode 100644 index 0000000000..214e532e99 --- /dev/null +++ b/scripts/e2e/message-edit-copy.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import i18n from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { en } from "../../packages/i18n/src/index"; +import { MessageRow } from "../../apps/desktop/src/features/chat/transcript/MessageRow"; +import { TranscriptMenuProvider } from "../../apps/desktop/src/features/chat/transcript/TranscriptMenu"; + +const check = (ok: boolean, label: string) => { if (!ok) throw new Error(label); }; +const settle = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); +const find = (selector: string) => { + const node = document.querySelector(selector); + if (!node) throw new Error(`Missing ${selector}`); + return node; +}; +const click = async (selector: string) => { + flushSync(() => find(selector).click()); + await settle(); +}; +const menu = async (node: HTMLElement) => { + flushSync(() => node.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 10, clientY: 10 }))); + await settle(); +}; + +Object.assign(globalThis, { messageEditCopyProbe: async () => { + await i18n.init({ lng: "en", resources: { en: { translation: en } } }); + let copied = ""; + // Mock only the external clipboard write, keeping native textarea selection. + Object.defineProperty(navigator, "clipboard", { value: { writeText: async (text: string) => { copied = text; } } }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + flushSync(() => root.render( + + )); + await menu(find('[role="article"]')); + await click('[data-context-menu-item="edit"]'); + const editor = find("textarea"); + editor.focus(); + editor.select(); + document.execCommand("insertText", false, "Fresh draft: ORANGE-927"); + await settle(); + editor.setSelectionRange(13, 23); + await menu(editor); + await click('[data-context-menu-item="copy"]'); + check(copied === "ORANGE-927", `Selected draft copy returned ${JSON.stringify(copied)}`); + editor.focus(); + editor.setSelectionRange(0, 0); + await menu(editor); + check(!document.querySelector('[data-context-menu-item="edit"]'), "Editing menu can reset the unsaved draft"); + check(!document.querySelector('[data-context-menu-item="delete"]'), "Editing menu exposes saved-message deletion"); + await click('[data-context-menu-item="copy"]'); + check(copied === editor.value, "Collapsed selection copied the saved message instead of the draft"); + await menu(editor); + await click('[data-context-menu-item="select-text"]'); + check(editor.selectionStart === 0 && editor.selectionEnd === editor.value.length, "Select text did not select the draft"); + await menu(editor); + await click('[data-context-menu-item="copy"]'); + check(copied === "Fresh draft: ORANGE-927", "Select text then Copy lost draft content"); + await click(".message-edit-cancel"); + await menu(find('[role="article"]')); + check(!!document.querySelector('[data-context-menu-item="edit"]'), "Normal message lost Edit"); + check(!!document.querySelector('[data-context-menu-item="delete"]'), "Normal message lost Delete"); + await click('[data-context-menu-item="copy"]'); + check(copied === "Original saved message", "Cancel changed the saved message"); + root.unmount(); + return "PASS: partial draft copy, whole draft copy, select text, editing actions, cancel and saved-message copy"; +} }); diff --git a/scripts/e2e/provider-recovery-assertions.mjs b/scripts/e2e/provider-recovery-assertions.mjs new file mode 100644 index 0000000000..c9d0198750 --- /dev/null +++ b/scripts/e2e/provider-recovery-assertions.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +export function verifyProviderRecovery(cases) { + assert.equal(cases.length, 5); + const expectedCounts = [3, 2, 3, 11, 24]; + const expectedDelays = [1000, 2000, 4000, 8000, 8000, 8000, 8000, 8000, 8000, 8000]; + const summary = cases.map((result, index) => { + const events = result.events + .filter((e) => e.sessionId === result.sessionId) + .map((e) => e.event); + const errors = events.filter((e) => e.type === "error"); + const retries = events + .filter((e) => e.type === "status" && e.status.activity?.phase === "retrying") + .map((e) => e.status.activity); + const assistants = result.detail.session.messages.filter( + (m) => m.role === "assistant", + ); + const tools = result.detail.session.messages.filter((m) => m.role === "tool"); + assert.equal(result.requests.length, expectedCounts[index], result.name); + assert.equal(result.retryShot, true, `${result.name}: retry countdown visible`); + assert.equal( + events.filter((e) => e.type === "agent_end").length, + 1, + `${result.name}: one terminal lifecycle`, + ); + if (index !== 3) { + assert.equal(errors.length, 0); + assert.equal(assistants.length, index === 4 ? 12 : 1); + assert(assistants.every((m) => m.status === "complete")); + assert.equal(assistants.at(-1).content, "RECOVERED_699"); + assert(result.text.includes("RECOVERED_699")); + assert(!result.text.includes("NETWORK_ERROR")); + } else { + assert.equal(errors.length, 1); + assert.equal(errors[0].error.code, "NETWORK_ERROR"); + assert.equal(errors[0].error.details.retryAttempt, 10); + assert.equal(assistants.filter((m) => m.status === "error").length, 1); + assert.deepEqual( + retries.map((r) => r.attempt), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ); + assert.deepEqual( + retries.map((r) => r.retryDelayMs), + expectedDelays, + ); + assert(result.text.includes("NETWORK_ERROR") && result.text.includes("继续")); + } + if (index === 3) { + const gaps = result.requests.slice(1).map((r, i) => r.at - result.requests[i].at); + assert( + gaps.every((gap, i) => gap >= expectedDelays[i] - 50), + "real backoff timing", + ); + } + if (index === 4) { + assert.equal(tools.length, 11, "eleven actual tools preserved"); + assert( + tools.every((m) => m.toolName === "Read" && m.toolStatus === "success"), + "all real Reads succeeded", + ); + assert.equal( + result.requests.at(-1).toolResults, + 11, + "successful tool context retained at final response", + ); + assert.deepEqual( + retries.map((r) => r.attempt), + Array(12).fill(1), + ); + assert.deepEqual( + retries.map((r) => r.retryDelayMs), + Array(12).fill(1000), + ); + } + return { + name: result.name, + requests: result.requests.length, + retries: retries.length, + durationMs: result.requests.at(-1).at - result.requests[0].at, + successfulTools: tools.filter((m) => m.toolStatus === "success").length, + terminalError: errors[0]?.error ?? null, + behaviorVerified: true, + exhaustedRetryDiagnosticPresent: + index === 3 ? errors[0].error.details.retryAttempt === 10 : null, + }; + }); + return summary; +}