diff --git a/.agents/skills/codex-app-parity/SKILL.md b/.agents/skills/codex-app-parity/SKILL.md index ef229b7..359be12 100644 --- a/.agents/skills/codex-app-parity/SKILL.md +++ b/.agents/skills/codex-app-parity/SKILL.md @@ -1225,3 +1225,73 @@ After each feature implementation session that uses this skill: - CX-Codex previously combined a running shortcut, pinned shortcut, optional chronological mode, project grouping, pinned-project priority, and manual project reordering. On the real 7420 sidebar this placed stale pinned projects above the active `codexui` directory and made one conversation appear in several navigation collections. - The user explicitly chose a simpler fixed model, so CX-Codex intentionally narrows rather than copies the full Codex option set: `置顶` conversations, then `目录`, then directory conversations. All three levels use the latest conversation timestamp; running, waiting, and unread remain row state rather than sorting priority or a separate collection. - Pinned conversations remain discoverable inside their owning directory, preserving project context. Directory collapse, search, current-thread reveal, thread menus, synchronization, routes, persistence, Runtime Store, and Android lifecycle ownership are unchanged; stored project pin/order metadata is retained for compatibility but no longer affects or advertises the fixed recent presentation. + +## Findings: Persistent Plan and Thread-goal Parity (2026-08-02) + +- Installed Windows Codex `26.721.4979` treats Plan as a persistent collaboration mode, consumes `turn/plan/updated` plus `item/plan/delta`, and offers `是,实施此计划`; it owns long-running work through `thread/goal/get`, `thread/goal/set`, `thread/goal/clear`, `thread/goal/updated`, and `thread/goal/cleared` rather than by injecting ordinary user text. +- CX-Codex previously reset Plan to execute after every submission and only exposed an unrelated one-turn requirement. Plan output was flattened or hidden, and no frontend owner hydrated or controlled the App Server's thread-goal state. +- CX-Codex now preserves Plan until the user disables it, merges structured plan events into one expandable step card, and switches to execute only when the latest eligible plan is explicitly implemented. Historical plan items remain readable through the same card fallback. +- A compact thread-goal bar now hydrates authoritative state per conversation and supports create, edit, pause, resume, clear, token/time progress, refresh recovery, and idle continuation. Continuation waits for message hydration and yields to running turns, queued work, and pending requests; a user stop pauses the active goal to prevent immediate restart. +- Follow-up UX hardening keeps Plan visibly dismissible above the composer, coalesces plan text deltas on the existing 48 ms live-update cadence, preserves an explicit submitted state after implementation, defaults older plans closed, and previews long plans around the active step. +- Thread-goal reads are deduplicated per thread and guarded by a state generation so a late read cannot overwrite a notification or edit. Mobile keeps pause/resume direct, moves edit/clear behind a compact menu, confirms clear inline with the objective, and exposes local retry without dropping the editor draft. +- Headless Playwright verified one plan card, three structured steps, the implementation action, active-goal controls, and zero horizontal overflow at phone and desktop sizes. The full 34-surface 7420 frontend regression, frontend normalizer smoke, server-module smoke, and production frontend build passed. The existing one-turn requirement remains separate and is now labeled explicitly as one-time. + +## Findings: Plan and Goal Interaction Hardening (2026-08-03) + +- Installed Windows Codex `26.721.4979` keeps Plan as a collaboration mode while long-running work remains thread-owned goal state. CX-Codex now states that coexistence directly: Plan changes how the next message is handled, while the active goal continues independently and can still be paused. +- Plan implementation state now survives refresh and cross-client synchronization by deriving `已提交执行` from the exact first implementation-confirmation message after that plan, while a newer plan closes the eligibility window. Long cards disclose collapsed context as `已隐藏 N 步` instead of silently omitting steps. +- Thread switches now reset goal-editor drafts, overflow menus, pending clear confirmations, and focus ownership. Goal-menu arrow/Home/End/Escape behavior, durable destructive confirmation, 44 px phone actions, visible mobile usage, and explicit pending labels match the shared Codex interaction boundaries while adapting them to the narrower 7420 surface. +- Headless Playwright at 393 x 852 and 1280 x 800 verified coexistence copy, cross-thread isolation, keyboard focus restoration, clear confirmation after 6.2 seconds, history-derived implementation state, visible hidden-step count, 44 px actions, and zero horizontal overflow. + +## Findings: Durable Mobile Queue and Message Fidelity (2026-08-03) + +- Desktop-grade conversation ownership requires queued work to remain thread-owned after the renderer disappears. CX-Codex now adapts that boundary to Android by persisting queue records in the 7420 Runtime Store and advancing them from a server coordinator, independent of the selected route, WebView, or Activity lifecycle. +- A queued row becomes an optimistic user bubble only when the server announces that it is starting. Its stable client id and pre-send message anchor then remain in the durable outbox until authoritative history contains the matching user item, preventing assistant output from appearing above a missing prompt after refresh. +- Manual conversation titles now have explicit durable ownership and reject later automatic name notifications. Generated images prefer the saved local path over oversized raw base64 payloads and expose a real remounting retry action on failure. +- Phone Markdown tables retain the semantic desktop table and use a focusable horizontal scroll owner instead of changing rows into cards. Headless Playwright at 393 x 852 measured 322 px visible width over 620 px content, 13 px cells, zero table-card DOM, and no browser console errors. + +## Findings: Quiet Docked Android Task Pet (2026-08-03) + +- Installed Windows Codex `26.721.4979` keeps exact-conversation navigation on the activity owner through `localConversationId` and `open-in-main-window`, carries a bounded notification count on the avatar frame, and distinguishes waiting (`Needs input`) from settled review (`Ready`). +- CX-Codex adapts those state and navigation boundaries to Android instead of reproducing the desktop window: the edge pet is a compact attention owner, a new assistant item gets one transient reply peek, a tap opens a bounded task stack, and full reading or input stays in the conversation. +- Unread ownership is now a persisted reply-event cursor. Stream chunks from the same assistant item update visible text without extending the five-second interruption window; opening the exact conversation advances the cursor and prevents stale completion attention after restart. +- The app foreground is deliberately quiet, restart returns to the docked state, and transient panel/peek state is not persisted. The native monitor remains authoritative in the background, while notification actions continue to provide the system-level fallback. + +## Findings: Restart-safe Queued Speed Handoff (2026-08-03) + +- A real failed 7420 queue record showed `queued -> queue_failed` in 53 ms with `Turn start was not confirmed after bridge restart`; its prompt was absent from the full authoritative thread history. The queued coordinator had marked the request `pending_start` before `config/batchWrite`, while that configuration call could restart app-server and trigger pending-start reconciliation. +- Queue speed configuration now remains preflight work. A row stays `queued` until any required configuration change has completed, so restart reconciliation cannot mistake preflight for a dispatched turn. A deletion during preflight is re-read and cannot be resurrected as `pending_start`. +- `config/read` suppresses no-op speed writes. Standard speed already represented by `service_tier: null` no longer performs the configuration write that triggered the observed restart; fast mode still enables its feature and changes service tier only when necessary. +- This preserves the desktop queue contract: genuine first-item failures still pause for retry, edit, or delete, while renderer-independent server ownership and stable `clientMessageId` ordering remain unchanged. + +## Findings: Structured File Citations and Legacy PDF Preview (2026-08-03) + +- Installed Windows Codex `26.721.4979` parses `codex-file-citation` as structured content, displays the basename, keeps the complete path as secondary context, and does not expose the transport directive or descriptive metadata in conversation text. +- CX-Codex previously let its broad plain-path matcher consume the directive prefix, which converted a valid absolute resume path into a nonexistent workspace-relative path. Historical local messages contain the same directive across PDF, DOCX, XLSX, and PPTX outputs, so the fix is a shared parser rather than a PDF-only string replacement. +- CX-Codex now adapts the desktop citation boundary to mobile: the basename is the compact link label, the full path remains the title and open target, and internal links stay in the current Android WebView. Invalid or incomplete directives degrade to safe text. +- The current `pdfjs-dist` standard build requires `Promise.withResolvers` and `Math.sumPrecise`, which are absent in some Android WebViews. The isolated local preview now uses PDF.js's official legacy main and worker bundles so compatibility applies on both execution contexts. +- Headless phone verification at 393 x 852 showed no protocol or metadata leakage, no document overflow, two rendered resume pages, and no browser errors or compatibility warnings. Evidence is under `output/regression-7420/file-citation-20260803`. + +## Findings: External-session Progress and Bounded History Recovery (2026-08-03) + +- Installed Windows Codex `26.727.51351` keeps request dispatch, turn start, first data, and first visible response as separate milestones. Its avatar overlay retains the owning `localConversationId`, opens through `open-in-main-window`, and keeps queued messages as a distinct composer-adjacent surface with retry and steer states. +- CX-Codex must preserve those ownership boundaries when mirroring a task executed by another desktop process. A session-file append is authoritative new progress, but it must not make 7420 re-read the complete rollout or invent a local Runtime-running state. +- Default recent-message and Runtime reads now use a bounded session-log projection. High-frequency response, reasoning, plan, command, file, MCP, and process deltas no longer evict that projection; explicit full-history and older-turn reads still use the authoritative App Server. +- Session-log recovery retains `phase: commentary`, so a progress-only tail renders under phase replies instead of becoming a fake final answer or an empty result. Native Task Pet reply freshness remains tied only to a newer `latestReplyEventSeq`. +- A 72 MB real conversation previously issued 6.7-8.0 second heavy reads every 8-10 seconds. After the boundary change, a fresh reload plus 35 seconds of live external updates produced zero heavy reads; light metadata reads peaked at 24 ms, with no RPC queue, pending server request, or uncertain Runtime request. + +## Findings: Task-pet Wake and Lossless Session Convergence (2026-08-03) + +- Installed Windows Codex `26.727.6591` still owns avatar attention through an exact `localConversationId`, dispatches `open-in-main-window`, presents a bounded notification count, and labels waiting work separately from review-ready work. +- A docked CX-Codex task pet previously stayed minimized when work arrived after an idle snapshot, and running-task replies were excluded from its badge. It now restores on task arrival, shows one five-second reply peek, counts unread reply cursors alongside waiting tasks, and clears only the exact conversation that is opened. +- Empty expanded stacks now return to the minimized pet after eight seconds. Close confirmation pauses that timer and cancellation starts a fresh timeout, matching the native Android interaction owner instead of allowing a destructive prompt to disappear mid-decision. +- Session-log ownership is split deliberately: the bounded file projection supplies immediate text, but it is not treated as structurally complete. File notifications reset a 1.8-second quiet window, after which one coalesced authoritative read restores plans, commands, file changes, approvals, and tool items without returning to per-append heavy reads. +- Real 7420 browser checks covered idle-to-task wake, `2 -> 1` unread acknowledgement, exact `fixture-running` navigation, empty-stack minimization, and close-confirmation timer ownership. Frontend normalization, production build, CLI build, server-module smoke, Android policy tests, and the full frontend regression remained the verification boundary. + +## Findings: Queue Generation Isolation and Stable Local Merge (2026-08-04) + +- Installed Windows Codex `26.727.6591` keeps queued work as a composer-adjacent list with stable message identity, explicit retry/edit/delete ownership, and first-failure blocking. Queue UI changes do not impersonate App Server turn lifecycle events. +- CX-Codex had two ownership leaks: its internal `runtime/queue/updated` broadcast reconciled a newly claimed request against the previous turn's completed snapshot, and a second-precision pre-start `thread/read` could release the active lease before `turn/start` materialized. +- Internal queue notifications now remain replayable UI signals but cannot mutate or reconcile Runtime state. A same-second non-running thread snapshot is ignored during a bounded 20-second materialization window, keeping queued work isolated behind the real active turn without making restart recovery permanent. +- Frontend persistence acknowledgements merge into the latest queue by stable `clientMessageId`, cancel server rows deleted during an in-flight write, and stop behind the first failed local row. After a complete sync, an exact-set server reorder makes the retained UI order authoritative without letting stale or partial client state move another client's message. +- A real controlled 7420 run recorded `BASE2 -> QUEUE2-A -> QUEUE2-B` as three distinct turns. A/B reached first token in 2.27/1.85 seconds, while a separate direct no-tool reply reached first token in 1.71 seconds; no queued prompt was lost or injected into the previous turn. A second run reordered `A -> B -> C` into `C -> A -> B`, and both the immediate server list and authoritative history followed that exact order. diff --git a/.opencodereview/rule.json b/.opencodereview/rule.json new file mode 100644 index 0000000..6afe634 --- /dev/null +++ b/.opencodereview/rule.json @@ -0,0 +1,37 @@ +{ + "include": [ + "android/app/src/test/**/*.java", + "**/*Test.java", + "**/*Tests.java", + "**/*.{test,spec}.{ts,tsx,js,jsx,mjs,cjs}" + ], + "exclude": [ + "node_modules/**", + "dist/**", + "dist-cli/**", + "coverage/**", + "android/**/build/**" + ], + "rules": [ + { + "path": "src/server/**/*.{ts,js}", + "rule": "Preserve the CLI -> Express bridge -> Codex app-server contract. Treat session-log reads as potentially lossy fallback data, keep normal thread/read responses authoritative, and verify cache invalidation, thread identity, response-view windows, abort handling, and per-thread ordering. Flag optimizations that can hide structured messages such as plans, command executions, file changes, approvals, or tool calls.", + "merge_system_rule": true + }, + { + "path": "src/**/*.{ts,vue}", + "rule": "Preserve Vue reactivity and the desktop/mobile runtime state contract. Check cleanup of timers, listeners, and abort controllers; guard stale async responses by thread identity or version; retain optimistic messages until authoritative reconciliation; and avoid visual-only changes that diverge from the installed Codex desktop behavior.", + "merge_system_rule": true + }, + { + "path": "android/app/src/**/*.java", + "rule": "Preserve Android foreground-service, notification, overlay, and activity lifecycle behavior. Check main-thread UI access, background execution restrictions, PendingIntent identity, configuration changes, accessibility timeouts, lock-screen privacy, resource cleanup, and parity between native task-pet state and the web preview.", + "merge_system_rule": true + }, + { + "path": "scripts/**/*.{ts,mjs,js,ps1}", + "rule": "Keep verification deterministic and behavior-based. Distinguish expected diagnostic logs from failures, avoid timing-only assertions when state can be observed directly, preserve Windows PowerShell compatibility, and make regression checks fail with a specific actionable message.", + "merge_system_rule": true + } + ] +} diff --git a/README.md b/README.md index 8f3952d..8ffd9af 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Self-hosted OpenAI Codex Web UI and Android client bridge. ## 快速入口 - 最新 Release: [github.com/Qjzn/CX-Codex/releases/latest](https://github.com/Qjzn/CX-Codex/releases/latest) +- 2.7.3 发布说明: [docs/release-notes-2.7.3.zh-CN.md](./docs/release-notes-2.7.3.zh-CN.md) - 2.7.2 发布说明: [docs/release-notes-2.7.2.zh-CN.md](./docs/release-notes-2.7.2.zh-CN.md) - 2.6.0 发布说明: [docs/release-notes-2.6.0.zh-CN.md](./docs/release-notes-2.6.0.zh-CN.md) - 2.5.9 发布说明: [docs/release-notes-2.5.9.zh-CN.md](./docs/release-notes-2.5.9.zh-CN.md) diff --git a/android/app/src/main/java/com/cxcodex/bridge/MainActivity.java b/android/app/src/main/java/com/cxcodex/bridge/MainActivity.java index 39662d7..9acc42a 100644 --- a/android/app/src/main/java/com/cxcodex/bridge/MainActivity.java +++ b/android/app/src/main/java/com/cxcodex/bridge/MainActivity.java @@ -35,6 +35,7 @@ public class MainActivity extends BridgeActivity { private static final long CONNECTION_TIMEOUT_MS = 15000L; + private static volatile boolean appForeground; private boolean initialCreateComplete; private boolean mainFrameLoadFailed; private LinearLayout connectionOverlay; @@ -84,6 +85,26 @@ public void onResume() { openPendingTaskPetThread(); } + @Override + public void onStart() { + super.onStart(); + appForeground = true; + TaskPetOverlayService.refreshPresentation(this); + } + + @Override + public void onStop() { + if (!isChangingConfigurations()) { + appForeground = false; + TaskPetOverlayService.refreshPresentation(this); + } + super.onStop(); + } + + static boolean isAppForeground() { + return appForeground; + } + private void captureTaskPetThreadFromIntent(Intent intent) { if (intent == null || !intent.hasExtra(TaskPetOverlayService.EXTRA_THREAD_ID)) { return; diff --git a/android/app/src/main/java/com/cxcodex/bridge/TaskPetOverlayService.java b/android/app/src/main/java/com/cxcodex/bridge/TaskPetOverlayService.java index 6365653..b8797ba 100644 --- a/android/app/src/main/java/com/cxcodex/bridge/TaskPetOverlayService.java +++ b/android/app/src/main/java/com/cxcodex/bridge/TaskPetOverlayService.java @@ -1,6 +1,7 @@ package com.cxcodex.bridge; import android.animation.ValueAnimator; +import android.app.KeyguardManager; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; @@ -31,6 +32,7 @@ import android.view.MotionEvent; import android.view.View; import android.view.WindowManager; +import android.view.accessibility.AccessibilityManager; import android.view.animation.Interpolator; import android.view.animation.PathInterpolator; import android.view.inputmethod.InputMethodManager; @@ -97,14 +99,18 @@ public final class TaskPetOverlayService extends Service { private static final int OMITTED_PROVISIONAL_MISSING_LIMIT = 3; private static final int REPLY_CONFIRMATION_MISSING_LIMIT = 3; private static final int ROOT_PADDING_DP = 6; - private static final int PANEL_WIDTH_DP = 282; - private static final int PANEL_CONTENT_WIDTH_DP = 258; - private static final int MASCOT_WIDTH_DP = 72; - private static final int MASCOT_HEIGHT_DP = 79; + private static final int PANEL_WIDTH_DP = 248; + private static final int PANEL_CONTENT_WIDTH_DP = 224; + private static final int MASCOT_WIDTH_DP = 56; + private static final int MASCOT_HEIGHT_DP = 62; private static final int MINI_SIZE_DP = 48; - private static final int TASK_ROW_HEIGHT_DP = 88; - private static final int COMPACT_PREVIEW_HEIGHT_DP = 86; + private static final int TASK_ROW_HEIGHT_DP = 64; + private static final int COMPACT_PREVIEW_WIDTH_DP = 224; + private static final int COMPACT_PREVIEW_HEIGHT_DP = 60; private static final int COMPACT_PREVIEW_GAP_DP = 4; + private static final int EDGE_VISIBLE_HANDLE_DP = 32; + private static final long REPLY_PEEK_TIMEOUT_MS = 5_000L; + private static final long TASK_STACK_TIMEOUT_MS = 8_000L; private static final AtomicBoolean RUNNING = new AtomicBoolean(false); private static final Interpolator EASE_OUT = new PathInterpolator(0.22f, 1f, 0.36f, 1f); @@ -157,6 +163,7 @@ public final class TaskPetOverlayService extends Service { private boolean destroyed; private boolean sendingReply; private boolean closeConfirmationVisible; + private boolean replyPeekVisible; private int panelAnimationToken; private int pendingCollapsedPetX = -1; private int pendingCollapsedPetY = -1; @@ -232,6 +239,13 @@ public void run() { } }; + private final Runnable hideReplyPeekRunnable = () -> hideTransientReplyPeek(true); + + private final Runnable collapseTaskStackRunnable = () -> { + if (!expanded || sendingReply || closeConfirmationVisible) return; + setExpanded(false); + }; + public static boolean isRunning() { return RUNNING.get(); } @@ -271,6 +285,13 @@ public static void stop(Context context) { context.stopService(new Intent(context, TaskPetOverlayService.class)); } + public static void refreshPresentation(Context context) { + if (!isRunning()) return; + context.startService( + new Intent(context, TaskPetOverlayService.class).setAction(ACTION_UPDATE) + ); + } + public static void submitNotificationReply( Context context, String threadId, @@ -529,6 +550,8 @@ public void onDestroy() { persistMonitorDiagnostics(true); mainHandler.removeCallbacks(pollRunnable); mainHandler.removeCallbacks(eventStreamReconnectRunnable); + mainHandler.removeCallbacks(hideReplyPeekRunnable); + mainHandler.removeCallbacks(collapseTaskStackRunnable); stopEventStream(); releaseTaskWakeLock(); removeOverlay(); @@ -630,6 +653,10 @@ private boolean shouldShowOverlay() { && android.provider.Settings.canDrawOverlays(this); } + private boolean shouldAttachOverlay() { + return shouldShowOverlay() && !MainActivity.isAppForeground(); + } + private int activeTaskCount() { int count = 0; for (TaskItem task : tasks) { @@ -694,7 +721,7 @@ private void releaseTaskWakeLock() { } private void syncOverlayVisibility() { - if (shouldShowOverlay()) { + if (shouldAttachOverlay()) { if (overlayRoot == null && windowManager != null) createOverlay(); return; } @@ -702,6 +729,8 @@ private void syncOverlayVisibility() { } private void removeOverlay() { + mainHandler.removeCallbacks(hideReplyPeekRunnable); + mainHandler.removeCallbacks(collapseTaskStackRunnable); if (positionAnimator != null) positionAnimator.cancel(); positionAnimator = null; if (mascot != null) mascot.animate().cancel(); @@ -743,6 +772,7 @@ private void removeOverlay() { replySendButton = null; windowParams = null; expanded = false; + replyPeekVisible = false; closeConfirmationVisible = false; lastPetMode = ""; lastRenderedTaskSignature = ""; @@ -782,19 +812,7 @@ private void createOverlay() { taskPanel.addView(panelHeader, new LinearLayout.LayoutParams(dp(PANEL_CONTENT_WIDTH_DP), dp(48))); taskPanel.addView(taskList, listParams); recentSection = buildRecentSection(); - LinearLayout.LayoutParams recentParams = new LinearLayout.LayoutParams( - dp(PANEL_CONTENT_WIDTH_DP), - LinearLayout.LayoutParams.WRAP_CONTENT - ); - recentParams.topMargin = dp(8); - taskPanel.addView(recentSection, recentParams); replyComposer = buildReplyComposer(); - LinearLayout.LayoutParams composerParams = new LinearLayout.LayoutParams( - dp(PANEL_CONTENT_WIDTH_DP), - LinearLayout.LayoutParams.WRAP_CONTENT - ); - composerParams.topMargin = dp(8); - taskPanel.addView(replyComposer, composerParams); closeConfirmPanel = buildCloseConfirmPanel(); LinearLayout.LayoutParams closeConfirmParams = new LinearLayout.LayoutParams( dp(PANEL_CONTENT_WIDTH_DP), @@ -806,7 +824,7 @@ private void createOverlay() { compactPreview = buildCompactPreview(); LinearLayout.LayoutParams compactPreviewParams = new LinearLayout.LayoutParams( - dp(PANEL_WIDTH_DP), + dp(COMPACT_PREVIEW_WIDTH_DP), dp(COMPACT_PREVIEW_HEIGHT_DP) ); compactPreviewParams.bottomMargin = dp(COMPACT_PREVIEW_GAP_DP); @@ -824,10 +842,11 @@ private void createOverlay() { overlayRoot.addView(miniMascot, miniParams); attachDragAndClick(miniMascot, () -> { setMinimized(false); - if (tasks.isEmpty()) setExpanded(true); + setExpanded(true); }); minimized = true; + replyPeekVisible = false; mascot.setVisibility(minimized ? View.GONE : View.VISIBLE); miniMascot.setVisibility(minimized ? View.VISIBLE : View.GONE); @@ -851,13 +870,14 @@ private void createOverlay() { windowParams.x = initialX; windowParams.y = initialY; windowManager.addView(overlayRoot, windowParams); + overlayRoot.post(this::snapPetToNearestEdge); } private LinearLayout buildCompactPreview() { LinearLayout preview = new LinearLayout(this); preview.setOrientation(LinearLayout.VERTICAL); preview.setGravity(Gravity.CENTER_VERTICAL); - preview.setPadding(dp(12), dp(8), dp(12), dp(8)); + preview.setPadding(dp(11), dp(6), dp(11), dp(6)); preview.setBackground(touchBackground(Color.rgb(253, 253, 254), 15, Color.argb(28, 42, 114, 232))); preview.setElevation(dp(7)); preview.setVisibility(View.GONE); @@ -867,23 +887,23 @@ private LinearLayout buildCompactPreview() { compactPreviewContext.setEllipsize(android.text.TextUtils.TruncateAt.END); preview.addView(compactPreviewContext, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(18) + dp(14) )); compactPreviewReply = text("", 13, Color.rgb(30, 36, 48), Typeface.BOLD); - compactPreviewReply.setMaxLines(2); + compactPreviewReply.setMaxLines(1); compactPreviewReply.setEllipsize(android.text.TextUtils.TruncateAt.END); compactPreviewReply.setGravity(Gravity.CENTER_VERTICAL); preview.addView(compactPreviewReply, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(38) + dp(24) )); compactPreviewFreshness = text("", 9, Color.rgb(42, 103, 190), Typeface.NORMAL); compactPreviewFreshness.setSingleLine(true); preview.addView(compactPreviewFreshness, new LinearLayout.LayoutParams( LinearLayout.LayoutParams.MATCH_PARENT, - dp(14) + dp(12) )); return preview; } @@ -1033,23 +1053,18 @@ private FrameLayout buildMascot() { petImage = new ImageView(this); petImage.setImageResource(R.drawable.cx_pet_idle); petImage.setScaleType(ImageView.ScaleType.FIT_CENTER); - FrameLayout.LayoutParams imageParams = new FrameLayout.LayoutParams(dp(70), dp(73), Gravity.TOP | Gravity.CENTER_HORIZONTAL); + FrameLayout.LayoutParams imageParams = new FrameLayout.LayoutParams(dp(56), dp(60), Gravity.TOP | Gravity.CENTER_HORIZONTAL); root.addView(petImage, imageParams); petStatus = text("待命", 10, Color.rgb(53, 62, 78), Typeface.BOLD); - petStatus.setGravity(Gravity.CENTER); - petStatus.setBackground(rounded(Color.argb(238, 255, 255, 255), 999)); - FrameLayout.LayoutParams statusParams = new FrameLayout.LayoutParams(dp(44), dp(20), Gravity.BOTTOM | Gravity.START); - statusParams.leftMargin = dp(1); - root.addView(petStatus, statusParams); + petStatus.setVisibility(View.GONE); badge = text("", 11, Color.WHITE, Typeface.BOLD); badge.setGravity(Gravity.CENTER); badge.setBackground(rounded(Color.rgb(42, 114, 232), 999)); badge.setElevation(dp(4)); - FrameLayout.LayoutParams badgeParams = new FrameLayout.LayoutParams(dp(24), dp(24), Gravity.TOP | Gravity.END); - badgeParams.topMargin = dp(3); - badgeParams.rightMargin = dp(1); + FrameLayout.LayoutParams badgeParams = new FrameLayout.LayoutParams(dp(20), dp(20), Gravity.TOP | Gravity.END); + badgeParams.topMargin = dp(1); root.addView(badge, badgeParams); return root; } @@ -1081,12 +1096,19 @@ private void renderTasks() { int activeCount = 0; int completedCount = 0; int retryCount = 0; + int attentionCount = 0; boolean waiting = false; for (TaskItem task : tasks) { if (TaskPetRuntimePolicy.isActiveTaskState(task.state)) activeCount += 1; if ("completed".equals(task.state)) completedCount += 1; if ("retry".equals(task.state)) retryCount += 1; if ("waiting".equals(task.state)) waiting = true; + if (TaskPetRuntimePolicy.shouldCountAttention( + task.state, + task.readAcknowledged, + task.latestReplyEventSeq, + task.readThroughReplyEventSeq + )) attentionCount += 1; } String foregroundTaskSignature = foregroundTaskSignature(activeCount); if ( @@ -1099,21 +1121,20 @@ private void renderTasks() { } if ( taskList == null || badge == null || miniBadge == null || petImage == null - || miniPetImage == null || petStatus == null || recentList == null + || miniPetImage == null || petStatus == null ) return; if (tasks.isEmpty() && !expanded && !minimized) { + hideTransientReplyPeek(false); setMinimized(true); - } else if (!tasks.isEmpty() && minimized) { - setMinimized(false); } - badge.setVisibility(tasks.isEmpty() ? View.GONE : View.VISIBLE); - badge.setText(tasks.size() > 99 ? "99+" : String.valueOf(tasks.size())); + badge.setVisibility(attentionCount == 0 ? View.GONE : View.VISIBLE); + badge.setText(attentionCount > 99 ? "99+" : String.valueOf(attentionCount)); int badgeColor = retryCount > 0 ? Color.rgb(196, 73, 65) : waiting ? Color.rgb(214, 126, 28) : Color.rgb(42, 114, 232); badge.setBackground(rounded(badgeColor, 999)); - miniBadge.setVisibility(tasks.isEmpty() ? View.GONE : View.VISIBLE); - miniBadge.setText(tasks.size() > 9 ? "9+" : String.valueOf(tasks.size())); + miniBadge.setVisibility(attentionCount == 0 ? View.GONE : View.VISIBLE); + miniBadge.setText(attentionCount > 9 ? "9+" : String.valueOf(attentionCount)); miniBadge.setBackground(rounded(badgeColor, 999)); String petMode = waiting || retryCount > 0 ? "waiting" @@ -1123,13 +1144,15 @@ private void renderTasks() { : waiting ? "待处理" : activeCount > 0 ? "工作中" : completedCount > 0 ? "已完成" : "待命"; updatePetImage(petMode); petStatus.setText(stateLabel); - String countLabel = tasks.isEmpty() ? "没有任务" : tasks.size() + " 个任务"; + String countLabel = tasks.isEmpty() + ? "没有任务" + : tasks.size() + " 个任务," + attentionCount + " 条待处理"; mascot.setContentDescription("CX-Codex 任务宠物," + stateLabel + "," + countLabel + ",点击查看任务进展"); miniMascot.setContentDescription("已最小化的 CX-Codex 任务宠物," + stateLabel + "," + countLabel + ",点击恢复"); if (panelSummary != null) { panelSummary.setText( activeCount > 0 - ? activeCount + " 个任务 · 最新回复实时更新" + (retryCount > 0 ? " · " + retryCount + " 条待重试" : "") + ? activeCount + " 个任务" + (attentionCount > 0 ? " · " + attentionCount + " 条待处理" : "") : retryCount > 0 ? retryCount + " 条回复待重试" : completedCount > 0 @@ -1209,10 +1232,95 @@ private boolean shouldShowCompactPreview() { return compactPreview != null && !expanded && !minimized + && replyPeekVisible && !tasks.isEmpty() && (taskPanel == null || taskPanel.getVisibility() != View.VISIBLE); } + private void showTransientReplyPeek(TaskItem task, boolean replyItemChanged) { + if (overlayRoot == null || windowParams == null || !shouldAttachOverlay()) return; + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(KEYGUARD_SERVICE); + boolean screenInteractive = powerManager != null && powerManager.isInteractive(); + boolean deviceLocked = keyguardManager != null && keyguardManager.isKeyguardLocked(); + if (!TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + MainActivity.isAppForeground(), + screenInteractive, + deviceLocked, + replyItemChanged, + task.latestReplyEventSeq, + task.readThroughReplyEventSeq + )) return; + if (expanded) { + scheduleTaskStackCollapse(); + return; + } + if (minimized) setMinimized(false); + setReplyPeekVisible(true); + mainHandler.removeCallbacks(hideReplyPeekRunnable); + mainHandler.postDelayed( + hideReplyPeekRunnable, + recommendedTimeoutMs( + REPLY_PEEK_TIMEOUT_MS, + AccessibilityManager.FLAG_CONTENT_TEXT | AccessibilityManager.FLAG_CONTENT_CONTROLS + ) + ); + } + + private void hideTransientReplyPeek(boolean tuckAfterHide) { + mainHandler.removeCallbacks(hideReplyPeekRunnable); + if (!replyPeekVisible) return; + setReplyPeekVisible(false); + if (tuckAfterHide && !expanded) snapPetToNearestEdge(); + } + + private void setReplyPeekVisible(boolean visible) { + if (replyPeekVisible == visible) { + syncCompactPreviewVisibility(); + return; + } + int petX = windowParams == null + ? 0 + : windowParams.x + collapsedMascotOffsetX(minimized); + int petY = windowParams == null + ? 0 + : windowParams.y + collapsedCompactOffsetY(minimized); + replyPeekVisible = visible; + syncCompactPreviewVisibility(); + if (windowParams == null || windowManager == null || overlayRoot == null || expanded) return; + windowParams.x = petX - collapsedMascotOffsetX(minimized); + windowParams.y = Math.max(0, petY - collapsedCompactOffsetY(minimized)); + if (visible) { + int rootWidth = dp(collapsedRootWidthDp(minimized)); + int margin = dp(8); + int maxX = Math.max(margin, getResources().getDisplayMetrics().widthPixels - rootWidth - margin); + windowParams.x = anchoredRight ? maxX : margin; + } + windowManager.updateViewLayout(overlayRoot, windowParams); + } + + private void scheduleTaskStackCollapse() { + mainHandler.removeCallbacks(collapseTaskStackRunnable); + if (!expanded) return; + mainHandler.postDelayed( + collapseTaskStackRunnable, + recommendedTimeoutMs( + TASK_STACK_TIMEOUT_MS, + AccessibilityManager.FLAG_CONTENT_TEXT | AccessibilityManager.FLAG_CONTENT_CONTROLS + ) + ); + } + + private long recommendedTimeoutMs(long originalTimeoutMs, int contentFlags) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return originalTimeoutMs; + AccessibilityManager manager = (AccessibilityManager) getSystemService(ACCESSIBILITY_SERVICE); + if (manager == null) return originalTimeoutMs; + return Math.max( + originalTimeoutMs, + manager.getRecommendedTimeoutMillis((int) originalTimeoutMs, contentFlags) + ); + } + private void updatePetImage(String mode) { if (mode.equals(lastPetMode)) return; lastPetMode = mode; @@ -1303,10 +1411,8 @@ private void stopPetAnimation(@Nullable ImageView target) { private String renderTaskList() { taskList.removeAllViews(); - renderRecentThreads(); String renderedReplyTaskKey = ""; int maxVisibleRows = getResources().getDisplayMetrics().heightPixels < dp(700) ? 2 : 3; - if (!replyThreadId.isEmpty()) maxVisibleRows = 1; int visibleCount = Math.min(tasks.size(), maxVisibleRows); if (visibleCount == 0) { TextView empty = text("没有正在运行的任务", 13, Color.rgb(87, 98, 117), Typeface.NORMAL); @@ -1428,7 +1534,7 @@ private View buildTaskRow(TaskItem task) { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); - row.setPadding(dp(10), dp(7), dp(9), dp(7)); + row.setPadding(dp(9), dp(5), dp(8), dp(5)); row.setBackground(touchBackground(Color.rgb(242, 245, 249), 11, Color.argb(28, 42, 114, 232))); row.setClickable(canOpenThread); row.setFocusable(canOpenThread); @@ -1464,7 +1570,7 @@ private View buildTaskRow(TaskItem task) { contextView.setSingleLine(true); contextView.setEllipsize(android.text.TextUtils.TruncateAt.END); TextView replyView = text(replyPreview, 12, Color.rgb(30, 36, 48), Typeface.NORMAL); - replyView.setMaxLines(2); + replyView.setMaxLines(1); replyView.setEllipsize(android.text.TextUtils.TruncateAt.END); replyView.setGravity(Gravity.CENTER_VERTICAL); TextView freshness = text( @@ -1476,23 +1582,10 @@ private View buildTaskRow(TaskItem task) { Typeface.NORMAL ); freshness.setSingleLine(true); - copy.addView(contextView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(18))); - copy.addView(replyView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(38))); - copy.addView(freshness, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(16))); - row.addView(copy, new LinearLayout.LayoutParams(0, dp(72), 1f)); - - TextView reply = text("回复", 11, Color.rgb(42, 103, 190), Typeface.BOLD); - reply.setGravity(Gravity.CENTER); - reply.setClickable(true); - reply.setFocusable(true); - reply.setContentDescription("直接回复" + task.title); - reply.setBackground(touchBackground(Color.WHITE, 999, Color.argb(30, 42, 114, 232))); - reply.setOnClickListener(view -> { - view.performHapticFeedback(android.view.HapticFeedbackConstants.KEYBOARD_TAP); - showReplyComposer(task); - }); - reply.setVisibility(canOpenThread ? View.VISIBLE : View.GONE); - row.addView(reply, new LinearLayout.LayoutParams(dp(44), dp(38))); + copy.addView(contextView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(15))); + copy.addView(replyView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(24))); + copy.addView(freshness, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(13))); + row.addView(copy, new LinearLayout.LayoutParams(0, dp(52), 1f)); return row; } @@ -1729,6 +1822,8 @@ private boolean applySuccessfulReply( nextRuntimeState, 0L, 0L, + "", + 0L, System.currentTimeMillis(), 0L, !awaitingConfirmation, @@ -1748,6 +1843,9 @@ private boolean applySuccessfulReply( task.latestActivity = clean(message, 80); task.latestReply = ""; task.latestReplyEventSeq = 0L; + task.latestReplyItemId = ""; + task.readThroughReplyEventSeq = 0L; + task.latestReplyUpdatedAtMs = 0L; task.lastUpdatedAtMs = System.currentTimeMillis(); task.lastNoProgressReminderAtMs = 0L; task.requestAccepted = !awaitingConfirmation; @@ -1794,6 +1892,8 @@ private void handleNotificationReply(Intent intent) { "pending_start", 0L, 0L, + "", + 0L, now, 0L, false, @@ -1812,6 +1912,9 @@ private void handleNotificationReply(Intent intent) { task.latestActivity = clean(message, 80); task.latestReply = ""; task.latestReplyEventSeq = 0L; + task.latestReplyItemId = ""; + task.readThroughReplyEventSeq = 0L; + task.latestReplyUpdatedAtMs = 0L; task.lastUpdatedAtMs = now; task.lastNoProgressReminderAtMs = 0L; task.requestAccepted = false; @@ -2034,6 +2137,8 @@ private void ensurePersistedReplyAttemptTask() { awaitingConfirmation ? "pending_start" : "retry", 0L, 0L, + "", + 0L, now, 0L, false, @@ -2054,6 +2159,9 @@ private void ensurePersistedReplyAttemptTask() { task.latestReply = ""; task.lastEventSeq = 0L; task.latestReplyEventSeq = 0L; + task.latestReplyItemId = ""; + task.readThroughReplyEventSeq = 0L; + task.latestReplyUpdatedAtMs = 0L; task.lastUpdatedAtMs = now; task.lastNoProgressReminderAtMs = 0L; task.requestAccepted = false; @@ -2087,20 +2195,26 @@ private String taskListSignature() { } private int freshnessBucket(TaskItem task) { - long ageMs = Math.max(0L, System.currentTimeMillis() - task.lastUpdatedAtMs); + long ageMs = Math.max(0L, System.currentTimeMillis() - freshnessTimestampMs(task)); if (ageMs < 15_000L) return 0; if (ageMs < 60_000L) return 1; return 2 + (int) Math.min(59L, ageMs / 60_000L); } private String freshnessText(TaskItem task) { - long ageMs = Math.max(0L, System.currentTimeMillis() - task.lastUpdatedAtMs); + long ageMs = Math.max(0L, System.currentTimeMillis() - freshnessTimestampMs(task)); if (ageMs < 15_000L) return "刚刚"; if (ageMs < 60_000L) return "1 分钟内"; long minutes = Math.max(1L, ageMs / 60_000L); return Math.min(59L, minutes) + " 分钟前"; } + private long freshnessTimestampMs(TaskItem task) { + return !task.latestReply.isEmpty() && task.latestReplyUpdatedAtMs > 0L + ? task.latestReplyUpdatedAtMs + : task.lastUpdatedAtMs; + } + private boolean isNoProgress(TaskItem task) { return TaskPetRuntimePolicy.shouldNotifyNoProgress( task.state, @@ -2205,14 +2319,22 @@ public boolean onTouch(View view, MotionEvent event) { private void snapPetToNearestEdge() { int screenWidth = getResources().getDisplayMetrics().widthPixels; int screenHeight = getResources().getDisplayMetrics().heightPixels; - int margin = dp(8); - int maxX = Math.max(margin, screenWidth - overlayRoot.getWidth() - margin); - int maxY = Math.max(margin, screenHeight - overlayRoot.getHeight() - margin); - int centerX = windowParams.x + overlayRoot.getWidth() / 2; + boolean tuckAtEdge = !expanded && !replyPeekVisible; + int margin = tuckAtEdge ? 0 : dp(8); + int verticalMargin = dp(8); + int visibleHandle = dp(EDGE_VISIBLE_HANDLE_DP); + int rootWidth = expanded + ? Math.max(dp(PANEL_WIDTH_DP + ROOT_PADDING_DP * 2), overlayRoot.getWidth()) + : dp(collapsedRootWidthDp(minimized)); + int maxX = Math.max(margin, screenWidth - rootWidth - margin); + int maxY = Math.max(verticalMargin, screenHeight - overlayRoot.getHeight() - verticalMargin); + int centerX = windowParams.x + rootWidth / 2; anchoredRight = centerX >= screenWidth / 2; updateMascotGravity(); - int targetX = anchoredRight ? maxX : margin; - int targetY = Math.max(margin, Math.min(maxY, windowParams.y)); + int targetX = tuckAtEdge + ? anchoredRight ? screenWidth - visibleHandle : -(rootWidth - visibleHandle) + : anchoredRight ? maxX : margin; + int targetY = Math.max(verticalMargin, Math.min(maxY, windowParams.y)); animateWindowTo(targetX, targetY); } @@ -2266,6 +2388,7 @@ private void scheduleDragLayoutUpdate() { private void replaceTasks(String tasksJson, boolean frontendSnapshot) { List next = new ArrayList<>(); + TaskItem frontendPeekCandidate = null; long now = System.currentTimeMillis(); try { JSONArray rows = new JSONArray(tasksJson); @@ -2320,6 +2443,26 @@ private void replaceTasks(String tasksJson, boolean frontendSnapshot) { long resolvedLatestReplyEventSeq = preserveKnownLatestReply ? previous.latestReplyEventSeq : incomingLatestReplyEventSeq; + String incomingLatestReplyItemId = clean(row.optString("latestReplyItemId"), 160); + String resolvedLatestReplyItemId = preserveKnownLatestReply && previous != null + ? previous.latestReplyItemId + : incomingLatestReplyItemId.isEmpty() && previous != null && sameGeneration + ? previous.latestReplyItemId + : incomingLatestReplyItemId; + boolean resolvedReadAcknowledged = previous != null && sameGeneration + ? previous.readAcknowledged + : !frontendSnapshot && row.optBoolean("readAcknowledged", false); + long resolvedReadThroughReplyEventSeq = previous != null && sameGeneration + ? previous.readThroughReplyEventSeq + : !frontendSnapshot + ? row.has("readThroughReplyEventSeq") + ? Math.max(0L, row.optLong("readThroughReplyEventSeq", 0L)) + : resolvedReadAcknowledged ? resolvedLatestReplyEventSeq : 0L + : 0L; + if (TaskPetRuntimePolicy.hasUnreadReply( + resolvedLatestReplyEventSeq, + resolvedReadThroughReplyEventSeq + )) resolvedReadAcknowledged = false; TaskItem incoming = new TaskItem( resolvedThreadId, resolvedClientMessageId, @@ -2341,6 +2484,8 @@ private void replaceTasks(String tasksJson, boolean frontendSnapshot) { ), incomingLastEventSeq, resolvedLatestReplyEventSeq, + resolvedLatestReplyItemId, + resolvedReadThroughReplyEventSeq, row.optLong("lastUpdatedAtMs", now), row.optLong( "lastNoProgressReminderAtMs", @@ -2349,12 +2494,19 @@ private void replaceTasks(String tasksJson, boolean frontendSnapshot) { row.has("requestAccepted") ? row.optBoolean("requestAccepted", false) : previous != null && sameGeneration && previous.requestAccepted, - previous != null && sameGeneration - ? previous.readAcknowledged - : !frontendSnapshot && row.optBoolean("readAcknowledged", false), + resolvedReadAcknowledged, frontendSnapshot ? false : row.optBoolean("omittedFromFrontend", false), frontendSnapshot ? 0 : row.optInt("missingRequestPollCount", 0) ); + long incomingReplyObservedAtMs = row.has("latestReplyUpdatedAtMs") + ? Math.max(0L, row.optLong("latestReplyUpdatedAtMs", 0L)) + : Math.max(0L, row.optLong("lastUpdatedAtMs", now)); + incoming.latestReplyUpdatedAtMs = TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + previous != null && sameGeneration ? previous.latestReplyEventSeq : 0L, + incoming.latestReplyEventSeq, + previous != null && sameGeneration ? previous.latestReplyUpdatedAtMs : 0L, + incomingReplyObservedAtMs + ); if ( previous != null && !sameGeneration @@ -2396,6 +2548,20 @@ private void replaceTasks(String tasksJson, boolean frontendSnapshot) { if (previous != null && sameGeneration && incoming.hasSameVisibleContent(previous)) { incoming.lastUpdatedAtMs = previous.lastUpdatedAtMs; } + if ( + frontendSnapshot + && previous != null + && sameGeneration + && !TaskPetRuntimePolicy.hasUnreadReply( + previous.latestReplyEventSeq, + previous.readThroughReplyEventSeq + ) + && TaskPetRuntimePolicy.hasUnreadReply( + incoming.latestReplyEventSeq, + incoming.readThroughReplyEventSeq + ) + && !incoming.latestReply.isEmpty() + ) frontendPeekCandidate = incoming; next.add(incoming); } } catch (Exception ignored) { @@ -2429,6 +2595,9 @@ private void replaceTasks(String tasksJson, boolean frontendSnapshot) { tasks.addAll(next); reconcileNoProgressNotifications(); persistTasks(); + if (frontendPeekCandidate != null) { + showTransientReplyPeek(frontendPeekCandidate, true); + } } private void replaceRecentThreads(String recentThreadsJson) { @@ -2488,6 +2657,9 @@ private void persistTasks(boolean synchronous) { .put("executionState", task.runtimeState) .put("lastEventSeq", task.lastEventSeq) .put("latestReplyEventSeq", task.latestReplyEventSeq) + .put("latestReplyItemId", task.latestReplyItemId) + .put("readThroughReplyEventSeq", task.readThroughReplyEventSeq) + .put("latestReplyUpdatedAtMs", task.latestReplyUpdatedAtMs) .put("lastUpdatedAtMs", task.lastUpdatedAtMs) .put("lastNoProgressReminderAtMs", task.lastNoProgressReminderAtMs) .put("requestAccepted", task.requestAccepted) @@ -2853,6 +3025,7 @@ private List readRuntimeResults(List active) { false, 0L, 0L, + "", "" )); continue; @@ -2889,6 +3062,7 @@ private List readRuntimeResults(List active) { false, 0L, 0L, + "", "" )); continue; @@ -2921,6 +3095,7 @@ private List readRuntimeResults(List active) { false, 0L, 0L, + "", "" )); } @@ -2967,6 +3142,7 @@ private List readRuntimeResults(List active) { long latestReplyEventSeq = snapshot.has("latestReplyEventSeq") ? Math.max(0L, snapshot.optLong("latestReplyEventSeq", 0L)) : latestReply.isEmpty() ? 0L : lastEventSeq; + String latestReplyItemId = clean(snapshot.optString("latestReplyItemId"), 160); results.add(new RuntimeResult( threadId, clientMessageId, @@ -2978,6 +3154,7 @@ private List readRuntimeResults(List active) { snapshot.optBoolean("stale", false), lastEventSeq, latestReplyEventSeq, + latestReplyItemId, latestReply )); } @@ -3028,6 +3205,8 @@ private void applyRuntimeResults(List requestedTasks, List requestedTasks, List requestedTasks, List task.latestReplyEventSeq ); if (canApplyReply) { + long previousReplyEventSeq = task.latestReplyEventSeq; boolean replyChanged = !result.latestReply.equals(task.latestReply); + boolean replyItemChanged = !result.latestReplyItemId.isEmpty() + ? !result.latestReplyItemId.equals(previousReplyItemId) + : previousReply.isEmpty() && replyChanged; task.latestReply = result.latestReply; task.latestReplyEventSeq = Math.max(0L, result.latestReplyEventSeq); + task.latestReplyUpdatedAtMs = TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + previousReplyEventSeq, + task.latestReplyEventSeq, + task.latestReplyUpdatedAtMs, + System.currentTimeMillis() + ); + if (!result.latestReplyItemId.isEmpty()) { + task.latestReplyItemId = result.latestReplyItemId; + } + if (TaskPetRuntimePolicy.hasUnreadReply( + task.latestReplyEventSeq, + task.readThroughReplyEventSeq + )) task.readAcknowledged = false; if (replyChanged) { replySnapshotApplyCount += 1L; replyDiagnosticsChanged = true; @@ -3146,6 +3343,16 @@ private void applyRuntimeResults(List requestedTasks, List requestedTasks, List requestedTasks, List= 0 ? pendingCollapsedPetY : windowParams.y + collapsedCompactOffsetY(minimized); + mainHandler.removeCallbacks(hideReplyPeekRunnable); + replyPeekVisible = false; pendingCollapsedPetX = -1; pendingCollapsedPetY = -1; lastRenderedTaskSignature = ""; @@ -3574,7 +3804,10 @@ private void setExpanded(boolean nextExpanded) { .translationY(0f) .setDuration(190L) .setInterpolator(EASE_OUT) - .withEndAction(() -> commitPendingReplyRender(renderedReplyTaskKey)) + .withEndAction(() -> { + commitPendingReplyRender(renderedReplyTaskKey); + scheduleTaskStackCollapse(); + }) .start(); return; } @@ -3606,6 +3839,7 @@ private void setExpanded(boolean nextExpanded) { pendingCollapsedPetX = -1; pendingCollapsedPetY = -1; if (tasks.isEmpty()) setMinimized(true); + else snapPetToNearestEdge(); }) .start(); } @@ -3615,6 +3849,9 @@ private void collapsePanelImmediately() { if (sendingReply) return; if (replyComposer != null && replyComposer.getVisibility() == View.VISIBLE) hideReplyComposer(); if (closeConfirmationVisible) hideCloseConfirmation(); + mainHandler.removeCallbacks(collapseTaskStackRunnable); + mainHandler.removeCallbacks(hideReplyPeekRunnable); + replyPeekVisible = false; panelAnimationToken += 1; taskPanel.animate().cancel(); int petX = windowParams.x + (anchoredRight ? expandedAnchorOffset() : 0); @@ -3631,6 +3868,7 @@ private void collapsePanelImmediately() { ); windowParams.y = Math.max(0, petY - collapsedCompactOffsetY(minimized)); windowManager.updateViewLayout(overlayRoot, windowParams); + overlayRoot.post(this::snapPetToNearestEdge); } private Intent createThreadIntent(String threadId) { @@ -3718,6 +3956,12 @@ private NotificationCompat.Builder buildCompactTaskNotification( } private void notifyTaskSettled(TaskItem task, boolean failed) { + if (MainActivity.isAppForeground()) { + lastCompletionNotificationResult = "suppressed_foreground"; + lastCompletionNotificationBodySource = "none"; + persistMonitorDiagnostics(true); + return; + } ensureCompletionNotificationChannel(); String runtimeState = failed ? "failed" : task.runtimeState; if (runtimeState.isEmpty() || "running".equals(runtimeState)) runtimeState = "completed"; @@ -4165,6 +4409,9 @@ private static final class TaskItem { String runtimeState; long lastEventSeq; long latestReplyEventSeq; + String latestReplyItemId; + long readThroughReplyEventSeq; + long latestReplyUpdatedAtMs; long lastUpdatedAtMs; long lastNoProgressReminderAtMs; boolean requestAccepted; @@ -4187,6 +4434,8 @@ private static final class TaskItem { String runtimeState, long lastEventSeq, long latestReplyEventSeq, + String latestReplyItemId, + long readThroughReplyEventSeq, long lastUpdatedAtMs, long lastNoProgressReminderAtMs, boolean requestAccepted, @@ -4208,6 +4457,9 @@ private static final class TaskItem { this.runtimeState = runtimeState; this.lastEventSeq = lastEventSeq; this.latestReplyEventSeq = latestReplyEventSeq; + this.latestReplyItemId = latestReplyItemId; + this.readThroughReplyEventSeq = readThroughReplyEventSeq; + this.latestReplyUpdatedAtMs = latestReply.isEmpty() ? 0L : lastUpdatedAtMs; this.lastUpdatedAtMs = lastUpdatedAtMs; this.lastNoProgressReminderAtMs = lastNoProgressReminderAtMs; this.requestAccepted = requestAccepted; @@ -4217,7 +4469,7 @@ private static final class TaskItem { } TaskItem copy() { - return new TaskItem( + TaskItem copy = new TaskItem( threadId, clientMessageId, activityId, @@ -4232,6 +4484,8 @@ TaskItem copy() { runtimeState, lastEventSeq, latestReplyEventSeq, + latestReplyItemId, + readThroughReplyEventSeq, lastUpdatedAtMs, lastNoProgressReminderAtMs, requestAccepted, @@ -4239,6 +4493,8 @@ TaskItem copy() { omittedFromFrontend, missingRequestPollCount ); + copy.latestReplyUpdatedAtMs = latestReplyUpdatedAtMs; + return copy; } boolean hasSameVisibleContent(TaskItem other) { @@ -4247,6 +4503,7 @@ boolean hasSameVisibleContent(TaskItem other) { && detail.equals(other.detail) && latestActivity.equals(other.latestActivity) && latestReply.equals(other.latestReply) + && latestReplyItemId.equals(other.latestReplyItemId) && state.equals(other.state) && runtimeState.equals(other.runtimeState) && activeTurnId.equals(other.activeTurnId); @@ -4276,6 +4533,7 @@ private static final class RuntimeResult { final boolean stale; final long lastEventSeq; final long latestReplyEventSeq; + final String latestReplyItemId; final String latestReply; RuntimeResult( @@ -4289,6 +4547,7 @@ private static final class RuntimeResult { boolean stale, long lastEventSeq, long latestReplyEventSeq, + String latestReplyItemId, String latestReply ) { this.threadId = threadId; @@ -4301,6 +4560,7 @@ private static final class RuntimeResult { this.stale = stale; this.lastEventSeq = lastEventSeq; this.latestReplyEventSeq = latestReplyEventSeq; + this.latestReplyItemId = latestReplyItemId; this.latestReply = latestReply; } } diff --git a/android/app/src/main/java/com/cxcodex/bridge/TaskPetRuntimePolicy.java b/android/app/src/main/java/com/cxcodex/bridge/TaskPetRuntimePolicy.java index f5c249f..f378b6f 100644 --- a/android/app/src/main/java/com/cxcodex/bridge/TaskPetRuntimePolicy.java +++ b/android/app/src/main/java/com/cxcodex/bridge/TaskPetRuntimePolicy.java @@ -52,6 +52,56 @@ static boolean shouldRetainUnreadSettledTask(boolean readAcknowledged) { return !readAcknowledged; } + static boolean hasUnreadReply(long latestReplyEventSeq, long readThroughReplyEventSeq) { + return latestReplyEventSeq > 0L && latestReplyEventSeq > readThroughReplyEventSeq; + } + + static long resolveReplyUpdatedAtMs( + long currentReplyEventSeq, + long incomingReplyEventSeq, + long currentReplyUpdatedAtMs, + long observedAtMs + ) { + return incomingReplyEventSeq > 0L && incomingReplyEventSeq > currentReplyEventSeq + ? Math.max(0L, observedAtMs) + : Math.max(0L, currentReplyUpdatedAtMs); + } + + static boolean shouldRetainUnreadSettledTask( + boolean readAcknowledged, + long latestReplyEventSeq, + long readThroughReplyEventSeq + ) { + return !readAcknowledged || hasUnreadReply(latestReplyEventSeq, readThroughReplyEventSeq); + } + + static boolean shouldCountAttention( + String state, + boolean readAcknowledged, + long latestReplyEventSeq, + long readThroughReplyEventSeq + ) { + return "waiting".equals(state) + || "retry".equals(state) + || hasUnreadReply(latestReplyEventSeq, readThroughReplyEventSeq) + || ("completed".equals(state) && !readAcknowledged); + } + + static boolean shouldShowTransientReplyPeek( + boolean appForeground, + boolean screenInteractive, + boolean deviceLocked, + boolean replyItemChanged, + long latestReplyEventSeq, + long readThroughReplyEventSeq + ) { + return !appForeground + && screenInteractive + && !deviceLocked + && replyItemChanged + && hasUnreadReply(latestReplyEventSeq, readThroughReplyEventSeq); + } + static boolean shouldDropOmittedProvisional( boolean omittedFromFrontend, boolean hasThreadId, diff --git a/android/app/src/test/java/com/cxcodex/bridge/TaskPetRuntimePolicyTest.java b/android/app/src/test/java/com/cxcodex/bridge/TaskPetRuntimePolicyTest.java index 4d03393..444476d 100644 --- a/android/app/src/test/java/com/cxcodex/bridge/TaskPetRuntimePolicyTest.java +++ b/android/app/src/test/java/com/cxcodex/bridge/TaskPetRuntimePolicyTest.java @@ -278,6 +278,25 @@ public void promotesTheNewestReplyIntoTheVisibleTaskRows() { assertEquals(0, TaskPetRuntimePolicy.compareTaskRecency(100L, 100L)); } + @Test + public void advancesReplyFreshnessOnlyForANewerReplyEvent() { + long previousReplyAtMs = 1_000L; + long observedAtMs = 2_000L; + + assertEquals(previousReplyAtMs, TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + 42L, 42L, previousReplyAtMs, observedAtMs + )); + assertEquals(previousReplyAtMs, TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + 42L, 0L, previousReplyAtMs, observedAtMs + )); + assertEquals(observedAtMs, TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + 42L, 43L, previousReplyAtMs, observedAtMs + )); + assertEquals(observedAtMs, TaskPetRuntimePolicy.resolveReplyUpdatedAtMs( + 0L, 1L, 0L, observedAtMs + )); + } + @Test public void commitsReplyRenderEvidenceOnlyAfterThePanelIsActuallyVisible() { assertTrue(TaskPetRuntimePolicy.shouldCommitReplyRender(true, true, 1f, true)); @@ -301,6 +320,38 @@ public void commitsReplyRenderEvidenceWhenTheCompactPreviewIsActuallyVisible() { public void retainsAndNotifiesOnlyUnreadSettledTasks() { assertTrue(TaskPetRuntimePolicy.shouldRetainUnreadSettledTask(false)); assertFalse(TaskPetRuntimePolicy.shouldRetainUnreadSettledTask(true)); + + assertTrue(TaskPetRuntimePolicy.hasUnreadReply(43L, 42L)); + assertFalse(TaskPetRuntimePolicy.hasUnreadReply(42L, 42L)); + assertFalse(TaskPetRuntimePolicy.hasUnreadReply(0L, 0L)); + assertFalse(TaskPetRuntimePolicy.shouldRetainUnreadSettledTask(true, 42L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldRetainUnreadSettledTask(true, 43L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldRetainUnreadSettledTask(false, 0L, 0L)); + } + + @Test + public void separatesExecutionStateFromAttentionAndTransientPresentation() { + assertFalse(TaskPetRuntimePolicy.shouldCountAttention("running", true, 42L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldCountAttention("running", false, 43L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldCountAttention("waiting", true, 42L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldCountAttention("retry", true, 42L, 42L)); + assertTrue(TaskPetRuntimePolicy.shouldCountAttention("completed", false, 42L, 42L)); + + assertTrue(TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + false, true, false, true, 43L, 42L + )); + assertFalse(TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + true, true, false, true, 43L, 42L + )); + assertFalse(TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + false, false, false, true, 43L, 42L + )); + assertFalse(TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + false, true, true, true, 43L, 42L + )); + assertFalse(TaskPetRuntimePolicy.shouldShowTransientReplyPeek( + false, true, false, false, 43L, 42L + )); } @Test diff --git a/docs/changelog.zh-CN.md b/docs/changelog.zh-CN.md index 2633896..9aa1a9c 100644 --- a/docs/changelog.zh-CN.md +++ b/docs/changelog.zh-CN.md @@ -2,6 +2,18 @@ ## 未发布 +## 2.7.3 - 2026-08-04 + +- 计划模式改为持续生效,并新增结构化计划卡、一步提交实施和线程级持续目标;目标的暂停、继续、编辑、清除、预算与耗时都以 App Server 状态为准。 +- 消息排队改由 7420 服务端持久接管:切换会话、刷新页面或关闭 Android 页面后仍会按顺序发送;修复排队消息被旧完成快照误判成功、并入上一轮或静默丢失的问题。 +- 快速连续排队、删除、重试和失败阻塞改为按稳定消息 ID 合并;完整同步后才执行服务端精确重排,遇到并发变化会回退到权威顺序,不再由旧异步写入覆盖新队列。 +- 长会话优先读取最近 40 轮的本地有界投影,连续追加只读取新增字节;静默 1.8 秒后再做一次权威收敛,既减少 6–8 秒重读,也保留计划、命令、文件、审批和工具记录。 +- 已发送但尚未进入权威历史的用户消息可跨刷新恢复;外部 Codex 进程写入的 commentary 会及时显示,不再被误判为最终回复或触发高频完整会话读取。 +- Codex 文件引用会显示为可打开的真实文件名,Windows/Unicode 路径与附加属性安全保留;Android 旧版 WebView 改用 PDF.js legacy 主线程和 worker,PDF 预览不再因缺少新 JavaScript API 而空白。 +- Android 任务宠物改为安静贴边模式:默认只保留侧边入口,新助手回复短暂显示五秒,任务列表八秒自动收起,避免长期遮挡桌面。 +- 未读提醒改用持久化回复事件游标;进入对应会话后立即标记已读,普通执行中任务不再被误计为未读,也不会持续显示“工作中”。 +- 浮窗任务列表收敛为最多三条并继续直达对应会话;移除最近会话和浮窗输入框,应用位于前台时不重复展示浮窗或完成通知。 + ## 2.7.2 - 2026-08-02 - 左侧会话区收敛为“置顶会话 + 最近活动目录 + 目录内最近会话”的固定层级,目录和会话都随最近活动自然上移;新增全部收起/展开和“定位当前会话”,不再暴露与最近排序冲突的手动置顶目录或排序入口。 diff --git a/docs/release-notes-2.7.3.zh-CN.md b/docs/release-notes-2.7.3.zh-CN.md new file mode 100644 index 0000000..9ea8255 --- /dev/null +++ b/docs/release-notes-2.7.3.zh-CN.md @@ -0,0 +1,63 @@ +# CX-Codex 2.7.3:会话队列不丢消息,长会话回复更快 + +2.7.3 集中收口会话模块的可靠性与响应速度:排队消息真正由 7420 服务端持久接管,刷新、切换会话或关闭 Android 页面后仍能继续;长会话先展示最近内容,再在后台恢复完整结构;计划、持续目标、文件引用和任务宠物也一起完成稳定化。 + +## 本版重点 + +- 计划模式会持续开启,直到用户主动关闭或提交实施;结构化计划卡支持折叠、进度状态和“一步实施”。 +- 新增线程级持续目标,可暂停、继续、编辑和清除,并展示预算与耗时。目标状态以 App Server 为唯一事实来源,不会与用户消息或待处理审批抢占执行。 +- 已发送但尚未进入权威历史的消息会跨刷新恢复;外部 Codex 进程追加的 commentary 能及时出现,不再被当成空白或最终答复。 + +## 真实消息队列 + +- 排队消息在进入界面队列后立即持久化到 7420 服务端。切换会话、刷新浏览器或关闭 Android Activity 后,服务端仍会在当前轮次结束后按顺序继续发送。 +- 修复内部队列通知拿上一轮完成快照结算新消息的问题;新排队消息不会再“显示完成但没有 turnId”,也不会静默消失。 +- 修复 App Server 秒级时间戳导致新消息在启动阶段并入上一轮的问题。每条排队消息都由独立 turn 承载。 +- 快速连续添加、删除、重试和失败阻塞按稳定 `clientMessageId` 合并;完整同步后才会应用服务端精确重排,并发变化时回退到服务器权威顺序。 + +## 长会话与回复速度 + +- 默认最近消息读取优先使用本地会话日志的有界投影,最多保留最近 40 轮;大文件追加时只读取新增字节,不再每 8–10 秒重新扫描完整历史。 +- 连续写入停止 1.8 秒后只做一次权威收敛,补回计划、命令执行、文件变化、审批、MCP 和工具项目,速度优化不会牺牲结构化消息。 +- 72 MB 真实长会话中,原先反复出现的 6.7–8.0 秒重读已消失;最终普通无工具回复从接受到首个助手数据约 1.7 秒,排队回复首字约 1.9–2.3 秒。 + +## 文件引用与 Android + +- `:codex-file-citation` 现在显示真实文件名并打开完整路径,不再泄漏内部指令文本或附加元数据;Windows 路径、空格、Unicode 和转义属性都能安全处理。 +- Android 本地 PDF 预览改用 PDF.js legacy 主线程与 worker,在缺少 `Promise.withResolvers` 或 `Math.sumPrecise` 的旧 WebView 中也能渲染。 +- Android 任务宠物默认安静贴边,只在新助手回复时短暂显示五秒,任务栈八秒后自动收起;进入 CX-Codex 前台后不重复显示浮窗或完成通知。 +- 未读状态由持久化回复事件游标管理。打开准确会话后立即清除对应提醒,普通运行状态不再被误计为未读。 + +## 安全与兼容边界 + +- 本版没有放宽文件、工作区、Cookie、WebSocket、远程入口或 App Server 权限边界;文件预览仍受服务端允许路径和登录态约束。 +- 会话日志投影只用于快速显示可恢复文本,完整结构仍由权威 `thread/read` 收敛,不宣称完全对齐所有未来 App Server schema。 +- 正式 Android APK 继续由 GitHub Release 工作流使用固定证书构建并校验证书 SHA-256;源码不内置私人服务地址或 Firebase 项目凭据。 + +## 安装或升级 + +Windows 服务端可继续使用正式 Release 安装或升级;`RemoteQuick` 和 `JsonOutput` 合同保持兼容: + +```powershell +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/Qjzn/CX-Codex/main/scripts/bootstrap-windows.ps1'))) -RemoteQuick -JsonOutput +``` + +Android 用户可从本 Release 下载 `cx-codex-android-v2.7.3.apk` 覆盖安装。正式 APK 与 `2.5.9+` 使用同一签名,可保留已有服务地址和设置。 + +官方卸载入口仍可默认保留访问配置、Codex 登录态、工作区和 Android 正式签名: + +```powershell +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/Qjzn/CX-Codex/main/scripts/uninstall-windows.ps1'))) -JsonOutput +``` + +请同时下载对应 `.sha256` 文件校验 Release ZIP 或 APK 的 SHA-256。 + +## 发布验证 + +- 前端 normalizer、服务模块、前端生产构建、CLI 构建和侧栏数据回归通过。 +- 完整 7420 前端套件在最终队列顺序实现上再次覆盖 36 个桌面、手机横竖屏和折叠屏场景,耗时 590.3 秒。 +- 真实队列验证得到 `BASE -> A -> B` 三个独立 turn;重排验证得到 `BASE -> C -> A -> B` 四个独立 turn,服务端列表与权威会话历史一致。 +- 最终会话列表检查覆盖 239 个活跃会话、20 个目录和 6 个可读置顶会话,零 RPC 重试;服务健康状态中活动/等待 RPC、待处理请求、未知执行、线程租约和消息队列均为零。 +- Release ZIP、npm 打包边界、Windows 产品化、Android 正式签名和所有 SHA-256 会在标签工作流中再次验证。 + +标签发布前,本版仍属于候选版本。只有主分支 CI、Release 工作流、公开 ZIP/APK、SHA-256 和固定 Android 签名全部通过后,才视为正式稳定版本。 diff --git a/package-lock.json b/package-lock.json index de15e96..e4e975f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cx-codex", - "version": "2.7.2", + "version": "2.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cx-codex", - "version": "2.7.2", + "version": "2.7.3", "license": "MIT", "dependencies": { "@capacitor/app": "^8.1.1", diff --git a/package.json b/package.json index 868ae98..8cb73d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cx-codex", - "version": "2.7.2", + "version": "2.7.3", "description": "CX-Codex Web UI and browser bridge for Windows, Linux, Android, LAN, and remote self-hosted access", "type": "module", "license": "MIT", diff --git a/scripts/regression-7420-frontend.ps1 b/scripts/regression-7420-frontend.ps1 index 43640c8..f1a700d 100644 --- a/scripts/regression-7420-frontend.ps1 +++ b/scripts/regression-7420-frontend.ps1 @@ -412,6 +412,8 @@ function Assert-RuntimeSnapshotOrderingSource { $sourcePath = Join-Path (Get-Location) "src\composables\useDesktopState.ts" $source = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourcePath $gatewaySource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\api\codexGateway.ts") + $runtimeQueueClientSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\api\runtimeMessageQueue.ts") + $runtimeQueueServerSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\runtimeMessageQueue.ts") $serverSnapshotSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\appServerThreadRuntimeSnapshot.ts") $conversationSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\components\content\ThreadConversation.vue") Assert-True ($source -match "const\s+currentEventSeq\s*=\s*Math\.max\([\s\S]*?latestRuntimeEventSeqByThreadId\.get\(threadId\)[\s\S]*?shouldApplyRuntimeSnapshotVersion\(\{\s*lastEventSeq:\s*currentEventSeq\s*\},\s*snapshot\)") "runtime snapshots must be checked against the latest buffered event sequence" @@ -419,13 +421,15 @@ function Assert-RuntimeSnapshotOrderingSource { Assert-True ($source -match "rememberLatestRuntimeEventSequence\(threadId,\s*notification\.seq\)[\s\S]*?method\.endsWith\('/delta'\)") "delta events must record their latest sequence before taking the non-reactive fast path" Assert-True ($source -match "method\.endsWith\('/delta'\)[\s\S]*?isRuntimeExecutionActiveState\(currentState\)[\s\S]*?markThreadLiveExecutionSignal\(threadId\)[\s\S]*?isRuntimeExecutionSettledState\(currentState\)\)\s*return") "high-frequency deltas must not rewrite reactive runtime state or revive a settled turn" Assert-True ($source -match "const\s+initialRuntimeSnapshotApplied\s*=\s*applyRuntimeSnapshotState\(threadId,\s*snapshot\)[\s\S]*?refreshSettledSnapshotMessagesFromRpc") "foreground recovery must apply the lightweight runtime snapshot before a heavy history refresh" - Assert-True ($source -match "preferCachedMessages:\s*shouldShowLoading") "cold thread selection must request recoverable cached messages before a heavy history read" - Assert-True ($source -match "preferCachedMessages:\s*shouldShowLoading\s*\|\|\s*options\.fullHistory\s*===\s*true\s*\|\|\s*Boolean\(options\.olderHistory\)") "explicit history paging must reuse the lightweight cached state before its authoritative RPC" + Assert-True ($source -match "preferCachedMessages:\s*options\.preferSessionLogMessages\s*===\s*true\s*\|\|\s*shouldShowLoading") "session-log notifications and cold thread selection must request recoverable cached messages before a heavy history read" + Assert-True ($source -match "preferCachedMessages:\s*options\.preferSessionLogMessages\s*===\s*true\s*\|\|\s*shouldShowLoading\s*\|\|\s*options\.fullHistory\s*===\s*true\s*\|\|\s*Boolean\(options\.olderHistory\)") "session-log refreshes and explicit history paging must reuse the lightweight cached state before an authoritative RPC" Assert-True ($gatewaySource -match "preferCachedMessages\s*\?\s*'\?preferCachedMessages=1'") "the frontend gateway must opt into the cache-first thread-state route" Assert-True ($serverSnapshotSource -match "options\.preferCachedMessages\s*===\s*true[\s\S]*?readSessionLogThreadRead[\s\S]*?messageState\s*=\s*'cached'") "cache-first state must recover local session messages without presenting them as authoritative" Assert-True ($serverSnapshotSource -match "trimThreadTurnsInRpcResult\('thread/read',\s*recoveredThreadRead\)") "cache-first session recovery must retain the bounded initial message window" Assert-True ($source -match "shouldDeferCachedRpcRefresh[\s\S]*?scheduleSettledSnapshotMessagesRpcRefresh") "cache-first messages must trigger an immediate background authoritative refresh" Assert-True ($source -match "shouldDeferCachedRpcRefresh\s*=\s*options\.forceSettledRpcRefresh\s*!==\s*true") "the forced authoritative refresh must not defer itself again" + Assert-True ($source -match "options\.preferSessionLogMessages\s*===\s*true\s*&&\s*snapshot\.messageState\s*===\s*'cached'[\s\S]*?scheduleSessionLogAuthoritativeRefresh\(threadId") "session-log projections must coalesce a quiet-period authoritative refresh instead of remaining permanently lossy" + Assert-True ($source -match "pendingSessionLogMessageRefresh\.add\(threadId\)[\s\S]{0,240}?scheduleSessionLogAuthoritativeRefresh\(threadId\)") "each session-log notification must reset the authoritative quiet window before its local projection begins" Assert-True ($source -match "shouldForceCachedSnapshotRefresh\s*=\s*options\.force\s*===\s*true\s*&&\s*snapshot\.messageState\s*===\s*'cached'") "cached historical threads without a terminal event key must still receive an authoritative refresh" Assert-True ($source -match "!options\.olderHistory\s*&&\s*!shouldForceCachedSnapshotRefresh") "explicit older-history reads must not be blocked when a legacy thread has no terminal refresh key" Assert-True ($conversationSource -match "pendingRemoteOlderHistoryAnchor\s*=\s*anchorSnapshot[\s\S]*?emit\('loadOlderHistory'\)[\s\S]*?props\.messages\.length[\s\S]*?restoreScrollAnchorOverFrames\(anchorSnapshot,\s*6\)") "remote older-history insertion must restore the pre-request reading anchor after messages arrive" @@ -445,12 +449,12 @@ function Assert-RuntimeSnapshotOrderingSource { Assert-True ($source -match "const\s+UNREAD_STATE_STORAGE_KEY[\s\S]*?function\s+saveUnreadStateMap") "completion unread state must survive mobile process restarts" Assert-True ($source -match "function\s+applyReplayedRuntimeTerminalCleanup[\s\S]*?markThreadUnreadByEvent\(threadId\)") "replayed terminal events must restore unread completion feedback" Assert-True ($source -match "clearSettledRuntimeResidue\(threadId,\s*snapshot\.executionState\)[\s\S]*?processQueuedMessages\(threadId\)") "a settled runtime snapshot must release the previous turn and advance queued work" - Assert-True ($source -match "if\s*\(queue\[0\]\?\.deliveryState\s*===\s*'failed'\)\s*return") "a failed queued follow-up must pause instead of retrying indefinitely" - Assert-True ($source -match "catch\s*\(unknownError\)[\s\S]*?setQueuedMessageDeliveryState\([\s\S]*?threadId,[\s\S]*?next\.id,[\s\S]*?'failed'") "queued send failures must remain visible and retryable" + Assert-True ($runtimeQueueServerSource -match "next\.status\s*===\s*'queue_failed'[\s\S]*?return") "a failed durable queued follow-up must pause instead of retrying indefinitely" + Assert-True ($runtimeQueueClientSource -match "persistRuntimeQueuedMessages[\s\S]*?isRetryableQueueError[\s\S]*?deliveryState:\s*'failed'") "queued handoff failures must remain visible and retryable" Assert-True ($source -match "function\s+retryQueuedMessage[\s\S]*?setQueuedMessageDeliveryState\(threadId,\s*messageId,\s*'queued'\)[\s\S]*?processQueuedMessages\(threadId\)") "manual queue retry must resume the paused first item" Assert-True ($source -match "function\s+deleteQueuedMessage[\s\S]*?removeQueuedMessageByThreadId\(threadId,\s*messageId\)[\s\S]*?processQueuedMessages\(threadId\)") "deleting a failed queue item must release the following item" - Assert-True ($source -match "clientMessageId:\s*next\.clientMessageId") "queued follow-ups must reuse a stable idempotency key across pages and retries" - Assert-True ($source -match "event\.key\s*===\s*QUEUED_MESSAGES_STORAGE_KEY[\s\S]*?queuedMessagesByThreadId\.value\s*=\s*loadQueuedMessagesMap\(\)") "parallel 7420 pages must converge queued-message state without auto-running it" + Assert-True ($runtimeQueueClientSource -match "clientMessageId:\s*message\.clientMessageId") "queued follow-ups must reuse a stable idempotency key across pages and retries" + Assert-True ($source -match "event\.key\s*===\s*QUEUED_MESSAGES_STORAGE_KEY[\s\S]*?api\.loadQueuedMessagesMap\(QUEUED_MESSAGES_STORAGE_KEY\)") "parallel 7420 pages must converge queued-message state without browser-owned execution" $strongSignalMatch = [regex]::Match($source, "function\s+hasStrongExecutionSignal[\s\S]*?\n\s*}") Assert-True ($strongSignalMatch.Success -and $strongSignalMatch.Value -notmatch "hasQueuedThreadWork") "queued follow-up work must not keep the completed turn marked as running" $messageRefreshMatch = [regex]::Match($source, "function\s+shouldRefreshMessagesFromNotification[\s\S]*?\n}") @@ -667,6 +671,8 @@ function Assert-BoundedRuntimeSendRecoverySource { $serverSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\appServerRuntimeStart.ts") $runtimeActionSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\runtimeActionRoutes.ts") $runtimeStoreSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\runtimeStore.ts") + $runtimeQueueClientSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\api\runtimeMessageQueue.ts") + $runtimeQueueServerSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\runtimeMessageQueue.ts") $codexBridgeSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\codexAppServerBridge.ts") $codexBridgeDisposeSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "src\server\codexBridgeMiddlewareDispose.ts") $androidTaskPetSource = Get-Content -Raw -Encoding UTF8 -LiteralPath (Join-Path (Get-Location) "android\app\src\main\java\com\cxcodex\bridge\TaskPetOverlayService.java") @@ -765,11 +771,12 @@ function Assert-BoundedRuntimeSendRecoverySource { Assert-True ($appSource -match "useDesktopState\(\{[\s\S]*?onDeliveryPersisted:[\s\S]*?ensureMobileShellTaskNotificationPermission\(\)[\s\S]*?onPendingRequestCreated:[\s\S]*?syncMobileShellTaskPet\(true\)[\s\S]*?onRequestDispatched:[\s\S]*?ensureMobileShellTaskNotificationPermission\(\)") "the App must install native monitoring callbacks for retries, rollback resend, and automatic queue execution" $processQueuedMessagesMatch = [regex]::Match($source, "async\s+function\s+processQueuedMessages[\s\S]*?\n\s*async\s+function\s+interruptSelectedThreadTurn") Assert-True ($processQueuedMessagesMatch.Success) "could not find automatic queued-message execution source" - Assert-True ($processQueuedMessagesMatch.Value -match "notifyPendingRequestCreated\(undefined,\s*next\.clientMessageId\)[\s\S]*?await\s+startTurnForThread") "automatic queued-message execution must hand the stable client id to Android before the network await" - Assert-True ($processQueuedMessagesMatch.Value -match "clientMessageId:\s*next\.clientMessageId[\s\S]*?onRequestDispatched:\s*submitCallbacks\.onRequestDispatched") "automatic queued-message execution must preserve request-dispatched notification ownership" - Assert-True ($source -match "function\s+latestTaskPetClientMessageId[\s\S]*?queueProcessingByThreadId\.value\[threadId\][\s\S]*?queuedMessagesByThreadId\.value\[threadId\][\s\S]*?clientMessageId") "native task sync must expose the stable id of an automatically promoted queue row" + Assert-True ($processQueuedMessagesMatch.Value -match "api\.persistRuntimeQueuedMessages\(threadId,\s*queue\)[\s\S]*?syncRuntimeMessageQueue\(threadId\)") "browser queue processing must hand local rows to the durable 7420 queue instead of owning turn execution" + Assert-True ($runtimeQueueClientSource -match "persistRuntimeQueuedMessages[\s\S]*?clientMessageId:\s*message\.clientMessageId") "durable queue handoff must preserve the stable idempotency key" + Assert-True ($source -match "function\s+promoteQueuedMessageToOptimistic[\s\S]*?createMessageOutboxEntry[\s\S]*?clientMessageId:\s*queued\.clientMessageId") "a server-started queue row must transfer its stable id to the durable optimistic outbox" + Assert-True ($runtimeQueueServerSource -match "setInterval\(\(\)\s*=>\s*this\.scheduleAll\(\),\s*QUEUE_SWEEP_INTERVAL_MS\)" -and $runtimeQueueServerSource -match "listQueuedThreadIds[\s\S]*?processThread") "7420 must advance queued messages independently of the selected conversation and Android renderer lifecycle" Assert-True ($appSource -match "const\s+ownsSyncSlot\s*=\s*!mobileShellTaskPetSyncInFlight[\s\S]*?if\s*\(!ownsSyncSlot\s*&&\s*!force\)") "an immediate send handoff must bypass an older renderer sync already awaiting its bridge response" - Assert-True ($source -match "setQueuedMessagesForThread\(threadId,\s*nextQueue\)[\s\S]*?notifyDeliveryPersisted\(internalOptions\.onDeliveryPersisted\)[\s\S]*?await\s+recoverThreadExecutionState") "queued delivery must be persisted before contextual notification permission work begins" + Assert-True ($source -match "setQueuedMessagesForThread\(threadId,\s*nextQueue\)[\s\S]*?notifyDeliveryPersisted\(internalOptions\.onDeliveryPersisted\)[\s\S]*?await\s+processQueuedMessages\(threadId\)") "queued delivery must be persisted locally before its immediate durable 7420 handoff" Assert-True ($functionMatch.Value -match "const\s+runtimeRequest\s*=\s*startRuntimeThreadTurn\(args\)[\s\S]*?onRequestDispatched\?\.\(\)[\s\S]*?await\s+runtimeRequest") "contextual permission work must begin only after runtime/send has been dispatched" Assert-True ($appSource -match "onDeliveryPersisted:[\s\S]*?ensureMobileShellTaskNotificationPermission[\s\S]*?onRequestDispatched:[\s\S]*?ensureMobileShellTaskNotificationPermission") "existing-thread queue and immediate sends must use their respective durable or dispatched permission boundaries" Assert-True ($appSource -match "onPendingRequestCreated:[\s\S]*?syncMobileShellTaskPet\(true\)[\s\S]*?onRequestDispatched:[\s\S]*?ensureMobileShellTaskNotificationPermission") "new-thread sends must register provisional native tracking before requesting permission after runtime/send dispatch" @@ -899,9 +906,16 @@ function Assert-BoundedRuntimeSendRecoverySource { Assert-True ($androidTaskPetSource -match 'next\.sort\(\(left,\s*right\)\s*->[\s\S]*?compareTaskRecency[\s\S]*?tasks\.addAll\(next\)') "frontend task snapshots must preserve newest-progress-first native ordering" Assert-True ($androidTaskPetSource -match 'latestReplyCandidate[\s\S]*?shouldPreferReplyCandidate[\s\S]*?pendingReplyRenderTaskKey\s*=\s*taskNotificationKey\(latestReplyCandidate\)[\s\S]*?tasks\.remove\(latestReplyCandidate\)[\s\S]*?tasks\.add\(0,\s*latestReplyCandidate\)') "the task with the newest assistant reply must enter the visible overlay rows before render evidence is committed" Assert-True ($androidTaskPetPolicySource -match 'shouldCommitReplyRender[\s\S]*?overlayExpanded[\s\S]*?panelShown[\s\S]*?panelAlpha\s*>\s*0f[\s\S]*?renderedTaskMatchesPendingReply') "reply-render evidence must require an expanded and actually shown overlay panel" - Assert-True ($androidTaskPetSource -match 'commitPendingReplyRender\(String renderedReplyTaskKey\)[\s\S]*?taskPanel\.isShown\(\)[\s\S]*?taskPanel\.getAlpha\(\)[\s\S]*?replyRenderCount\s*\+=\s*1L' -and $androidTaskPetSource -match 'withEndAction\(\(\)\s*->\s*commitPendingReplyRender\(renderedReplyTaskKey\)\)') "opening a collapsed panel must commit reply-render evidence only after its visible animation completes" - Assert-True ($taskPetPreviewSource -match 'task-pet-preview-compact[\s\S]*?primaryItem[\s\S]*?\$emit\(''open'',\s*primaryItem\.threadId\)') "the collapsed web task-pet surface must show one latest-reply preview that opens its exact conversation" + Assert-True ($androidTaskPetSource -match 'commitPendingReplyRender\(String renderedReplyTaskKey\)[\s\S]*?taskPanel\.isShown\(\)[\s\S]*?taskPanel\.getAlpha\(\)[\s\S]*?replyRenderCount\s*\+=\s*1L' -and $androidTaskPetSource -match 'withEndAction\(\(\)\s*->\s*\{[\s\S]*?commitPendingReplyRender\(renderedReplyTaskKey\)[\s\S]*?scheduleTaskStackCollapse\(\)') "opening a collapsed panel must commit reply-render evidence only after its visible animation completes" + Assert-True ($taskPetPreviewSource -match 'task-pet-preview-compact[\s\S]*?primaryItem[\s\S]*?openTask\(primaryItem\)' -and $taskPetPreviewSource -match 'function\s+openTask[\s\S]*?emit\(''open'',\s*item\.threadId\)') "the collapsed web task-pet surface must show one latest-reply preview that opens its exact conversation" + Assert-True ($taskPetPreviewSource -match 'watch\(\(\)\s*=>\s*props\.items\.length[\s\S]*?count\s*>\s*0[\s\S]*?minimized\.value\s*=\s*false') "a web task arriving after idle must restore the task pet before reply presentation" + Assert-True ($taskPetPreviewSource -match 'unreadReplyTaskKeys[\s\S]*?item\.state\s*===\s*''waiting''\s*\|\|\s*unreadReplyTaskKeys\.value\.has') "web task-pet attention must count waiting tasks and unread running replies separately" + Assert-True ($taskPetPreviewSource -match 'function\s+showCloseConfirmation[\s\S]*?stopStackTimer\(\)[\s\S]*?closeConfirmationVisible\.value\s*=\s*true' -and $taskPetPreviewSource -match 'function\s+cancelCloseConfirmation[\s\S]*?closeConfirmationVisible\.value\s*=\s*false[\s\S]*?scheduleStackCollapse\(\)') "web task-pet close confirmation must pause auto-collapse and resume it only after cancellation" Assert-True ($androidTaskPetSource -match 'buildCompactPreview[\s\S]*?renderCompactPreview[\s\S]*?compactPreviewReply\.setText\(replyPreview\)[\s\S]*?openThread\(task\.threadId\)' -and $androidTaskPetPolicySource -match 'shouldCommitCompactReplyRender[\s\S]*?!overlayExpanded[\s\S]*?!overlayMinimized[\s\S]*?previewShown[\s\S]*?previewAlpha\s*>\s*0f') "the collapsed native task pet must render the newest reply, open its exact thread, and record visibility only while the compact preview is shown" + Assert-True ($androidTaskPetPolicySource -match 'hasUnreadReply[\s\S]*?latestReplyEventSeq\s*>\s*readThroughReplyEventSeq' -and $androidTaskPetSource -match 'put\("readThroughReplyEventSeq",\s*task\.readThroughReplyEventSeq\)' -and $androidTaskPetSource -match 'task\.readThroughReplyEventSeq\s*=\s*Math\.max[\s\S]*?task\.latestReplyEventSeq') "native task attention must use a persisted reply-event read cursor instead of a sticky boolean alone" + Assert-True ($androidTaskPetSource -match 'latestReplyItemId[\s\S]*?replyItemChanged[\s\S]*?showTransientReplyPeek' -and $androidTaskPetSource -match 'REPLY_PEEK_TIMEOUT_MS\s*=\s*5_000L' -and $androidTaskPetSource -match 'getRecommendedTimeoutMillis') "one assistant item may open one accessible five-second reply peek without stream chunks restarting the timer" + Assert-True ($androidTaskPetSource -match 'TASK_STACK_TIMEOUT_MS\s*=\s*8_000L[\s\S]*?collapseTaskStackRunnable[\s\S]*?setExpanded\(false\)' -and $androidTaskPetSource -match 'EDGE_VISIBLE_HANDLE_DP\s*=\s*32[\s\S]*?screenWidth\s*-\s*visibleHandle[\s\S]*?-\(rootWidth\s*-\s*visibleHandle\)') "the Android task stack must auto-collapse and leave only a bounded side handle" + Assert-True ($androidMainActivitySource -match 'appForeground\s*=\s*true[\s\S]*?refreshPresentation' -and $androidMainActivitySource -match 'appForeground\s*=\s*false[\s\S]*?refreshPresentation' -and $androidTaskPetSource -match 'shouldAttachOverlay[\s\S]*?!MainActivity\.isAppForeground\(\)' -and $androidTaskPetSource -match 'suppressed_foreground') "the overlay and terminal notification must stay quiet while the Android app itself is foreground" Assert-True ($androidTaskPetSource -match 'restorePendingReplyRender\(\)[\s\S]*?PREF_TASK_PET_PENDING_REPLY_RENDER_KEY[\s\S]*?PREF_TASK_PET_PENDING_REPLY_RENDER_EVENT_SEQ' -and $androidTaskPetSource -match 'pendingReplyRenderTaskKey\s*=\s*taskNotificationKey\(latestReplyCandidate\)[\s\S]*?persistPendingReplyRender\(\)' -and $androidTaskPetSource -match 'replyRenderCount\s*\+=\s*1L[\s\S]*?persistPendingReplyRender\(\)') "a collapsed latest-reply render boundary must survive service recreation and clear only after visible render" Assert-True ($androidTaskPetPolicySource -match 'shouldPreserveKnownLatestReply[\s\S]*?frontendSnapshot[\s\S]*?sameTaskGeneration[\s\S]*?!currentReply\.isEmpty\(\)[\s\S]*?incomingReplyEventSeq\s*<=\s*0L\s*\|\|\s*incomingReplyEventSeq\s*<=\s*currentReplyEventSeq' -and $androidTaskPetSource -match 'incomingLatestReplyEventSeq[\s\S]*?preserveKnownLatestReply[\s\S]*?resolvedLatestReplyEventSeq[\s\S]*?previous\.latestReplyEventSeq') "an unversioned, equal-version, or older recovering frontend reply must not overwrite a same-generation reply already persisted by the native monitor" $nativeSettledStatePolicyMatch = [regex]::Match($androidTaskPetPolicySource, 'static\s+boolean\s+shouldPreserveNativeSettledState[\s\S]*?\n\s*\}') @@ -3615,6 +3629,9 @@ JSON.stringify((() => { const tableScrolls = Array.from(document.querySelectorAll('.message-table-scroll')); const tableCardGroups = Array.from(document.querySelectorAll('.message-table-cards')); const tableCards = Array.from(document.querySelectorAll('.message-table-card')); + const tableScrollableCount = tableScrolls.filter((node) => node.scrollWidth > node.clientWidth + 2).length; + const tableFontSizes = tableScrolls.flatMap((node) => Array.from(node.querySelectorAll('th, td'))) + .map((node) => Number.parseFloat(window.getComputedStyle(node).fontSize || '0')); const runtimeStatusBars = Array.from(document.querySelectorAll('.conversation-regression-fixture .runtime-status-bar')); const runtimeStatusHeights = runtimeStatusBars.map((node) => Math.round(node.getBoundingClientRect().height)); const queuedPanels = Array.from(document.querySelectorAll('.conversation-regression-fixture .queued-messages-inner')); @@ -3709,6 +3726,8 @@ JSON.stringify((() => { tableScrollCount: tableScrolls.length, tableCardGroupCount: tableCardGroups.length, tableCardCount: tableCards.length, + tableScrollableCount, + tableMaxFontSize: tableFontSizes.length ? Math.max(...tableFontSizes) : 0, runtimeStatusBarCount: runtimeStatusBars.length, runtimeStatusMaxHeight: runtimeStatusHeights.length ? Math.max(...runtimeStatusHeights) : 0, viewportWidth, @@ -4348,9 +4367,10 @@ function Assert-ConversationFixture { Assert-True ($Metrics.toolPanelCount -ge 1) "conversation fixture is missing tool call panel" Assert-True ($Metrics.requestButtonCount -ge 3) "conversation fixture is missing permission action buttons" if ([int]$Metrics.viewportWidth -lt 768) { - Assert-True ([int]$Metrics.tableScrollCount -eq 0) "conversation fixture phone viewport mounted desktop table DOM: $($Metrics.tableScrollCount)" - Assert-True ([int]$Metrics.tableCardGroupCount -ge 1) "conversation fixture phone viewport is missing mobile table card group" - Assert-True ([int]$Metrics.tableCardCount -ge 1) "conversation fixture phone viewport is missing mobile table cards" + Assert-True ([int]$Metrics.tableScrollCount -ge 1) "conversation fixture phone viewport is missing semantic table scroll region" + Assert-True ([int]$Metrics.tableScrollableCount -ge 1) "conversation fixture phone table is not horizontally scrollable" + Assert-True ([double]$Metrics.tableMaxFontSize -le 13.5) "conversation fixture phone table font is too large: $($Metrics.tableMaxFontSize)" + Assert-True ([int]$Metrics.tableCardGroupCount -eq 0) "conversation fixture phone viewport still mounted vertical table cards" } else { Assert-True ([int]$Metrics.tableScrollCount -ge 1) "conversation fixture $ViewportName viewport is missing desktop table DOM" Assert-True ([int]$Metrics.tableCardGroupCount -eq 0) "conversation fixture $ViewportName viewport mounted mobile table DOM: $($Metrics.tableCardGroupCount)" @@ -7265,9 +7285,78 @@ Assert-ThreadAttentionChromeSource Assert-True ($notificationRecoveryFixture.hasCompletionNotificationRecovery -eq $true) "completion notification recovery fixture phone is missing the blocked-channel state or recovery action" Add-RegressionResult -Name "completion-notification-recovery-phone" -Page $notificationRecoveryFixture + $taskPetWakeFixtureUrl = $BaseUrl + "/#/__regression/task-pet?regression=frontend" + $taskPetWakeFixture = Open-And-ReadPage -Session $session -Url $taskPetWakeFixtureUrl -Width $PhoneWidth -Height $PhoneHeight + Assert-Page -Page $taskPetWakeFixture -Name "task-pet idle wake fixture phone" + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="toggle-task-state"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "100") | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="toggle-task-state"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "100") | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="simulate-latest-reply"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "200") | Out-Null + $taskPetWakeMetrics = Invoke-BrowserEvalJson -Session $session -Script @' +JSON.stringify((() => { + const root = document.querySelector('[aria-label="任务宠物预览"]'); + const badge = root?.querySelector('.task-pet-preview-badge'); + return { + minimized: Boolean(root?.querySelector('[aria-label="恢复任务宠物"]')), + compactCount: root?.querySelectorAll('.task-pet-preview-compact').length || 0, + compactText: root?.querySelector('.task-pet-preview-compact')?.textContent?.replace(/\s+/g, ' ').trim() || '', + attentionCount: Number(badge?.textContent?.trim() || 0) + }; +})()) +'@ + Assert-True ($taskPetWakeMetrics.minimized -eq $false) "a task arriving after idle must restore the task pet instead of leaving it minimized" + Assert-True ([int]$taskPetWakeMetrics.compactCount -eq 1) "a new assistant reply after idle must show exactly one transient reply preview" + Assert-True ([string]$taskPetWakeMetrics.compactText -match '浮窗已实时同步最新回复') "the reply preview after idle did not show the new assistant reply" + Assert-True ([int]$taskPetWakeMetrics.attentionCount -eq 2) "one waiting task plus one unread running reply must show attention count 2" + Invoke-AgentBrowser -Arguments @("--session", $session, "click", ".task-pet-preview-mascot") | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="toggle-task-state"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "8250") | Out-Null + $taskPetIdleCollapseMetrics = Invoke-BrowserEvalJson -Session $session -Script @' +JSON.stringify({ + expanded: Boolean(document.querySelector('.task-pet-preview-panel')), + minimized: Boolean(document.querySelector('[aria-label="恢复任务宠物"]')) +}) +'@ + Assert-True ($taskPetIdleCollapseMetrics.expanded -eq $false) "an empty task stack must auto-collapse after its visibility timeout" + Assert-True ($taskPetIdleCollapseMetrics.minimized -eq $true) "an empty task stack must return to the minimized pet after auto-collapse" + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="toggle-task-state"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "click", ".task-pet-preview-mascot") | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[aria-label="关闭浮窗"]') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "8250") | Out-Null + $taskPetCloseConfirmationMetrics = Invoke-BrowserEvalJson -Session $session -Script @' +JSON.stringify({ + dialog: Boolean(document.querySelector('[aria-label="确认关闭浮窗"]')), + expanded: Boolean(document.querySelector('.task-pet-preview-panel')) +}) +'@ + Assert-True ($taskPetCloseConfirmationMetrics.dialog -eq $true) "task-pet close confirmation must pause auto-collapse while awaiting a decision" + Assert-True ($taskPetCloseConfirmationMetrics.expanded -eq $true) "task-pet close confirmation must keep its panel expanded" + Invoke-AgentBrowser -Arguments @("--session", $session, "click", '.task-pet-preview-close-confirm button:not([data-tone="danger"])') | Out-Null + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "100") | Out-Null + Add-RegressionResult -Name "task-pet-idle-wake-phone" -Page $taskPetWakeFixture + $latestReplyFixtureUrl = $BaseUrl + "/#/__regression/task-pet?regression=frontend&latestReplyBurst=1" $latestReplyFixture = Open-And-ReadPage -Session $session -Url $latestReplyFixtureUrl -Width $PhoneWidth -Height $PhoneHeight Assert-Page -Page $latestReplyFixture -Name "latest reply promoted fixture phone" + $latestReplyDockedMetrics = Invoke-BrowserEvalJson -Session $session -Script @' +JSON.stringify({ + visibleRowCount: document.querySelectorAll('.task-pet-preview-row').length, + compactCount: document.querySelectorAll('.task-pet-preview-compact').length +}) +'@ + Assert-True ([int]$latestReplyDockedMetrics.visibleRowCount -eq 0) "task pet must start quietly docked until the user expands it" + Assert-True ([int]$latestReplyDockedMetrics.compactCount -eq 0) "quietly docked task pet must not expose a stale reply preview" + $expandLatestReplyPreview = Invoke-BrowserEvalJson -Session $session -Script @' +JSON.stringify((() => { + const mascot = document.querySelector('.task-pet-preview-mascot'); + mascot?.click(); + return { expanded: Boolean(mascot) }; +})()) +'@ + Assert-True ($expandLatestReplyPreview.expanded -eq $true) "task-pet latest-reply fixture could not expand its task stack" + Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "250") | Out-Null $latestReplyFixtureMetrics = Invoke-BrowserEvalJson -Session $session -Script @' JSON.stringify((() => { const rows = Array.from(document.querySelectorAll('.task-pet-preview-row')); @@ -7304,8 +7393,7 @@ JSON.stringify((() => { }; })()) '@ - Assert-True ([int]$compactLatestReplyBefore.compactCount -eq 1) "collapsed task pet must show exactly one latest-reply preview" - Assert-True ([string]$compactLatestReplyBefore.text -match '最新回复已提升到浮窗可见首行') "collapsed task pet did not retain the newest visible reply" + Assert-True ([int]$compactLatestReplyBefore.compactCount -eq 0) "collapsed task pet must remain quiet until a new reply arrives" Assert-True ([int]$compactLatestReplyBefore.documentWidth -eq [int]$compactLatestReplyBefore.viewportWidth) "collapsed latest-reply preview has horizontal overflow" Invoke-AgentBrowser -Arguments @("--session", $session, "click", '[data-testid="simulate-latest-reply"]') | Out-Null Invoke-AgentBrowser -Arguments @("--session", $session, "wait", "200") | Out-Null diff --git a/scripts/server-module-smoke.ts b/scripts/server-module-smoke.ts index ad06869..95d43e2 100644 --- a/scripts/server-module-smoke.ts +++ b/scripts/server-module-smoke.ts @@ -134,6 +134,7 @@ import { import { isSessionLogThreadReadCandidateLine, parseThreadReadFromSessionLog, + readThreadReadFromSessionLog, } from '../src/server/appServerSessionLogThreadRead.js' import { createAppServerRuntimeReaders } from '../src/server/appServerRuntimeReaders.js' import { @@ -302,6 +303,7 @@ import { import { handleThreadRoutes } from '../src/server/threadRoutes.js' import { isRuntimeActiveState, + RUNTIME_START_THREAD_READ_GRACE_MS, RuntimeStateStore, toPersistableRuntimeSnapshot, type RuntimeSnapshotOverlay, @@ -315,6 +317,7 @@ import { type RuntimeEventRecord, type RuntimeRequestRecord, } from '../src/server/runtimeStore.js' +import { RuntimeMessageQueue } from '../src/server/runtimeMessageQueue.js' import { handleRpcProxyRoute, type RpcProxyRouteDependencies } from '../src/server/rpcProxyRoute.js' import { createRuntimePromptHash, @@ -445,6 +448,7 @@ import { CodexSessionFileChangeObserver, classifyCodexSessionFileChange, createCodexSessionFileChangedNotification, + resolveCodexSessionFileChangeOrigin, } from '../src/server/codexSessionFileChangeObserver.js' import { CX_SESSION_FILES_CHANGED_METHOD, @@ -563,6 +567,7 @@ try { smokeAppServerRuntimeSnapshotPersistence() smokeAppServerNotificationRuntimeSync() await smokeRuntimeActionRoutes() + await smokeRuntimeMessageQueue() await smokeMobilePush() await smokeDiagnosticsRoutes() await smokeRuntimeStoreMaintenance() @@ -3852,6 +3857,14 @@ async function smokeAppServerRpcCache(): Promise { assert.equal(shouldInvalidateThreadReadCacheForRpc('model/list'), false) assert.equal(shouldInvalidateThreadReadCacheForNotification('thread/goal/updated'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification(CX_SESSION_FILES_CHANGED_METHOD), true) + assert.equal(shouldInvalidateThreadReadCacheForNotification(CX_SESSION_FILES_CHANGED_METHOD, { + source: 'session-log', + origin: 'live-app-server', + }), true) + assert.equal(shouldInvalidateThreadReadCacheForNotification(CX_SESSION_FILES_CHANGED_METHOD, { + source: 'session-log', + origin: 'external', + }), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('thread/goal/cleared'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('thread/compacted'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('turn/completed'), true) @@ -3859,18 +3872,18 @@ async function smokeAppServerRpcCache(): Promise { assert.equal(shouldInvalidateThreadReadCacheForNotification('turn/plan/updated'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('item/updated'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('rawResponseItem/completed'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/agentMessage/delta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/plan/delta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/summaryTextDelta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/summaryPartAdded'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/textDelta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/commandExecution/outputDelta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/commandExecution/terminalInteraction'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/fileChange/outputDelta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/fileChange/patchUpdated'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('item/mcpToolCall/progress'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('command/exec/outputDelta'), true) - assert.equal(shouldInvalidateThreadReadCacheForNotification('process/outputDelta'), true) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/agentMessage/delta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/plan/delta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/summaryTextDelta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/summaryPartAdded'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/reasoning/textDelta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/commandExecution/outputDelta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/commandExecution/terminalInteraction'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/fileChange/outputDelta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/fileChange/patchUpdated'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('item/mcpToolCall/progress'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('command/exec/outputDelta'), false) + assert.equal(shouldInvalidateThreadReadCacheForNotification('process/outputDelta'), false) assert.equal(shouldInvalidateThreadReadCacheForNotification('process/exited'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('tool/failed'), true) assert.equal(shouldInvalidateThreadReadCacheForNotification('thread/name/updated'), false) @@ -4970,6 +4983,7 @@ function smokeCodexBridgeNotificationRuntime(): void { const observedRuntimeEvents: unknown[] = [] const deletedThreadReads: string[] = [] const emittedEvents: unknown[] = [] + const persistedRuntimeThreads: string[] = [] const runtime = createCodexBridgeNotificationRuntime({ subscribeAppServerNotifications: (listener) => { @@ -5014,7 +5028,10 @@ function smokeCodexBridgeNotificationRuntime(): void { observedStatusNotifications.push(observation) }, }, - persistRuntimeSnapshot: () => ({} as never), + persistRuntimeSnapshot: (threadId) => { + persistedRuntimeThreads.push(threadId) + return {} as never + }, }) const unsubscribeBridgeListener = runtime.bridgeNotificationListeners.subscribe((event) => { @@ -5046,20 +5063,42 @@ function smokeCodexBridgeNotificationRuntime(): void { assert.equal(observedStatusNotifications.length, 1) assert.equal(observedRuntimeEvents.length, 1) assert.deepEqual(deletedThreadReads, ['thread-a']) + assert.deepEqual(persistedRuntimeThreads, ['thread-a']) assert.deepEqual(emittedEvents, [observedRuntimeEvents[0]]) assert.equal(runtime.listNotificationEventsAfter(10, 5).notifications.length, 1) assert.equal(runtime.listNotificationEventsAfter(10, 5).streamId, 'stream-runtime') - const externalEvent = runtime.publishBridgeNotification(createCodexSessionFileChangedNotification({ + const commandOutputEvent = runtime.publishBridgeNotification({ + method: 'item/commandExecution/outputDelta', + params: { threadId: 'thread-a', turnId: 'turn-a', itemId: 'command-a', delta: 'chunk' }, + }) + assert.equal(commandOutputEvent.seq, 12) + assert.equal(observedRuntimeEvents.length, 2) + assert.deepEqual(deletedThreadReads, ['thread-a']) + assert.deepEqual(persistedRuntimeThreads, ['thread-a']) + assert.equal(emittedEvents.length, 2) + + const coveredSessionEvent = runtime.publishBridgeNotification(createCodexSessionFileChangedNotification({ source: 'session-log', threadId: 'thread-a', + origin: 'live-app-server', })) - assert.equal(externalEvent.seq, 12) - assert.equal(externalEvent.method, CX_SESSION_FILES_CHANGED_METHOD) - assert.equal(observedRuntimeEvents.length, 1) + assert.equal(coveredSessionEvent.seq, 13) + assert.equal(coveredSessionEvent.method, CX_SESSION_FILES_CHANGED_METHOD) + assert.equal(observedRuntimeEvents.length, 2) assert.deepEqual(deletedThreadReads, ['thread-a', 'thread-a']) - assert.equal(emittedEvents.length, 2) - assert.equal(runtime.listNotificationEventsAfter(10, 5).notifications.length, 2) + assert.deepEqual(persistedRuntimeThreads, ['thread-a']) + assert.equal(emittedEvents.length, 3) + assert.equal(runtime.listNotificationEventsAfter(10, 5).notifications.length, 3) + + const externalEvent = runtime.publishBridgeNotification(createCodexSessionFileChangedNotification({ + source: 'session-log', + threadId: 'thread-a', + origin: 'external', + })) + assert.equal(externalEvent.seq, 14) + assert.deepEqual(deletedThreadReads, ['thread-a', 'thread-a', 'thread-a']) + assert.equal(emittedEvents.length, 4) unsubscribeBridgeListener() assert.equal(runtime.bridgeNotificationListeners.count, 0) @@ -5069,6 +5108,21 @@ function smokeCodexBridgeNotificationRuntime(): void { async function smokeCodexSessionFileChangeObserver(): Promise { const threadId = '019f7e69-25a4-7aa2-8166-b8257873b8ab' + assert.equal(resolveCodexSessionFileChangeOrigin( + { source: 'session-log', threadId }, + 1_000, + 5_000, + ), 'live-app-server') + assert.equal(resolveCodexSessionFileChangeOrigin( + { source: 'session-log', threadId }, + 1_000, + 6_001, + ), 'external') + assert.equal(resolveCodexSessionFileChangeOrigin( + { source: 'session-index', threadId: '' }, + 5_000, + 5_000, + ), 'external') assert.equal(shouldInvalidateThreadCollectionForCxSessionFileChange({ source: 'session-log' }), false) assert.equal(shouldInvalidateThreadCollectionForCxSessionFileChange({ source: 'session-index' }), true) assert.equal(shouldInvalidateThreadCollectionForCxSessionFileChange({ source: 'unknown' }), true) @@ -6377,24 +6431,28 @@ async function smokeThreadTokenUsage(): Promise { } async function smokeThreadTitleCache(): Promise { - assert.deepEqual(normalizeThreadTitleCache(null), { titles: {}, order: [] }) + assert.deepEqual(normalizeThreadTitleCache(null), { titles: {}, order: [], manualTitleIds: [] }) assert.deepEqual(normalizeThreadTitleCache({ titles: { a: 'Alpha', b: '', c: 7 }, order: ['a', 'missing', 'a', '', 9], }), { titles: { a: 'Alpha' }, order: ['a', 'missing'], + manualTitleIds: [], }) - const updated = updateThreadTitleCache({ titles: { a: 'Alpha' }, order: ['a'] }, 'b', 'Beta') - assert.deepEqual(updated, { titles: { a: 'Alpha', b: 'Beta' }, order: ['b', 'a'] }) - assert.deepEqual(removeFromThreadTitleCache(updated, 'a'), { titles: { b: 'Beta' }, order: ['b'] }) + const updated = updateThreadTitleCache({ titles: { a: 'Alpha' }, order: ['a'], manualTitleIds: [] }, 'b', 'Beta') + assert.deepEqual(updated, { titles: { a: 'Alpha', b: 'Beta' }, order: ['b', 'a'], manualTitleIds: [] }) + assert.deepEqual(removeFromThreadTitleCache(updated, 'a'), { titles: { b: 'Beta' }, order: ['b'], manualTitleIds: [] }) + const manuallyUpdated = updateThreadTitleCache(updated, 'b', 'Manual Beta', { manual: true }) + assert.equal(updateThreadTitleCache(manuallyUpdated, 'b', 'Automatic Beta'), manuallyUpdated) assert.deepEqual(mergeThreadTitleCaches( - { titles: { a: 'Alpha', b: 'Base Beta' }, order: ['a', 'b'] }, - { titles: { b: 'Session Beta', c: 'Gamma' }, order: ['c', 'b'] }, + { titles: { a: 'Alpha', b: 'Base Beta' }, order: ['a', 'b'], manualTitleIds: ['b'] }, + { titles: { b: 'Session Beta', c: 'Gamma' }, order: ['c', 'b'], manualTitleIds: [] }, ), { - titles: { a: 'Alpha', b: 'Session Beta', c: 'Gamma' }, + titles: { a: 'Alpha', b: 'Base Beta', c: 'Gamma' }, order: ['c', 'b', 'a'], + manualTitleIds: ['b'], }) const tempDir = await mkdtemp(join(tmpdir(), 'cx-codex-thread-title-')) @@ -6402,12 +6460,13 @@ async function smokeThreadTitleCache(): Promise { const statePath = join(tempDir, 'global-state.json') const sessionIndexPath = join(tempDir, 'session_index.jsonl') - assert.deepEqual(await readThreadTitleCache(statePath), { titles: {}, order: [] }) + assert.deepEqual(await readThreadTitleCache(statePath), { titles: {}, order: [], manualTitleIds: [] }) await writeFile(statePath, JSON.stringify({ existing: true }), 'utf8') - await writeThreadTitleCache(statePath, { titles: { manual: 'Manual title' }, order: ['manual'] }) + await writeThreadTitleCache(statePath, { titles: { manual: 'Manual title' }, order: ['manual'], manualTitleIds: ['manual'] }) assert.deepEqual(await readThreadTitleCache(statePath), { titles: { manual: 'Manual title' }, order: ['manual'], + manualTitleIds: ['manual'], }) await writeFile(sessionIndexPath, [ @@ -6421,15 +6480,18 @@ async function smokeThreadTitleCache(): Promise { assert.deepEqual(await parseThreadTitlesFromSessionIndex(sessionIndexPath), { titles: { 'thread-a': 'New A', 'thread-b': 'Beta' }, order: ['thread-a', 'thread-b'], + manualTitleIds: [], }) assert.deepEqual(await readThreadTitlesFromSessionIndex(sessionIndexPath), { titles: { 'thread-a': 'New A', 'thread-b': 'Beta' }, order: ['thread-a', 'thread-b'], + manualTitleIds: [], }) - assert.deepEqual(await readThreadTitlesFromSessionIndex(join(tempDir, 'missing.jsonl')), { titles: {}, order: [] }) + assert.deepEqual(await readThreadTitlesFromSessionIndex(join(tempDir, 'missing.jsonl')), { titles: {}, order: [], manualTitleIds: [] }) assert.deepEqual(await readMergedThreadTitleCache(statePath, sessionIndexPath), { titles: { manual: 'Manual title', 'thread-a': 'New A', 'thread-b': 'Beta' }, order: ['thread-a', 'thread-b', 'manual'], + manualTitleIds: ['manual'], }) } finally { await rm(tempDir, { recursive: true, force: true }) @@ -6715,7 +6777,7 @@ async function smokeThreadSearchIndex(): Promise { : { data: [{ id: 'hydrated-thread', name: 'Hydrated needle' }] } }, getSessionIndexPath: () => 'cached-factory-session-index.jsonl', - readThreadTitlesFromSessionIndex: async () => ({ titles: {}, order: [] }), + readThreadTitlesFromSessionIndex: async () => ({ titles: {}, order: [], manualTitleIds: [] }), }) assert.deepEqual(await cachedFactoryStore.search('cached', 10), { threadIds: ['cached-thread'], @@ -6742,7 +6804,7 @@ async function smokeThreadRoutes(): Promise { const bodies: unknown[] = [ { query: ' alpha ', limit: 2.8 }, { query: ' ', limit: 10 }, - { id: 'thread-a', title: 'Alpha title' }, + { id: 'thread-a', title: 'Alpha title', manual: true }, { id: 'thread-a', title: '' }, { title: 'Missing id' }, ] @@ -6765,7 +6827,7 @@ async function smokeThreadRoutes(): Promise { }), readThreadTitleCache: async (path: string) => { readTitlePaths.push(path) - return { titles: { 'thread-a': 'Old title' }, order: ['thread-a'] } + return { titles: { 'thread-a': 'Old title' }, order: ['thread-a'], manualTitleIds: [] } }, writeThreadTitleCache: async (path: string, cache: unknown) => { writeTitleCalls.push({ path, cache }) @@ -6819,6 +6881,7 @@ async function smokeThreadRoutes(): Promise { cache: { titles: { 'thread-a': 'Alpha title' }, order: ['thread-a'], + manualTitleIds: ['thread-a'], }, }) assert.deepEqual(JSON.parse(updateTitle.body), { ok: true }) @@ -6835,6 +6898,7 @@ async function smokeThreadRoutes(): Promise { cache: { titles: {}, order: [], + manualTitleIds: [], }, }) assert.deepEqual(JSON.parse(removeTitle.body), { ok: true }) @@ -8230,6 +8294,47 @@ function smokeRuntimeStateStore(): void { store.markRunning('thread-timer', 'turn-new') assert.equal(store.snapshot('thread-timer').lastStartedAtIso, restarted.lastStartedAtIso) + store.markStarting('thread-start-second') + const currentStart = store.snapshot('thread-start-second') + const currentStartAtMs = Date.parse(currentStart.lastStartedAtIso ?? '') + const currentStartSecondIso = new Date(Math.floor(currentStartAtMs / 1_000) * 1_000).toISOString() + store.observeThreadRead('thread-start-second', false, '', currentStartSecondIso, 'thread-read') + assert.equal( + store.snapshot('thread-start-second').executionState, + 'starting', + 'a second-precision thread read from the start second must not settle a materializing turn', + ) + + const expiredStartAtMs = Date.now() - RUNTIME_START_THREAD_READ_GRACE_MS - 1_000 + const expiredStartAtIso = new Date(expiredStartAtMs).toISOString() + const expiredStartStore = new RuntimeStateStore({ + readThreadIdFromPayload, + readTurnIdFromPayload, + readItemIdFromPayload, + readThreadInProgressFromThreadReadPayload: (payload) => readBooleanProperty(payload, 'inProgress'), + getErrorMessage: (_payload, fallback) => fallback, + }, { + loadPersistedSnapshot: (threadId) => createThreadRuntimeSnapshot({ + threadId, + executionState: 'starting', + inProgress: true, + updatedAtIso: expiredStartAtIso, + lastStartedAtIso: expiredStartAtIso, + }), + }) + expiredStartStore.observeThreadRead( + 'thread-expired-start-second', + false, + '', + new Date(Math.floor(expiredStartAtMs / 1_000) * 1_000).toISOString(), + 'thread-read', + ) + assert.equal( + expiredStartStore.snapshot('thread-expired-start-second').executionState, + 'completed', + 'an expired same-second snapshot must remain recoverable after lifecycle notifications are lost', + ) + store.markStarting('thread-failed-start') store.markFailed('thread-failed-start', 'thread is archived') const failedStart = store.snapshot('thread-failed-start') @@ -9525,8 +9630,46 @@ function smokeAppServerNotificationRuntimeSync(): void { }, }]) - const noThreadEvent: BridgeNotificationEvent = { + const queueEvent: BridgeNotificationEvent = { seq: 8, + method: 'runtime/queue/updated', + params: { threadId: 'thread-a', requestId: 'queued-request', action: 'starting' }, + atIso: '2026-01-01T00:00:01.000Z', + } + const queueEmits: BridgeNotificationEvent[] = [] + assert.equal(syncBridgeNotificationRuntimeState({ + method: queueEvent.method, + params: queueEvent.params, + }, { + rememberNotificationEvent: () => queueEvent, + runtimeStateStore: { + observeEvent: () => { + throw new Error('internal queue notifications must not mutate runtime state') + }, + }, + readThreadIdFromPayload, + persistRuntimeSnapshot: () => { + throw new Error('internal queue notifications must not persist a stale runtime snapshot') + }, + runtimeStore: { + listRequestsByThread: () => { + throw new Error('internal queue notifications must not reconcile a pending queue request') + }, + updateRequest: () => { + throw new Error('internal queue notifications must not settle a pending queue request') + }, + }, + deleteCachedThreadRead: () => { + throw new Error('internal queue notifications must not invalidate thread reads') + }, + emitNotification: (emittedEvent) => { + queueEmits.push(emittedEvent) + }, + }), queueEvent) + assert.deepEqual(queueEmits, [queueEvent]) + + const noThreadEvent: BridgeNotificationEvent = { + seq: 9, method: 'app/list/updated', params: {}, atIso: '2026-01-01T00:00:01.000Z', @@ -9783,6 +9926,100 @@ async function smokeRuntimeActionRoutes(): Promise { ]) assert.deepEqual(JSON.parse(stopUncertain.body), { data: { status: 'stop_uncertain', requestId: 'stop-2' } }) + const queueEntry = { + requestId: 'queue-request-a', + clientMessageId: 'queue-client-a', + threadId: 'thread-queue', + status: 'queued' as const, + createdAtIso: '2026-01-01T00:00:00.000Z', + updatedAtIso: '2026-01-01T00:00:00.000Z', + lastError: null, + payload: { input: 'queue route' }, + } + const listedQueueThreadIds: string[] = [] + const cancelledQueueRequestIds: string[] = [] + const retriedQueueRequestIds: string[] = [] + const reorderedQueues: Array<{ threadId: string; requestIds: string[] }> = [] + const queueDependencies = { + ...dependencies, + readJsonBody: async () => ({ input: 'queue route' }), + enqueueRuntimeTurn: () => queueEntry, + listRuntimeQueue: (threadId = '') => { + listedQueueThreadIds.push(threadId) + return [queueEntry] + }, + cancelQueuedRuntimeTurn: (requestId: string) => { + cancelledQueueRequestIds.push(requestId) + return true + }, + retryQueuedRuntimeTurn: (requestId: string) => { + retriedQueueRequestIds.push(requestId) + return true + }, + reorderQueuedRuntimeTurns: (threadId: string, requestIds: string[]) => { + reorderedQueues.push({ threadId, requestIds }) + return true + }, + } + const queued = createRouteTestResponse() + assert.equal(await handleRuntimeActionRoutes( + { method: 'POST' } as never, + queued.response as never, + new URL('http://127.0.0.1/codex-api/runtime/queue'), + queueDependencies, + ), true) + assert.equal(queued.response.statusCode, 202) + assert.deepEqual(JSON.parse(queued.body), { data: queueEntry }) + + const listed = createRouteTestResponse() + assert.equal(await handleRuntimeActionRoutes( + { method: 'GET' } as never, + listed.response as never, + new URL('http://127.0.0.1/codex-api/runtime/queue?threadId=thread-queue'), + queueDependencies, + ), true) + assert.deepEqual(JSON.parse(listed.body), { data: [queueEntry] }) + assert.deepEqual(listedQueueThreadIds, ['thread-queue']) + + const removed = createRouteTestResponse() + assert.equal(await handleRuntimeActionRoutes( + { method: 'DELETE' } as never, + removed.response as never, + new URL('http://127.0.0.1/codex-api/runtime/queue/queue-request-a'), + queueDependencies, + ), true) + assert.equal(removed.response.statusCode, 200) + assert.deepEqual(cancelledQueueRequestIds, ['queue-request-a']) + + const retried = createRouteTestResponse() + assert.equal(await handleRuntimeActionRoutes( + { method: 'POST' } as never, + retried.response as never, + new URL('http://127.0.0.1/codex-api/runtime/queue/queue-request-a/retry'), + queueDependencies, + ), true) + assert.equal(retried.response.statusCode, 202) + assert.deepEqual(retriedQueueRequestIds, ['queue-request-a']) + + const reordered = createRouteTestResponse() + assert.equal(await handleRuntimeActionRoutes( + { method: 'POST' } as never, + reordered.response as never, + new URL('http://127.0.0.1/codex-api/runtime/queue/reorder'), + { + ...queueDependencies, + readJsonBody: async () => ({ + threadId: 'thread-queue', + requestIds: ['queue-request-b', 'queue-request-a'], + }), + }, + ), true) + assert.equal(reordered.response.statusCode, 200) + assert.deepEqual(reorderedQueues, [{ + threadId: 'thread-queue', + requestIds: ['queue-request-b', 'queue-request-a'], + }]) + assert.equal(await handleRuntimeActionRoutes( { method: 'GET' } as never, createRouteTestResponse().response as never, @@ -10334,7 +10571,7 @@ async function smokeAppServerSessionLogThreadRead(): Promise { title: string cwd: string preview: string - turns: Array<{ items: Array<{ type: string; text?: string; content?: Array<{ text: string }> }> }> + turns: Array<{ items: Array<{ type: string; phase?: string; text?: string; content?: Array<{ text: string }> }> }> } } | null @@ -10347,13 +10584,81 @@ async function smokeAppServerSessionLogThreadRead(): Promise { assert.equal(threadRead?.thread.turns[0]?.items[0]?.type, 'userMessage') assert.equal(threadRead?.thread.turns[0]?.items[0]?.content?.[0]?.text, 'Restore this session') assert.equal(threadRead?.thread.turns[0]?.items[1]?.type, 'agentMessage') - assert.equal(threadRead?.thread.turns[0]?.items[1]?.text, 'Recovered answer') - assert.equal(threadRead?.thread.turns[0]?.items.length, 2) + assert.equal(threadRead?.thread.turns[0]?.items[1]?.phase, 'commentary') + assert.equal(threadRead?.thread.turns[0]?.items[1]?.text, 'Intermediate progress should not be restored') + assert.equal(threadRead?.thread.turns[0]?.items[2]?.text, 'Recovered answer') + assert.equal(threadRead?.thread.turns[0]?.items.length, 3) assert.equal(threadRead?.thread.turns[1]?.items[0]?.content?.[0]?.text, '继续') assert.equal(threadRead?.thread.turns[1]?.items[1]?.text, 'Second recovered answer') assert.equal(threadRead?.thread.turns[2]?.items[0]?.content?.[0]?.text, '继续') assert.equal(threadRead?.thread.turns[2]?.items[1]?.text, 'Third recovered answer') + const incrementalSessionPath = join(dir, 'rollout-2026-07-06T10-02-00-thread-incremental.jsonl') + const incrementalFallback = { + thread: { + id: 'thread-incremental', + preview: '', + createdAt: 0, + updatedAt: 0, + path: incrementalSessionPath, + cwd: 'E:/workspace/incremental', + turns: [], + }, + } + await writeFile(incrementalSessionPath, `${[ + JSON.stringify({ + timestamp: '2026-07-06T10:02:01.000Z', + type: 'response_item', + payload: { + type: 'message', + id: 'incremental-user-1', + role: 'user', + content: [{ type: 'input_text', text: 'Incremental request one' }], + }, + }), + JSON.stringify({ + timestamp: '2026-07-06T10:02:02.000Z', + type: 'response_item', + payload: { + type: 'message', + id: 'incremental-agent-1', + role: 'assistant', + content: [{ type: 'output_text', text: 'Incremental answer one' }], + }, + }), + ].join('\n')}\n`, 'utf8') + await readThreadReadFromSessionLog(incrementalSessionPath, incrementalFallback) + await appendFile(incrementalSessionPath, `${[ + JSON.stringify({ + timestamp: '2026-07-06T10:02:03.000Z', + type: 'response_item', + payload: { + type: 'message', + id: 'incremental-user-2', + role: 'user', + content: [{ type: 'input_text', text: 'Incremental request two' }], + }, + }), + JSON.stringify({ + timestamp: '2026-07-06T10:02:04.000Z', + type: 'response_item', + payload: { + type: 'message', + id: 'incremental-agent-2', + role: 'assistant', + content: [{ type: 'output_text', text: 'Incremental answer two' }], + }, + }), + ].join('\n')}\n`, 'utf8') + const incrementalThreadRead = await readThreadReadFromSessionLog( + incrementalSessionPath, + incrementalFallback, + ) as { + thread: { turns: Array<{ items: Array<{ text?: string }> }> } + } | null + assert.equal(incrementalThreadRead?.thread.turns.length, 2) + assert.equal(incrementalThreadRead?.thread.turns[1]?.items[1]?.text, 'Incremental answer two') + const largeSessionPath = join(dir, 'rollout-2026-07-06T10-05-00-thread-large-fallback.jsonl') await writeFile(largeSessionPath, [ 'x'.repeat(2_100_000), @@ -10663,13 +10968,11 @@ async function smokeAppServerThreadRuntimeSnapshot(): Promise { sessionFallbackWarnings.push({ message, details }) }, }) - assert.deepEqual(sessionFallbackRpcCalls.map(readIncludeTurns), [false, true]) + assert.deepEqual(sessionFallbackRpcCalls.map(readIncludeTurns), [false]) assert.deepEqual(sessionFallbackRemembered, [sessionFallbackThreadReadPayload]) assert.equal(sessionFallbackSnapshot.threadRead, sessionFallbackThreadReadPayload) assert.equal(sessionFallbackSnapshot.messageState, 'cached') - assert.deepEqual(sessionFallbackWarnings.map((warning) => warning.message), [ - 'Heavy thread snapshot fell back to session log messages', - ]) + assert.deepEqual(sessionFallbackWarnings, []) const cacheFirstRpcCalls: unknown[] = [] const cacheFirstRemembered: unknown[] = [] @@ -10740,6 +11043,70 @@ async function smokeAppServerThreadRuntimeSnapshot(): Promise { assert.deepEqual(cacheFirstRemembered, [{ threadRead: cacheFirstThreadRead, source: 'session-log' }]) assert.equal(cacheFirstSnapshot.messageState, 'cached') + const activeSessionRpcCalls: unknown[] = [] + const activeSessionThreadRead = { + thread: { + id: 'thread-active-session', + updatedAt: updatedAtSeconds, + inProgress: true, + activeTurnId: 'turn-active-session', + path: 'session-active.jsonl', + turns: [{ + id: 'turn-active-session', + status: 'inProgress', + items: [{ type: 'agentMessage', id: 'agent-active-session', text: 'Live session progress' }], + }], + }, + } + const activeSessionSnapshot = await readAppServerThreadRuntimeSnapshot('thread-active-session', { + rpc: async (_method, params) => { + activeSessionRpcCalls.push(params) + if (readIncludeTurns(params) === true) { + throw new Error('active session snapshot must not issue a heavy thread read') + } + return { + thread: { + id: 'thread-active-session', + updatedAt: updatedAtSeconds, + inProgress: true, + activeTurnId: 'turn-active-session', + path: 'session-active.jsonl', + }, + } + }, + observeThreadRead: () => {}, + getCachedThreadRead: () => null, + rememberCachedThreadRead: (_threadId, threadRead, source) => createCachedThreadRead( + threadRead, + () => '2026-01-01T00:00:30.000Z', + source, + ), + snapshotRuntime: (threadId, overlay = {}) => createThreadRuntimeSnapshot({ + threadId, + executionState: 'running', + activeTurnId: 'turn-active-session', + threadRead: overlay.threadRead ?? null, + messageState: overlay.messageState ?? 'unavailable', + pendingServerRequests: overlay.pendingServerRequests ?? [], + tokenUsage: overlay.tokenUsage ?? null, + }), + observeRuntimeThreadRead: () => {}, + markRuntimeDegraded: () => { + throw new Error('active session recovery should not mark degraded') + }, + persistRuntimeSnapshot: (_threadId, snapshot) => snapshot, + listPendingServerRequestsForThread: () => [], + getThreadTokenUsage: () => null, + readSessionLogThreadRead: async () => activeSessionThreadRead, + getErrorMessage, + writeWarning: () => { + throw new Error('active session recovery should not warn') + }, + }) + assert.deepEqual(activeSessionRpcCalls.map(readIncludeTurns), [false]) + assert.equal(activeSessionSnapshot.threadRead, activeSessionThreadRead) + assert.equal(activeSessionSnapshot.messageState, 'cached') + const sessionFallbackCacheHit = createCachedThreadRead( sessionFallbackThreadReadPayload, () => '2026-01-01T00:00:30.000Z', @@ -11194,6 +11561,149 @@ function smokeAppServerNotificationReplay(): void { }]) } +async function smokeRuntimeMessageQueue(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'cx-codex-runtime-queue-')) + const dbPath = join(tempDir, 'runtime.sqlite') + const store = new RuntimeStore(dbPath) + const notifications: Array<{ method: string; params: unknown }> = [] + const startedClientMessageIds: string[] = [] + const rpcMethods: string[] = [] + const queueStatusAtRpc: Array<{ method: string; status: string }> = [] + let currentServiceTier: 'fast' | null = null + let expectedConfigClientMessageId = 'queued-client-1' + let queue: RuntimeMessageQueue | null = null + try { + store.createRequest({ + requestId: 'active-request', + clientMessageId: 'active-client', + threadId: 'thread-queue', + status: 'running', + promptHash: 'active-hash', + mode: 'execute', + payload: {}, + }) + queue = new RuntimeMessageQueue({ + store, + rpc: async (method, params) => { + rpcMethods.push(method) + const queuedRequest = store.getLatestRequestByClientMessageId(expectedConfigClientMessageId) + if (queuedRequest) { + queueStatusAtRpc.push({ method, status: queuedRequest.status }) + } + if (method === 'config/read') { + return { config: { service_tier: currentServiceTier, features: { fast_mode: true } } } + } + if (method === 'config/batchWrite') { + updateRuntimeRequestsFromSnapshot('thread-queue', createThreadRuntimeSnapshot({ + executionState: 'idle', + inProgress: false, + }), store) + const edits = params && typeof params === 'object' && !Array.isArray(params) + && Array.isArray((params as Record).edits) + ? (params as Record).edits as unknown[] + : [] + const serviceTierEdit = edits.find((edit) => readStringProperty(edit, 'keyPath') === 'service_tier') + currentServiceTier = readStringProperty(serviceTierEdit, 'value') === 'fast' ? 'fast' : null + } + return {} + }, + startRuntimeTurn: async (payload) => { + const clientMessageId = readStringProperty(payload, 'clientMessageId') + startedClientMessageIds.push(clientMessageId) + const request = store.getLatestRequestByClientMessageId(clientMessageId) + assert.ok(request) + store.updateRequest(request.requestId, { status: 'running', turnId: `turn-${clientMessageId}` }) + return {} + }, + publishNotification: (notification) => { notifications.push(notification) }, + getErrorMessage: (error, fallback) => error instanceof Error ? error.message : fallback, + }) + queue.start() + + const first = queue.enqueue({ + threadId: 'thread-queue', + clientMessageId: 'queued-client-1', + input: [{ type: 'text', text: 'First queued prompt' }], + collaborationMode: 'execute', + queueMetadata: { speedMode: 'standard', text: 'First queued prompt' }, + }) + assert.equal(first.status, 'queued') + const reorderPeer = queue.enqueue({ + threadId: 'thread-queue', + clientMessageId: 'queued-client-reorder-peer', + input: [{ type: 'text', text: 'Temporary reorder peer' }], + collaborationMode: 'execute', + queueMetadata: { speedMode: 'standard', text: 'Temporary reorder peer' }, + }) + assert.equal(queue.reorder('thread-queue', [reorderPeer.requestId, first.requestId]), true) + assert.deepEqual( + queue.list('thread-queue').map((entry) => entry.clientMessageId), + ['queued-client-reorder-peer', 'queued-client-1'], + ) + assert.equal(queue.reorder('thread-queue', [first.requestId]), false) + assert.equal(queue.reorder('thread-queue', [first.requestId, reorderPeer.requestId]), true) + assert.equal(queue.cancel(reorderPeer.requestId), true) + await new Promise((resolve) => setTimeout(resolve, 30)) + assert.deepEqual(startedClientMessageIds, []) + assert.equal(queue.list('thread-queue').length, 1) + + store.updateRequest('active-request', { status: 'completed' }) + queue.handleRuntimeEvent('turn/completed', 'thread-queue') + await waitForCondition(() => startedClientMessageIds.length === 1) + assert.deepEqual(startedClientMessageIds, ['queued-client-1']) + assert.equal(queue.list('thread-queue').length, 0) + assert.deepEqual(rpcMethods, ['config/read']) + assert.deepEqual(queueStatusAtRpc, [{ method: 'config/read', status: 'queued' }]) + assert.equal(notifications.some((notification) => readStringProperty(notification.params, 'action') === 'starting'), true) + + store.updateRequest(first.requestId, { status: 'completed' }) + expectedConfigClientMessageId = 'queued-client-fast' + const fast = queue.enqueue({ + threadId: 'thread-queue', + clientMessageId: 'queued-client-fast', + input: [{ type: 'text', text: 'Fast queued prompt' }], + collaborationMode: 'execute', + queueMetadata: { speedMode: 'fast', text: 'Fast queued prompt' }, + }) + await waitForCondition(() => startedClientMessageIds.length === 2) + assert.deepEqual(startedClientMessageIds, ['queued-client-1', 'queued-client-fast']) + assert.equal(rpcMethods.includes('config/batchWrite'), true) + assert.equal(store.getRequest(fast.requestId)?.status, 'running') + assert.equal( + queueStatusAtRpc.every((entry) => entry.status === 'queued'), + true, + 'queued speed configuration must finish before claiming pending_start', + ) + store.updateRequest(fast.requestId, { status: 'completed' }) + queue.dispose() + queue = null + const failedQueue = new RuntimeMessageQueue({ + store, + rpc: async () => ({}), + startRuntimeTurn: async () => { throw new Error('background start failed') }, + publishNotification: (notification) => { notifications.push(notification) }, + getErrorMessage: (error, fallback) => error instanceof Error ? error.message : fallback, + }) + const failed = failedQueue.enqueue({ + threadId: 'thread-queue', + clientMessageId: 'queued-client-failed', + input: [{ type: 'text', text: 'Failed queued prompt' }], + queueMetadata: { speedMode: 'standard', text: 'Failed queued prompt' }, + }) + await waitForCondition(() => store.getRequest(failed.requestId)?.status === 'queue_failed') + assert.equal(failedQueue.list('thread-queue')[0]?.lastError, 'background start failed') + assert.equal(failedQueue.retry(failed.requestId), true) + assert.equal(store.getRequest(failed.requestId)?.status, 'queued') + assert.equal(failedQueue.cancel(failed.requestId), true) + assert.equal(failedQueue.list('thread-queue').length, 0) + failedQueue.dispose() + } finally { + queue?.dispose() + store.close() + await rm(tempDir, { recursive: true, force: true }) + } +} + async function smokeRuntimeStoreMaintenance(): Promise { const root = await mkdtemp(join(tmpdir(), 'cx-codex-runtime-store-')) const dbPath = join(root, 'runtime.sqlite') diff --git a/scripts/verify-frontend-normalizers.mjs b/scripts/verify-frontend-normalizers.mjs index 6e8ae2c..f443618 100644 --- a/scripts/verify-frontend-normalizers.mjs +++ b/scripts/verify-frontend-normalizers.mjs @@ -16,6 +16,7 @@ const notificationReplayImport = toImportPath(relative(outputRoot, join(repoRoot const connectionManagerImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'connectionManager.ts'))) const conversationViewportImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'conversationViewport.ts'))) const runtimeSnapshotOrderingImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'runtimeSnapshotOrdering.ts'))) +const runtimeExecutionRecoveryImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'runtimeExecutionRecovery.ts'))) const messageOutboxMergeImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'messageOutboxMerge.ts'))) const messageIdentityImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'messageIdentity.ts'))) const composerTurnOptionsImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'composerTurnOptions.ts'))) @@ -31,6 +32,9 @@ const latestReplyImport = toImportPath(relative(outputRoot, join(repoRoot, 'src' const taskPetReadPolicyImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'mobile', 'taskPetReadPolicy.ts'))) const sessionFileChangeImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'sessionFileChange.ts'))) const composerEnterBehaviorImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'composerEnterBehavior.ts'))) +const threadGoalImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'composables', 'threadGoal.ts'))) +const codexFileCitationImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'utils', 'codexFileCitation.ts'))) +const runtimeMessageQueueImport = toImportPath(relative(outputRoot, join(repoRoot, 'src', 'api', 'runtimeMessageQueue.ts'))) try { writeFileSync(entryPath, ` @@ -48,10 +52,12 @@ import { isConversationViewportAtBottom, } from '${conversationViewportImport}' import { shouldApplyRuntimeSnapshotVersion } from '${runtimeSnapshotOrderingImport}' +import { isOptimisticOnlyExecutionEvidence } from '${runtimeExecutionRecoveryImport}' import { mergeMessageOutboxEntries, mergeMessageOutboxState } from '${messageOutboxMergeImport}' import { createClientMessageId, filterVisibleOptimisticUserMessages, + mergeVisibleOptimisticUserMessages, userMessageSignature, } from '${messageIdentityImport}' import { @@ -64,7 +70,9 @@ import { } from '${messageOutboxPersistenceImport}' import { areMessageFieldsEqual, + hasPlanImplementationConfirmation, mergeMessages, + PLAN_IMPLEMENTATION_CONFIRMATION, removeRedundantLiveAgentMessages, removeStaleHistoryNoticeAfterOlderMerge, sortMessagesByTurnIndex, @@ -102,9 +110,90 @@ import { CX_SESSION_FILES_CHANGED_METHOD, getCxSessionFileChangeSyncPolicy, isCxSessionFilesChangedMethod, + readCxSessionFileChangeOrigin, readCxSessionFileChangeSource, } from '${sessionFileChangeImport}' import { resolveSendWithEnterPreference } from '${composerEnterBehaviorImport}' +import { normalizeThreadGoal } from '${threadGoalImport}' +import { + readCodexFileCitationAt, + splitCodexFileCitations, +} from '${codexFileCitationImport}' +import { + mergePersistedRuntimeQueuedMessages, + mergeRuntimeMessageQueueThreadState, +} from '${runtimeMessageQueueImport}' + +const queuedA = { id: 'local-a', clientMessageId: 'client-a', deliveryState: 'queued' } as any +const queuedB = { id: 'local-b', clientMessageId: 'client-b', deliveryState: 'queued' } as any +const queuedC = { id: 'local-c', clientMessageId: 'client-c', deliveryState: 'queued' } as any +const serverA = { ...queuedA, id: 'server-a', serverRequestId: 'server-a', backgroundPersisted: true } +const serverC = { ...queuedC, id: 'server-c', serverRequestId: 'server-c', backgroundPersisted: true } +assert.deepEqual(mergePersistedRuntimeQueuedMessages([queuedA, queuedB], [serverA]), { + queue: [serverA, queuedB], + orphanedServerRequestIds: [], +}) +assert.deepEqual(mergePersistedRuntimeQueuedMessages([queuedB], [serverA]), { + queue: [queuedB], + orphanedServerRequestIds: ['server-a'], +}) +assert.deepEqual( + mergeRuntimeMessageQueueThreadState([serverA, queuedB, serverC], [serverC, serverA]), + [serverA, queuedB, serverC], +) +assert.deepEqual( + mergeRuntimeMessageQueueThreadState([serverA, queuedB, serverC], [serverC, serverA], false), + [serverC, serverA, queuedB], +) +assert.deepEqual(mergeRuntimeMessageQueueThreadState([serverA], []), []) + +const resumePdfCitation = ':codex-file-citation{path="E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf" purpose="产品与项目经理通用投递简历"}' +assert.deepEqual(readCodexFileCitationAt(resumePdfCitation, 0), { + raw: resumePdfCitation, + start: 0, + end: resumePdfCitation.length, + path: 'E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf', + purpose: '产品与项目经理通用投递简历', + attributes: { + path: 'E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf', + purpose: '产品与项目经理通用投递简历', + }, +}) +assert.deepEqual( + splitCodexFileCitations('PDF:' + resumePdfCitation + '。'), + [ + { kind: 'text', value: 'PDF:' }, + { + kind: 'citation', + citation: { + raw: resumePdfCitation, + start: 4, + end: 4 + resumePdfCitation.length, + path: 'E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf', + purpose: '产品与项目经理通用投递简历', + attributes: { + path: 'E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf', + purpose: '产品与项目经理通用投递简历', + }, + }, + }, + { kind: 'text', value: '。' }, + ], +) +const windowsSeparator = String.fromCharCode(92) +const spacedDocxPath = ['E:', '投递材料', '产品 经理', '邵卫 简历.docx'].join(windowsSeparator) +const spacedDocxCitation = ':codex-file-citation{purpose="带 ' + windowsSeparator + '"引号' + windowsSeparator + '" 的定制简历" path="' + spacedDocxPath + '" artifact_kind="document" page_number="2"}' +assert.equal(readCodexFileCitationAt(spacedDocxCitation, 0)?.path, spacedDocxPath) +assert.equal(readCodexFileCitationAt(spacedDocxCitation, 0)?.purpose, '带 "引号" 的定制简历') +assert.equal(readCodexFileCitationAt(spacedDocxCitation, 0)?.attributes.page_number, '2') +const unquotedXlsxCitation = ':codex-file-citation{path=E:/reports/项目清单.xlsx artifact_kind=workbook range=Sheet1!A1:D20}' +assert.equal(readCodexFileCitationAt(unquotedXlsxCitation, 0)?.path, 'E:/reports/项目清单.xlsx') +assert.equal(readCodexFileCitationAt(unquotedXlsxCitation, 0)?.attributes.range, 'Sheet1!A1:D20') +assert.equal(splitCodexFileCitations(resumePdfCitation + unquotedXlsxCitation).filter((part) => part.kind === 'citation').length, 2) +assert.equal(readCodexFileCitationAt(':codex-file-citation{purpose="缺少路径"}', 0)?.path, '') +assert.deepEqual(splitCodexFileCitations('保留 :codex-file-citation{path="E:/unfinished.pdf"'), [ + { kind: 'text', value: '保留 :codex-file-citation{path="E:/unfinished.pdf"' }, +]) assert.equal(CONVERSATION_BOTTOM_THRESHOLD_PX, 24) assert.equal(CX_SESSION_FILES_CHANGED_METHOD, 'cx/session-files/changed') @@ -113,24 +202,79 @@ assert.equal(isCxSessionFilesChangedMethod('turn/completed'), false) assert.equal(readCxSessionFileChangeSource({ source: 'session-log' }), 'session-log') assert.equal(readCxSessionFileChangeSource({ source: 'session-index' }), 'session-index') assert.equal(readCxSessionFileChangeSource({ source: 'unknown' }), '') +assert.equal(readCxSessionFileChangeOrigin({ origin: 'live-app-server' }), 'live-app-server') +assert.equal(readCxSessionFileChangeOrigin({ origin: 'external' }), 'external') +assert.equal(readCxSessionFileChangeOrigin({ origin: 'unknown' }), '') assert.deepEqual( getCxSessionFileChangeSyncPolicy(CX_SESSION_FILES_CHANGED_METHOD, { source: 'session-log' }), - { refreshMessages: true, refreshThreads: false }, + { refreshMessages: true, refreshThreads: false, preferSessionLogMessages: true }, +) +assert.deepEqual( + getCxSessionFileChangeSyncPolicy(CX_SESSION_FILES_CHANGED_METHOD, { + source: 'session-log', + origin: 'live-app-server', + }), + { refreshMessages: true, refreshThreads: false, preferSessionLogMessages: true }, ) assert.deepEqual( getCxSessionFileChangeSyncPolicy(CX_SESSION_FILES_CHANGED_METHOD, { source: 'session-index' }), - { refreshMessages: false, refreshThreads: true }, + { refreshMessages: false, refreshThreads: true, preferSessionLogMessages: false }, ) assert.deepEqual( getCxSessionFileChangeSyncPolicy(CX_SESSION_FILES_CHANGED_METHOD, {}), - { refreshMessages: true, refreshThreads: true }, + { refreshMessages: true, refreshThreads: true, preferSessionLogMessages: false }, ) assert.equal(getCxSessionFileChangeSyncPolicy('turn/completed', { source: 'session-log' }), null) +assert.equal(isOptimisticOnlyExecutionEvidence({ + executionActive: true, + sourceInProgress: false, + runtimeFreshActive: true, + hasRunningCommand: false, + hasPendingServerRequest: false, + hasFreshExecutionSignal: false, + pendingTurnAgeMs: null, + recoveryGraceMs: 2_500, + hasActiveTurnId: true, + queueProcessing: false, +}), false) +assert.equal(isOptimisticOnlyExecutionEvidence({ + executionActive: true, + sourceInProgress: false, + runtimeFreshActive: false, + hasRunningCommand: false, + hasPendingServerRequest: false, + hasFreshExecutionSignal: false, + pendingTurnAgeMs: null, + recoveryGraceMs: 2_500, + hasActiveTurnId: true, + queueProcessing: false, +}), true) assert.equal(resolveSendWithEnterPreference(null, false), true) assert.equal(resolveSendWithEnterPreference(null, true), false) assert.equal(resolveSendWithEnterPreference('1', true), true) assert.equal(resolveSendWithEnterPreference('0', false), false) assert.equal(resolveSendWithEnterPreference('invalid', true), false) +assert.deepEqual(normalizeThreadGoal({ + threadId: 'thread-goal', + objective: 'Keep improving', + status: 'active', + tokenBudget: 1000, + tokensUsed: 120, + timeUsedSeconds: 30, + createdAt: 1, + updatedAt: 2, +}), { + threadId: 'thread-goal', + objective: 'Keep improving', + status: 'active', + tokenBudget: 1000, + tokensUsed: 120, + timeUsedSeconds: 30, + createdAt: 1, + updatedAt: 2, +}) +assert.equal(normalizeThreadGoal({ threadId: 'thread-goal', objective: 'x', status: 'unknown' }), null) +assert.equal(normalizeThreadGoal({ threadId: '', objective: 'x', status: 'active' }), null) assert.equal(conversationDistanceFromBottom({ scrollHeight: 1000, scrollTop: 676, clientHeight: 300 }), 24) assert.equal(isConversationViewportAtBottom({ scrollHeight: 1000, scrollTop: 676, clientHeight: 300 }), true) assert.equal(isConversationViewportAtBottom({ scrollHeight: 1000, scrollTop: 675, clientHeight: 300 }), false) @@ -501,6 +645,8 @@ const rememberedIdentityMeta = new Map([[optimisticIdentityMessage.id, { kind: 'optimisticUserMessage', signature: identitySignature, baselineMatchCount: 1, + baselineMessageCount: 2, + baselineTailMessageId: 'baseline-assistant', createdAtMs: 1, }]]) assert.deepEqual( @@ -519,6 +665,73 @@ assert.deepEqual( ), [], ) +assert.deepEqual( + mergeVisibleOptimisticUserMessages( + [ + persistedIdentityMessage, + { id: 'baseline-assistant', role: 'assistant', text: 'Previous answer' }, + { id: 'new-assistant', role: 'assistant', text: 'Reply arrived before history acknowledgement' }, + ], + [optimisticIdentityMessage], + rememberedIdentityMeta, + ).map((message) => message.id), + ['persisted-identity-1', 'baseline-assistant', 'optimistic-user:identity-1', 'new-assistant'], +) +const optimisticAfterFirst = { id: 'optimistic-user:after-first', role: 'user', text: 'First queued prompt' } +const optimisticAfterLast = { id: 'optimistic-user:after-last', role: 'user', text: 'Second queued prompt' } +assert.deepEqual( + mergeVisibleOptimisticUserMessages( + [ + { id: 'persisted-a', role: 'assistant', text: 'A' }, + { id: 'persisted-b', role: 'assistant', text: 'B' }, + { id: 'persisted-c', role: 'assistant', text: 'C' }, + ], + [optimisticAfterFirst, optimisticAfterLast], + new Map([ + [optimisticAfterFirst.id, { + kind: 'optimisticUserMessage', + signature: userMessageSignature(optimisticAfterFirst), + baselineMatchCount: 0, + baselineMessageCount: 1, + baselineTailMessageId: 'persisted-a', + createdAtMs: 2, + }], + [optimisticAfterLast.id, { + kind: 'optimisticUserMessage', + signature: userMessageSignature(optimisticAfterLast), + baselineMatchCount: 0, + baselineMessageCount: 3, + baselineTailMessageId: 'persisted-c', + createdAtMs: 3, + }], + ]), + ).map((message) => message.id), + ['persisted-a', 'optimistic-user:after-first', 'persisted-b', 'persisted-c', 'optimistic-user:after-last'], +) + +const generatedImageMessages = normalizeThreadMessagesV2({ + thread: { + id: 'thread-generated-image', + cwd: 'E:\\repo', + preview: '', + updatedAt: 1, + createdAt: 1, + turns: [{ + id: 'turn-generated-image', + status: 'completed', + items: [{ + id: 'generated-image-1', + type: 'imageGeneration', + status: 'completed', + savedPath: 'C:\\work\\generated.png', + result: 'a'.repeat(512), + }], + }], + }, +}) +assert.equal(generatedImageMessages.length, 1) +assert.equal(generatedImageMessages[0]?.messageType, 'imageGeneration') +assert.deepEqual(generatedImageMessages[0]?.images, ['C:\\work\\generated.png']) const historyNoticeMessage = { id: 'history-notice', @@ -562,6 +775,37 @@ assert.equal(areMessageFieldsEqual(projectedCommand, { ...projectedCommand, commandExecution: { ...projectedCommand.commandExecution, command: 'npm run test' }, }), false) +const projectedPlan = { + id: 'plan:turn-projection', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'turn-projection', + explanation: 'Plan safely', + steps: [{ step: 'Inspect', status: 'pending' }], + rawText: '', + isStreaming: false, + }, +} +assert.equal(areMessageFieldsEqual(projectedPlan, { ...projectedPlan, plan: { ...projectedPlan.plan } }), true) +assert.equal(areMessageFieldsEqual(projectedPlan, { + ...projectedPlan, + plan: { ...projectedPlan.plan, steps: [{ step: 'Inspect', status: 'completed' }] }, +}), false) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { id: 'plan-confirmation', role: 'user', text: PLAN_IMPLEMENTATION_CONFIRMATION }, +], projectedPlan.id), true) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { id: 'ordinary-follow-up', role: 'user', text: '继续检查,但先不要执行' }, +], projectedPlan.id), false) +assert.equal(hasPlanImplementationConfirmation([ + projectedPlan, + { ...projectedPlan, id: 'plan:newer-turn' }, + { id: 'late-confirmation', role: 'user', text: PLAN_IMPLEMENTATION_CONFIRMATION }, +], projectedPlan.id), false) assert.equal(areMessageFieldsEqual(projectedCommand, { ...projectedCommand, commandExecution: { ...projectedCommand.commandExecution, cwd: 'E:/other' }, @@ -717,6 +961,7 @@ const messages = normalizeThreadMessagesV2({ status: 'completed', items: [ { id: 'item-known', type: 'agentMessage', text: 'Known message' }, + { id: 'item-plan', type: 'plan', text: '1. Inspect\\n2. Implement' }, { id: 'item-mcp', type: 'mcpToolCall', @@ -756,16 +1001,21 @@ const messages = normalizeThreadMessagesV2({ }, }) -assert.equal(messages.length, 3) +assert.equal(messages.length, 4) assert.equal(messages[0]?.messageType, 'agentMessage') assert.equal(messages[1]?.role, 'system') -assert.equal(messages[1]?.messageType, 'unhandled.threadShellCommandOutput') -assert.equal(messages[1]?.text, 'Unhandled App Server item: threadShellCommandOutput') -assert.equal(messages[1]?.isUnhandled, true) -assert.equal(messages[1]?.turnIndex, 0) -assert.equal(messages[1]?.rawPayload?.includes('secret command'), true) -assert.equal(messages[2]?.messageType, 'unhandled.invalidItem') +assert.equal(messages[1]?.id, 'plan:turn-a') +assert.equal(messages[1]?.messageType, 'plan') +assert.equal(messages[1]?.plan?.turnId, 'turn-a') +assert.equal(messages[1]?.plan?.rawText, '1. Inspect\\n2. Implement') +assert.equal(messages[1]?.plan?.isStreaming, false) +assert.equal(messages[2]?.messageType, 'unhandled.threadShellCommandOutput') +assert.equal(messages[2]?.text, 'Unhandled App Server item: threadShellCommandOutput') assert.equal(messages[2]?.isUnhandled, true) +assert.equal(messages[2]?.turnIndex, 0) +assert.equal(messages[2]?.rawPayload?.includes('secret command'), true) +assert.equal(messages[3]?.messageType, 'unhandled.invalidItem') +assert.equal(messages[3]?.isUnhandled, true) assert.equal(messages.some((message) => message.messageType === 'unhandled.fileChange'), false) assert.equal(messages.some((message) => message.messageType === 'unhandled.webSearch'), false) assert.equal(messages.some((message) => message.rawPayload?.includes('large internal patch details')), false) diff --git a/src/App.vue b/src/App.vue index fedda60..dcc6f46 100644 --- a/src/App.vue +++ b/src/App.vue @@ -813,6 +813,8 @@ :show-empty-thread-actions="isRouteOnlyEmptyThread" :is-turn-in-progress="isSelectedThreadInProgress" :is-rolling-back="isRollingBack" + :implementing-plan-id="implementingPlanId" + :implemented-plan-ids="implementedPlanIds" @update-scroll-state="onUpdateThreadScrollState" @respond-server-request="onRespondServerRequest" @toggle-favorite="onToggleFavoriteMessage" @@ -823,6 +825,7 @@ @dismiss-empty-thread="onDismissEmptyThread" @copy-status="onConversationCopyStatus" @retry-failed-message="retryFailedUserMessage" + @implement-plan="onImplementPlan" @rollback="onRollback" /> @@ -846,6 +849,20 @@ @retry="retryQueuedMessage" @delete="deleteQueuedMessage" /> + import('./components/content/ThreadGoalBar.vue')) const QueuedMessages = defineAsyncComponent(() => import('./components/content/QueuedMessages.vue')) const RateLimitStatus = defineAsyncComponent(() => import('./components/content/RateLimitStatus.vue')) const FavoritesModal = defineAsyncComponent(() => import('./components/content/FavoritesModal.vue')) @@ -1442,6 +1460,10 @@ const { selectedLiveOverlay, selectedThreadRuntimeStatus, selectedThreadTokenUsage, + selectedThreadGoal, + isSelectedThreadGoalLoading, + isSelectedThreadGoalUpdating, + selectedThreadGoalError, selectedThreadLoadError, selectedThreadId, availableModels, @@ -1508,6 +1530,10 @@ const { setWorktreeGitAutomationEnabled, setSelectedReasoningEffort, setSelectedCollaborationMode, + refreshSelectedThreadGoal, + saveSelectedThreadGoal, + updateSelectedThreadGoalStatus, + clearSelectedThreadGoal, updateSelectedSpeedMode, respondToPendingServerRequest, renameProject, @@ -1530,6 +1556,8 @@ const isSettingsSheetMode = computed(() => isMobile.value || isDualPaneMobile.va const { favorites, toggleFavorite, removeFavorite, refreshFavorites } = useFavorites() const homeThreadComposerRef = ref(null) const threadComposerRef = ref(null) +const implementingPlanId = ref('') +const implementedPlanIds = ref([]) const threadConversationRef = ref(null) const sidebarThreadTreeRef = ref<{ revealSelectedThread: () => Promise } | null>(null) const sidebarScrollableRef = ref(null) @@ -2333,6 +2361,13 @@ const displayFavorites = computed(() => ( )) const isSelectedThreadInProgress = computed(() => !isHomeRoute.value && selectedThreadExecutionActive.value) const isSelectedThreadInterruptible = computed(() => !isHomeRoute.value && selectedThreadCanStop.value) +const selectedThreadGoalExecutionHint = computed(() => { + if (selectedThreadGoal.value?.status !== 'active') return '' + if (selectedThreadServerRequests.value.length > 0) return '等待确认' + if (selectedThreadQueuedMessages.value.length > 0) return '等待消息队列' + if (isSelectedThreadInProgress.value) return '正在推进' + return '等待继续' +}) const shouldShowSelectedThreadProcessing = computed(() => ( selectedThreadServerRequests.value.length > 0 || selectedLiveOverlay.value !== null || @@ -4581,8 +4616,62 @@ function onSelectCollaborationMode(mode: CollaborationMode): void { setSelectedCollaborationMode(mode) } +function onSaveThreadGoal(objective: string): void { + void saveSelectedThreadGoal(objective).catch(() => { + // The desktop state exposes the actionable RPC error in the shared error banner. + }) +} + +function onSetThreadGoalStatus(status: 'active' | 'paused'): void { + void updateSelectedThreadGoalStatus(status).catch(() => { + // Keep the existing goal visible so the user can retry without re-entering it. + }) +} + +function onClearThreadGoal(): void { + void clearSelectedThreadGoal().catch(() => { + // The goal remains visible when the authoritative clear fails. + }) +} + +async function onImplementPlan(message: UiMessage): Promise { + const threadId = selectedThreadId.value + if ( + !threadId + || isSelectedThreadInProgress.value + || implementingPlanId.value + || implementedPlanIds.value.includes(message.id) + ) return + const previousMode = selectedCollaborationMode.value + implementingPlanId.value = message.id + setSelectedCollaborationMode('execute') + try { + await sendMessageToSelectedThread( + PLAN_IMPLEMENTATION_CONFIRMATION, + [], + [], + 'steer', + [], + undefined, + 'execute', + ) + implementedPlanIds.value = [...implementedPlanIds.value, message.id].slice(-12) + markDesktopSyncPending(threadId) + } catch { + if (selectedThreadId.value === threadId) setSelectedCollaborationMode(previousMode) + showProductToast('计划提交失败,计划卡已保留,可直接重试。', 'danger') + } finally { + if (implementingPlanId.value === message.id) implementingPlanId.value = '' + } +} + function onInterruptTurn(source: 'composer-stop' | 'runtime-status-stop' | 'unknown' = 'unknown'): void { - showProductToast('已请求停止,正在确认任务状态。', 'warning') + showProductToast( + selectedThreadGoal.value?.status === 'active' + ? '已请求停止,持续目标已同时暂停。' + : '已请求停止,正在确认任务状态。', + 'warning', + ) void interruptSelectedThreadTurn(source) } diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index c312020..42d9b4d 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -35,11 +35,14 @@ import type { SpeedMode, UiMessage, UiProjectGroup, + UiThreadGoal, + UiThreadGoalStatus, UiThreadTokenUsage, UiTokenUsageBreakdown, } from '../types/codex' import { normalizePathForUi } from '../pathUtils.js' import { shouldAutoLoginForResponse, tryMobileShellAutoLogin } from '../mobile/mobileAuth' +import { normalizeThreadGoal } from '../composables/threadGoal' type CurrentModelConfig = { model: string @@ -170,6 +173,8 @@ function throwIfSignalAborted(signal: AbortSignal | undefined): void { } export type RuntimeRequestStatus = + | 'queued' + | 'queue_failed' | 'pending_start' | 'starting' | 'running' @@ -628,6 +633,8 @@ function normalizeThreadTokenUsage(value: unknown): UiThreadTokenUsage | null { } } +const rejectedThreadListCursorsByArchiveState = new Map>() + async function listThreadsByArchiveState( archived: boolean, options: ThreadListOptions = {}, @@ -640,6 +647,7 @@ async function listThreadsByArchiveState( let pageCount = 0 do { + if (cursor && rejectedThreadListCursorsByArchiveState.get(archived)?.has(cursor)) break pageCount += 1 let payload: ThreadListResponse try { @@ -651,6 +659,9 @@ async function listThreadsByArchiveState( }, options) } catch (error) { if (cursor && data.length > 0 && !isAbortLikeError(error)) { + const rejected = rejectedThreadListCursorsByArchiveState.get(archived) ?? new Set() + rejected.add(cursor) + rejectedThreadListCursorsByArchiveState.set(archived, rejected) console.warn('Stopped thread/list pagination after a cursor error', error) break } @@ -1038,6 +1049,29 @@ export async function renameThread(threadId: string, threadName: string): Promis await callRpc('thread/name/set', { threadId, name: threadName }) } +export async function getThreadGoal(threadId: string, options: RpcCallOptions = {}): Promise { + const payload = await callRpc<{ goal?: unknown }>('thread/goal/get', { threadId }, options) + return normalizeThreadGoal(payload?.goal) +} + +export async function setThreadGoal( + threadId: string, + input: { objective?: string; status?: Extract }, +): Promise { + const params: Record = { threadId } + const objective = input.objective?.trim() + if (objective) params.objective = objective + if (input.status) params.status = input.status + const payload = await callRpc<{ goal?: unknown }>('thread/goal/set', params) + const goal = normalizeThreadGoal(payload?.goal) + if (!goal) throw new Error('thread/goal/set did not return a valid goal') + return goal +} + +export async function clearThreadGoal(threadId: string): Promise { + await callRpc('thread/goal/clear', { threadId }) +} + export async function rollbackThread(threadId: string, numTurns: number): Promise { const payload = await callRpc('thread/rollback', { threadId, numTurns }) return normalizeThreadMessagesV2(payload) @@ -1060,6 +1094,8 @@ function normalizeThreadIdFromPayload(payload: unknown): string { function normalizeRuntimeRequestStatus(value: unknown): RuntimeRequestStatus { const normalized = typeof value === 'string' ? value.trim() : '' if ( + normalized === 'queued' || + normalized === 'queue_failed' || normalized === 'pending_start' || normalized === 'starting' || normalized === 'running' || @@ -2438,7 +2474,7 @@ function getErrorMessageFromPayload(payload: unknown, fallback: string): string return typeof error === 'string' && error.trim().length > 0 ? error : fallback } -export type ThreadTitleCache = { titles: Record; order: string[] } +export type ThreadTitleCache = { titles: Record; order: string[]; manualTitleIds: string[] } let supportsThreadTitleGeneration: boolean | null = null export async function getThreadTitleCache(): Promise { @@ -2447,20 +2483,24 @@ export async function getThreadTitleCache(): Promise { timeoutMs: GATEWAY_BACKGROUND_FETCH_TIMEOUT_MS, label: 'Thread title cache request', }) - if (!response.ok) return { titles: {}, order: [] } + if (!response.ok) return { titles: {}, order: [], manualTitleIds: [] } const envelope = (await response.json()) as { data?: ThreadTitleCache } - return envelope.data ?? { titles: {}, order: [] } + return envelope.data ?? { titles: {}, order: [], manualTitleIds: [] } } catch { - return { titles: {}, order: [] } + return { titles: {}, order: [], manualTitleIds: [] } } } -export async function persistThreadTitle(id: string, title: string): Promise { +export async function persistThreadTitle( + id: string, + title: string, + options: { manual?: boolean } = {}, +): Promise { try { await fetchWithTimeout('/codex-api/thread-titles', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, title }), + body: JSON.stringify({ id, title, manual: options.manual === true }), }, { timeoutMs: GATEWAY_BACKGROUND_FETCH_TIMEOUT_MS, label: 'Thread title save request', diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index 7c5c360..433e479 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -223,7 +223,26 @@ function extractAssistantImages(item: ThreadItem): string[] { return images } -function toUiMessages(item: ThreadItem): UiMessage[] { +function normalizeGeneratedImageResult(value: unknown): string { + const result = readTrimmedString(value) + if (!result) return '' + if ( + result.startsWith('data:image/') + || result.startsWith('http://') + || result.startsWith('https://') + || result.startsWith('blob:') + || result.startsWith('/') + || /^[A-Za-z]:[\\/]/u.test(result) + ) { + return result + } + + const compact = result.replace(/\s+/gu, '') + if (compact.length < 256 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(compact)) return '' + return `data:image/png;base64,${compact}` +} + +function toUiMessages(item: ThreadItem, turnId = ''): UiMessage[] { const rawItem = item as Record const itemId = readTrimmedString(rawItem.id) || `unhandled:${readTrimmedString(rawItem.type) || 'item'}` const itemType = readTrimmedString(rawItem.type) @@ -254,6 +273,26 @@ function toUiMessages(item: ThreadItem): UiMessage[] { ] } + if (item.type === 'plan') { + const text = typeof item.text === 'string' ? item.text : '' + const normalizedTurnId = turnId.trim() + return [ + { + id: normalizedTurnId ? `plan:${normalizedTurnId}` : item.id, + role: 'system', + text, + messageType: 'plan', + plan: { + turnId: normalizedTurnId, + explanation: '', + steps: [], + rawText: text, + isStreaming: false, + }, + }, + ] + } + if (item.type === 'imageView') { const images: string[] = [] pushImageCandidate(images, rawItem.path) @@ -271,6 +310,25 @@ function toUiMessages(item: ThreadItem): UiMessage[] { ] } + if (itemType === 'imageGeneration') { + const images: string[] = [] + pushImageCandidate(images, rawItem.savedPath) + pushImageCandidate(images, rawItem.path) + pushImageCandidate(images, rawItem.url) + if (images.length === 0) { + const generatedResult = normalizeGeneratedImageResult(rawItem.result) + if (generatedResult) pushImageCandidate(images, generatedResult) + } + if (images.length === 0) return [] + return [{ + id: itemId, + role: 'assistant', + text: '', + images, + messageType: itemType, + }] + } + if (item.type === 'userMessage') { const parsed = parseUserMessageContent(item.id, item.content as UserInput[] | undefined) const messages: UiMessage[] = [] @@ -507,7 +565,7 @@ export function normalizeThreadMessagesV2(payload: ThreadReadResponse): UiMessag ? item : { id: `turn-${String(turnIndex)}:item-${String(messages.length)}`, type: 'invalidItem', content: item } ) as ThreadItem - for (const msg of toUiMessages(threadItem)) { + for (const msg of toUiMessages(threadItem, readTrimmedString(rawTurn.id))) { messages.push({ ...msg, turnIndex: absoluteTurnIndex }) } } diff --git a/src/api/runtimeMessageQueue.ts b/src/api/runtimeMessageQueue.ts new file mode 100644 index 0000000..7c92785 --- /dev/null +++ b/src/api/runtimeMessageQueue.ts @@ -0,0 +1,481 @@ +import type { + CollaborationMode, + ComposerPluginSelection, + ComposerTurnOptions, + ReasoningEffort, + SpeedMode, +} from '../types/codex' +import type { FileAttachmentParam } from './codexGateway' +import { normalizeComposerTurnOptions } from '../composables/composerTurnOptions' + +const QUEUE_REQUEST_TIMEOUT_MS = 15_000 + +export type RuntimeMessageQueueEntry = { + id: string + clientMessageId: string + threadId: string + deliveryState: 'queued' | 'failed' + text: string + imageUrls: string[] + skills: Array<{ name: string; path: string }> + fileAttachments: FileAttachmentParam[] + modelId: string + reasoningEffort: ReasoningEffort | '' + speedMode: SpeedMode + collaborationMode: CollaborationMode + turnOptions?: ComposerTurnOptions + createdAtIso: string + error: string | null +} + +export type RuntimeMessageQueueEnqueueArgs = { + threadId: string + text: string + imageUrls?: string[] + model?: string + effort?: ReasoningEffort + skills?: Array<{ name: string; path: string }> + fileAttachments?: FileAttachmentParam[] + collaborationMode?: CollaborationMode + turnOptions?: ComposerTurnOptions + clientMessageId: string + speedMode: SpeedMode +} + +export type RuntimeQueuedMessage = { + id: string + serverRequestId?: string + backgroundPersisted?: boolean + clientMessageId: string + deliveryState: 'queued' | 'failed' + text: string + imageUrls: string[] + skills: Array<{ name: string; path: string }> + fileAttachments: FileAttachmentParam[] + modelId: string + reasoningEffort: ReasoningEffort | '' + speedMode: SpeedMode + collaborationMode: CollaborationMode + turnOptions?: ComposerTurnOptions +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + +function errorMessageFromPayload(payload: unknown, fallback: string): string { + const error = asRecord(payload)?.error + return typeof error === 'string' && error.trim() ? error : fallback +} + +async function queueFetch(url: string, init: RequestInit, label: string): Promise { + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), QUEUE_REQUEST_TIMEOUT_MS) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error(`${label} timed out`) + } + throw error + } finally { + window.clearTimeout(timeout) + } +} + +function normalizeReasoningEffort(value: unknown): ReasoningEffort | '' { + const allowed: ReasoningEffort[] = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'] + return typeof value === 'string' && allowed.includes(value as ReasoningEffort) + ? value as ReasoningEffort + : '' +} + +function buildTextWithAttachments(prompt: string, files: FileAttachmentParam[]): string { + if (files.length === 0) return prompt + const lines = ['# Files mentioned by the user:'] + for (const file of files) lines.push('', `## ${file.label}: ${file.path}`) + lines.push('', '## My request for Codex:', '', prompt, '') + return lines.join('\n') +} + +function isLocalImageInput(value: string): boolean { + return value.startsWith('file://') || /^[A-Za-z]:[\\/]/u.test(value) || (value.startsWith('/') && !value.startsWith('/codex-local-image?')) +} + +function buildTurnStartInput( + text: string, + imageUrls: string[], + skills: Array<{ name: string; path: string }>, + fileAttachments: FileAttachmentParam[], + plugins: ComposerPluginSelection[] = [], +): Array> { + const input: Array> = [ + { type: 'text', text: buildTextWithAttachments(text, fileAttachments) }, + ] + for (const rawUrl of imageUrls) { + const url = rawUrl.trim() + if (!url) continue + input.push(isLocalImageInput(url) + ? { type: 'localImage', path: url } + : { type: 'image', url, image_url: url }) + } + for (const skill of skills) input.push({ type: 'skill', name: skill.name, path: skill.path }) + for (const plugin of plugins) { + const name = plugin.name.trim() + const id = plugin.id.trim() + const path = plugin.path?.trim() || (plugin.source === 'app' ? `app://${id}` : `plugin://${id}`) + if (name && path) input.push({ type: 'mention', name, path }) + } + return input +} + +function normalizeEntry(value: unknown): RuntimeMessageQueueEntry | null { + const row = asRecord(value) + const requestId = typeof row?.requestId === 'string' ? row.requestId.trim() : '' + const threadId = typeof row?.threadId === 'string' ? row.threadId.trim() : '' + if (!row || !requestId || !threadId) return null + const metadata = asRecord(asRecord(row.payload)?.queueMetadata) ?? {} + const skills = Array.isArray(metadata.skills) + ? metadata.skills.flatMap((item) => { + const skill = asRecord(item) + const name = typeof skill?.name === 'string' ? skill.name : '' + const path = typeof skill?.path === 'string' ? skill.path : '' + return name && path ? [{ name, path }] : [] + }) + : [] + const fileAttachments = Array.isArray(metadata.fileAttachments) + ? metadata.fileAttachments.flatMap((item) => { + const file = asRecord(item) + const label = typeof file?.label === 'string' ? file.label : '' + const path = typeof file?.path === 'string' ? file.path : '' + const fsPath = typeof file?.fsPath === 'string' ? file.fsPath : '' + return label && (path || fsPath) ? [{ label, path, fsPath }] : [] + }) + : [] + const turnOptions = asRecord(metadata.turnOptions) + return { + id: requestId, + clientMessageId: typeof row.clientMessageId === 'string' ? row.clientMessageId.trim() : '', + threadId, + deliveryState: row.status === 'queue_failed' ? 'failed' : 'queued', + text: typeof metadata.text === 'string' ? metadata.text : '', + imageUrls: Array.isArray(metadata.imageUrls) + ? metadata.imageUrls.filter((item): item is string => typeof item === 'string') + : [], + skills, + fileAttachments, + modelId: typeof metadata.modelId === 'string' ? metadata.modelId.trim() : '', + reasoningEffort: normalizeReasoningEffort(metadata.reasoningEffort), + speedMode: metadata.speedMode === 'fast' ? 'fast' : 'standard', + collaborationMode: metadata.collaborationMode === 'plan' ? 'plan' : 'execute', + turnOptions: turnOptions ? turnOptions as ComposerTurnOptions : undefined, + createdAtIso: typeof row.createdAtIso === 'string' ? row.createdAtIso : '', + error: typeof row.lastError === 'string' ? row.lastError : null, + } +} + +function queuedMessageFromRuntime(entry: RuntimeMessageQueueEntry): RuntimeQueuedMessage { + return { + id: entry.id, + serverRequestId: entry.id, + backgroundPersisted: true, + clientMessageId: entry.clientMessageId, + deliveryState: entry.deliveryState, + text: entry.text, + imageUrls: [...entry.imageUrls], + skills: entry.skills.map((skill) => ({ ...skill })), + fileAttachments: entry.fileAttachments.map((file) => ({ ...file })), + modelId: entry.modelId, + reasoningEffort: entry.reasoningEffort, + speedMode: entry.speedMode, + collaborationMode: entry.collaborationMode, + turnOptions: normalizeComposerTurnOptions(entry.turnOptions), + } +} + +function normalizeQueuedMessage(value: unknown): RuntimeQueuedMessage | null { + const row = asRecord(value) + const id = typeof row?.id === 'string' ? row.id.trim() : '' + if (!row || !id) return null + const skills = Array.isArray(row.skills) + ? row.skills.flatMap((item) => { + const skill = asRecord(item) + const name = typeof skill?.name === 'string' ? skill.name : '' + const path = typeof skill?.path === 'string' ? skill.path : '' + return name && path ? [{ name, path }] : [] + }) + : [] + const fileAttachments = Array.isArray(row.fileAttachments) + ? row.fileAttachments.flatMap((item) => { + const file = asRecord(item) + const label = typeof file?.label === 'string' ? file.label : '' + const path = typeof file?.path === 'string' ? file.path : '' + const fsPath = typeof file?.fsPath === 'string' ? file.fsPath : '' + return label && (path || fsPath) ? [{ label, path, fsPath }] : [] + }) + : [] + return { + id, + serverRequestId: typeof row.serverRequestId === 'string' && row.serverRequestId.trim() + ? row.serverRequestId.trim() + : undefined, + backgroundPersisted: row.backgroundPersisted === true, + clientMessageId: typeof row.clientMessageId === 'string' && row.clientMessageId.trim() + ? row.clientMessageId.trim() + : `queued-${id}`, + deliveryState: row.deliveryState === 'failed' ? 'failed' : 'queued', + text: typeof row.text === 'string' ? row.text : '', + imageUrls: Array.isArray(row.imageUrls) + ? row.imageUrls.filter((item): item is string => typeof item === 'string') + : [], + skills, + fileAttachments, + modelId: typeof row.modelId === 'string' ? row.modelId.trim() : '', + reasoningEffort: normalizeReasoningEffort(row.reasoningEffort), + speedMode: row.speedMode === 'fast' ? 'fast' : 'standard', + collaborationMode: row.collaborationMode === 'plan' ? 'plan' : 'execute', + turnOptions: normalizeComposerTurnOptions(row.turnOptions), + } +} + +export function loadQueuedMessagesMap(storageKey: string): Record { + try { + const raw = window.localStorage.getItem(storageKey) + if (!raw) return {} + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + const next: Record = {} + for (const [threadId, rows] of Object.entries(parsed as Record)) { + if (!threadId || !Array.isArray(rows)) continue + const queue = rows.map(normalizeQueuedMessage).filter((row): row is RuntimeQueuedMessage => row !== null) + if (queue.length > 0) next[threadId] = queue + } + return next + } catch { + return {} + } +} + +export function saveQueuedMessagesMap( + storageKey: string, + queueByThreadId: Record, +): void { + try { + window.localStorage.setItem(storageKey, JSON.stringify(queueByThreadId)) + } catch { + // Keep the in-memory queue when browser storage is unavailable. + } +} + +export function mergeRuntimeMessageQueueThreadState( + currentQueue: RuntimeQueuedMessage[], + serverQueue: RuntimeQueuedMessage[], + preserveCurrentOrder = true, +): RuntimeQueuedMessage[] { + if (!preserveCurrentOrder) { + const serverClientMessageIds = new Set(serverQueue.map((message) => message.clientMessageId)) + const localOnly = currentQueue.filter((message) => ( + !message.serverRequestId && !serverClientMessageIds.has(message.clientMessageId) + )) + return [...serverQueue, ...localOnly] + } + const serverByClientMessageId = new Map( + serverQueue.map((message) => [message.clientMessageId, message] as const), + ) + const next: RuntimeQueuedMessage[] = [] + for (const message of currentQueue) { + const serverMessage = serverByClientMessageId.get(message.clientMessageId) + if (serverMessage) { + next.push(serverMessage) + serverByClientMessageId.delete(message.clientMessageId) + } else if (!message.serverRequestId) { + next.push(message) + } + } + next.push(...serverByClientMessageId.values()) + return next +} + +export function mergePersistedRuntimeQueuedMessages( + currentQueue: RuntimeQueuedMessage[], + persistedQueue: RuntimeQueuedMessage[], +): { queue: RuntimeQueuedMessage[]; orphanedServerRequestIds: string[] } { + const currentClientMessageIds = new Set(currentQueue.map((message) => message.clientMessageId)) + const persistedByClientMessageId = new Map( + persistedQueue.map((message) => [message.clientMessageId, message] as const), + ) + return { + queue: currentQueue.map((message) => ( + persistedByClientMessageId.get(message.clientMessageId) ?? message + )), + orphanedServerRequestIds: persistedQueue.flatMap((message) => ( + message.serverRequestId && !currentClientMessageIds.has(message.clientMessageId) + ? [message.serverRequestId] + : [] + )), + } +} + +export async function syncRuntimeMessageQueueState( + current: Record, + threadId = '', + preserveCurrentOrder = true, +): Promise> { + const normalizedThreadId = threadId.trim() + const entries = await listRuntimeMessageQueue(normalizedThreadId) + const serverByThreadId = new Map() + for (const entry of entries) { + const queue = serverByThreadId.get(entry.threadId) ?? [] + queue.push(queuedMessageFromRuntime(entry)) + serverByThreadId.set(entry.threadId, queue) + } + const merge = (targetThreadId: string): RuntimeQueuedMessage[] => { + const serverQueue = serverByThreadId.get(targetThreadId) ?? [] + return mergeRuntimeMessageQueueThreadState( + current[targetThreadId] ?? [], + serverQueue, + preserveCurrentOrder, + ) + } + if (normalizedThreadId) { + const queue = merge(normalizedThreadId) + const next = { ...current } + if (queue.length > 0) next[normalizedThreadId] = queue + else delete next[normalizedThreadId] + return next + } + const next: Record = {} + const threadIds = new Set([...Object.keys(current), ...serverByThreadId.keys()]) + for (const targetThreadId of threadIds) { + const queue = merge(targetThreadId) + if (queue.length > 0) next[targetThreadId] = queue + } + return next +} + +function isRetryableQueueError(error: unknown): boolean { + if (error instanceof TypeError || error instanceof SyntaxError) return true + if (!(error instanceof Error)) return false + const message = error.message.toLowerCase() + return ['failed to fetch', 'networkerror', 'network error', 'load failed', 'connection reset', 'connection refused', 'timed out'] + .some((fragment) => message.includes(fragment)) +} + +export async function persistRuntimeQueuedMessages( + threadId: string, + queue: RuntimeQueuedMessage[], +): Promise { + let next = [...queue] + for (const message of queue) { + if (message.deliveryState === 'failed') break + if (message.serverRequestId) continue + try { + const entry = await enqueueRuntimeThreadTurn({ + threadId, + text: message.text, + imageUrls: message.imageUrls, + model: message.modelId || undefined, + effort: message.reasoningEffort || undefined, + skills: message.skills, + fileAttachments: message.fileAttachments, + collaborationMode: message.collaborationMode, + turnOptions: message.turnOptions, + clientMessageId: message.clientMessageId, + speedMode: message.speedMode, + }) + next = next.map((candidate) => candidate.clientMessageId === message.clientMessageId + ? queuedMessageFromRuntime(entry) + : candidate) + } catch (error) { + if (!isRetryableQueueError(error)) { + next = next.map((candidate) => candidate.id === message.id + ? { ...candidate, deliveryState: 'failed' } + : candidate) + } + break + } + } + return next +} + +export async function enqueueRuntimeThreadTurn(args: RuntimeMessageQueueEnqueueArgs): Promise { + const fileAttachments = args.fileAttachments ?? [] + const imageUrls = args.imageUrls ?? [] + const skills = args.skills ?? [] + const collaborationMode = args.collaborationMode ?? 'execute' + const body: Record = { + threadId: args.threadId.trim(), + input: buildTurnStartInput(args.text, imageUrls, skills, fileAttachments, args.turnOptions?.plugins), + attachments: fileAttachments.map((file) => ({ ...file })), + collaborationMode, + turnOptions: args.turnOptions, + clientMessageId: args.clientMessageId, + queueMetadata: { + text: args.text, + imageUrls, + skills, + fileAttachments, + modelId: args.model?.trim() ?? '', + reasoningEffort: args.effort ?? '', + speedMode: args.speedMode, + collaborationMode, + turnOptions: args.turnOptions, + }, + } + if (args.model?.trim()) body.model = args.model.trim() + if (args.effort?.trim()) body.effort = args.effort.trim() + + const response = await queueFetch('/codex-api/runtime/queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, 'Runtime queue request') + const payload = (await response.json()) as unknown + if (!response.ok && response.status !== 202) { + throw new Error(errorMessageFromPayload(payload, 'Failed to queue runtime turn')) + } + const entry = normalizeEntry(asRecord(payload)?.data) + if (!entry) throw new Error('Runtime queue response was invalid') + return entry +} + +export async function listRuntimeMessageQueue(threadId = ''): Promise { + const query = threadId.trim() ? `?threadId=${encodeURIComponent(threadId.trim())}` : '' + const response = await queueFetch(`/codex-api/runtime/queue${query}`, { method: 'GET' }, 'Runtime queue list request') + const payload = (await response.json()) as unknown + if (!response.ok) throw new Error(errorMessageFromPayload(payload, 'Failed to load runtime queue')) + const rows = asRecord(payload)?.data + return Array.isArray(rows) + ? rows.map(normalizeEntry).filter((entry): entry is RuntimeMessageQueueEntry => entry !== null) + : [] +} + +export async function removeRuntimeQueuedMessage(requestId: string): Promise { + const response = await queueFetch(`/codex-api/runtime/queue/${encodeURIComponent(requestId)}`, { method: 'DELETE' }, 'Runtime queue remove request') + if (!response.ok && response.status !== 404) { + throw new Error(errorMessageFromPayload(await response.json(), 'Failed to remove queued message')) + } +} + +export async function retryRuntimeQueuedMessage(requestId: string): Promise { + const response = await queueFetch(`/codex-api/runtime/queue/${encodeURIComponent(requestId)}/retry`, { method: 'POST' }, 'Runtime queue retry request') + if (!response.ok && response.status !== 202) { + throw new Error(errorMessageFromPayload(await response.json(), 'Failed to retry queued message')) + } +} + +export async function reorderRuntimeQueuedMessages(threadId: string, requestIds: string[]): Promise { + if (!threadId.trim() || requestIds.length < 2) return true + const response = await queueFetch('/codex-api/runtime/queue/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId: threadId.trim(), requestIds }), + }, 'Runtime queue reorder request') + if (response.status === 409) return false + if (!response.ok) { + throw new Error(errorMessageFromPayload(await response.json(), 'Failed to reorder queued messages')) + } + return true +} diff --git a/src/components/content/ComposerRegressionFixture.vue b/src/components/content/ComposerRegressionFixture.vue index 95f58f9..f179cfc 100644 --- a/src/components/content/ComposerRegressionFixture.vue +++ b/src/components/content/ComposerRegressionFixture.vue @@ -14,8 +14,30 @@ > 模拟语音转文字 + + {{ fixtureThreadId }} {{ submitCount }} + import { computed, onBeforeUnmount, ref } from 'vue' import ThreadComposer, { type SubmitPayload, type ThreadComposerExposed } from './ThreadComposer.vue' -import type { ComposerModelInfo, ComposerPluginInfo, ReasoningEffort } from '../../types/codex' +import ThreadGoalBar from './ThreadGoalBar.vue' +import type { CollaborationMode, ComposerModelInfo, ComposerPluginInfo, ReasoningEffort, UiThreadGoal } from '../../types/codex' import { useMobile } from '../../composables/useMobile' import { resolveSendWithEnterPreference } from '../../composables/composerEnterBehavior' @@ -94,6 +117,24 @@ const availableModels: ComposerModelInfo[] = [ ] const composerRef = ref(null) const submitCount = ref(0) +const fixtureParams = typeof window !== 'undefined' + ? new URLSearchParams(window.location.hash.split('?')[1] ?? '') + : new URLSearchParams() +const showGoalFixture = fixtureParams.get('goal') === '1' +const isGoalSwitchFixture = fixtureParams.get('goalSwitch') === '1' +const fixtureThreadId = ref(isGoalSwitchFixture ? 'fixture-thread-a' : 'fixture-thread-composer') +const selectedCollaborationMode = ref(fixtureParams.get('planMode') === '1' ? 'plan' : 'execute') +const fixtureGoalError = ref(fixtureParams.get('goalError') === '1' ? '持续目标同步失败,请重试。' : '') +const fixtureGoal = ref(fixtureParams.get('goalEmpty') === '1' ? null : { + threadId: fixtureThreadId.value, + objective: '持续完善 7420 的稳定性与细节体验,并以可复现回归作为完成标准。', + status: 'active', + tokenBudget: 120_000, + tokensUsed: 36_800, + timeUsedSeconds: 4_260, + createdAt: Date.now() - 4_260_000, + updatedAt: Date.now(), +}) const { isMobile } = useMobile() const sendWithEnter = computed(() => resolveSendWithEnterPreference(null, isMobile.value)) const originalFetch = window.fetch @@ -154,6 +195,40 @@ function noop(): void { // Fixture route only needs rendered output for browser assertions. } +function updateFixtureGoal(objective: string): void { + fixtureGoal.value = fixtureGoal.value + ? { ...fixtureGoal.value, objective, updatedAt: Date.now() } + : { + threadId: fixtureThreadId.value, + objective, + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + } +} + +function updateFixtureGoalStatus(status: 'active' | 'paused'): void { + if (!fixtureGoal.value) return + fixtureGoal.value = { ...fixtureGoal.value, status, updatedAt: Date.now() } +} + +function switchFixtureGoalThread(): void { + fixtureThreadId.value = fixtureThreadId.value === 'fixture-thread-a' ? 'fixture-thread-b' : 'fixture-thread-a' + fixtureGoal.value = { + threadId: fixtureThreadId.value, + objective: `持续目标:${fixtureThreadId.value}`, + status: 'active', + tokenBudget: 120_000, + tokensUsed: 36_800, + timeUsedSeconds: 4_260, + createdAt: Date.now() - 4_260_000, + updatedAt: Date.now(), + } +} + function onSubmit(_payload: SubmitPayload): void { submitCount.value += 1 } diff --git a/src/components/content/ConversationRegressionFixture.vue b/src/components/content/ConversationRegressionFixture.vue index bba1462..88805af 100644 --- a/src/components/content/ConversationRegressionFixture.vue +++ b/src/components/content/ConversationRegressionFixture.vue @@ -53,7 +53,7 @@ :pending-requests="pendingRequests" :live-overlay="liveOverlay" :is-loading="false" - :is-turn-in-progress="!isLoadFailureFixture && !isScrollSwitchRaceFixture" + :is-turn-in-progress="!isLoadFailureFixture && !isScrollSwitchRaceFixture && !isPlanFixture && !isFileCitationFixture" :load-error="isLoadFailureFixture ? '连接不到桌面端,会话内容暂时未加载。页面会自动重试,也可以检查或修改连接地址。' : ''" :show-connection-settings-action="isLoadFailureFixture" compact-runtime-chrome @@ -61,6 +61,8 @@ cwd="E:/javaword/CXCodex/codexui" :scroll-state="activeScrollState" :favorite-message-ids="favoriteMessageIds" + :implementing-plan-id="fixtureImplementingPlanId" + :implemented-plan-ids="fixtureImplementedPlanIds" @update-scroll-state="onUpdateScrollState" @respond-server-request="noop" @rollback="noop" @@ -71,6 +73,7 @@ @return-to-new-thread="noop" @dismiss-empty-thread="noop" @retry-failed-message="noop" + @implement-plan="implementFixturePlan" @copy-status="copyStatus = $event" />

(isPlanSubmittedFixture ? ['plan:fixture-plan-turn'] : []) const activeThreadId = ref(isScrollSwitchRaceFixture ? 'regression-scroll-a' : 'regression-conversation-blocks') const scrollStateByThreadId = ref>({}) const activeScrollState = computed(() => scrollStateByThreadId.value[activeThreadId.value] ?? null) @@ -393,6 +403,68 @@ const markdownImageMessages: UiMessage[] = [ turnIndex: 9, }, ] +const fileCitationMessages: UiMessage[] = [ + { + id: 'fixture-codex-file-citation', + role: 'assistant', + text: [ + '已生成产品与项目经理通用投递版简历。', + '', + ':codex-file-citation{path="E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.pdf" purpose="产品与项目经理通用投递简历"}', + '', + '可编辑内容::codex-file-citation{path="E:/javaword/CXCodex/role_resumes/邵卫-产品与项目经理-优化投递版-2026-08-03.md" purpose="产品与项目经理简历 Markdown 版本"}', + ].join('\n'), + turnIndex: 10, + }, +] +const planMessages: UiMessage[] = [ + { + id: 'plan:fixture-older-plan-turn', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'fixture-older-plan-turn', + explanation: '这是较早的计划,默认应收起以降低长会话噪声。', + steps: [ + { step: '读取现有实现', status: 'completed' }, + { step: '列出体验问题', status: 'completed' }, + ], + rawText: '', + isStreaming: false, + }, + turnIndex: 9, + }, + { + id: 'fixture-plan-request', + role: 'user', + text: '请先规划如何稳定实现持续目标,不要修改文件。', + turnIndex: 10, + }, + { + id: 'plan:fixture-plan-turn', + role: 'system', + text: '', + messageType: 'plan', + plan: { + turnId: 'fixture-plan-turn', + explanation: '先确认桌面端协议,再以最小改动补齐状态、交互和验证闭环。', + steps: [ + { step: '核对 thread/goal 与 turn/plan 事件结构', status: 'completed' }, + { step: '接入线程级目标生命周期和持续计划模式', status: 'completed' }, + { step: '补充计划增量合并,避免频繁重绘', status: 'completed' }, + { step: '为目标读取增加请求去重与旧响应保护', status: 'completed' }, + { step: '优化移动端目标操作区', status: 'inProgress' }, + { step: '验证清除确认和错误重试', status: 'pending' }, + { step: '完成浏览器回归并记录兼容边界', status: 'pending' }, + { step: '整理发布前验收结论', status: 'pending' }, + ], + rawText: '', + isStreaming: false, + }, + turnIndex: 10, + }, +] const scrollRaceMessagesByThreadId = ref>(Object.fromEntries( ['regression-scroll-a', 'regression-scroll-b'].map((threadId) => [ threadId, @@ -411,6 +483,17 @@ const fixtureMessages = computed(() => { } if (isImagePreviewFixture) return [...messages, imagePreviewMessage] if (isMarkdownImageFixture) return [...messages, ...markdownImageMessages] + if (isFileCitationFixture) return fileCitationMessages + if (isPlanFixture) { + return isPlanHistoryImplementedFixture + ? [...planMessages, { + id: 'fixture-plan-implementation-confirmation', + role: 'user' as const, + text: PLAN_IMPLEMENTATION_CONFIRMATION, + turnIndex: 11, + }] + : planMessages + } return messages }) const pendingRequests: UiServerRequest[] = isTailStatusFixture || isLoadFailureFixture ? [] : allPendingRequests @@ -428,7 +511,7 @@ const liveOverlay = ref({ errorText: '', }) -if (isLoadFailureFixture || isScrollSwitchRaceFixture) { +if (isLoadFailureFixture || isScrollSwitchRaceFixture || isPlanFixture || isFileCitationFixture) { liveOverlay.value = null } @@ -498,6 +581,15 @@ function noop(): void { // Fixture route only needs rendered output for browser assertions. } +function implementFixturePlan(message: UiMessage): void { + if (fixtureImplementingPlanId.value || fixtureImplementedPlanIds.value.includes(message.id)) return + fixtureImplementingPlanId.value = message.id + window.setTimeout(() => { + fixtureImplementedPlanIds.value = [...fixtureImplementedPlanIds.value, message.id] + fixtureImplementingPlanId.value = '' + }, 400) +} + function appendResumeOutput(): void { const threadId = activeThreadId.value const currentMessages = scrollRaceMessagesByThreadId.value[threadId] ?? [] diff --git a/src/components/content/QueuedMessages.vue b/src/components/content/QueuedMessages.vue index 8c3ca36..a9e4170 100644 --- a/src/components/content/QueuedMessages.vue +++ b/src/components/content/QueuedMessages.vue @@ -8,14 +8,17 @@

下一条未能发送,队列已暂停。重试、编辑或删除后继续。

+

+ 已保存在本机,正在等待连接 7420;同步完成后才可脱离移动端执行。 +

- 当前任务结束后按顺序执行。点正文可继续编辑。 + 已交给 7420 后台;切换会话或关闭移动端后仍会按顺序执行。点正文可编辑。

() const hasPausedFailure = computed(() => props.messages[0]?.deliveryState === 'failed') +const hasLocalPending = computed(() => props.messages.some((message) => message.backgroundPersisted !== true)) function getMessagePreview(message: QueuedMessageRow): string { const text = message.text.trim() diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 373a580..15c8cd6 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -221,7 +221,10 @@
-
+
× + +
@@ -409,9 +428,9 @@ > - 仅生成计划 + 计划模式 - {{ selectedCollaborationMode === 'plan' ? '本次只生成计划,发送后回到执行' : '仅规划一次,不执行文件和命令' }} + {{ selectedCollaborationMode === 'plan' ? '持续开启,仅规划,不执行修改' : '开启后持续使用计划模式' }}