From 3106f22cf9d3b76e1a3a1675a75c8e17eb8159f1 Mon Sep 17 00:00:00 2001 From: ackingliu Date: Tue, 1 Sep 2026 14:52:02 +0800 Subject: [PATCH 1/4] chore(deps): update codex to upstream main (+318 commits) Opening an existing conversation could fail with errors from the vendored codex thread-store: "unknown variant `completed`" for a stored SubAgentActivity item, and "invalid paginated history lineage: cycle detected". Neither was our bug. Codex Desktop writes ~/.codex with a newer schema than deps/codex was pinned to, so our reader rejected records whose variants it lacked. Upstream already carries the reads: SubAgentActivityKind::Completed (openai/codex#40437), RolloutItem::RealtimeItem (#40508), and SessionMeta.forked_from_ordinal_exclusive (#40987), which decouples a thread's logical fork boundary from its physical history_base so a paginated thread survives revert. Merged rather than rebased, matching the two prior upstream syncs. The Windows console-suppression and ConPTY adaptations carried over without conflict. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 42 ++++++++++++++++++++++++++++++++++++++++-- deps/codex | 2 +- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e41d8ff..115dfd7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1783,6 +1783,19 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "codex-agent-roles" +version = "0.0.0" +dependencies = [ + "codex-config", + "codex-file-system", + "codex-utils-absolute-path", + "codex-utils-path-uri", + "serde", + "toml 0.9.12+spec-1.1.0", + "tracing", +] + [[package]] name = "codex-analytics" version = "0.0.0" @@ -2006,6 +2019,7 @@ version = "0.0.0" dependencies = [ "anyhow", "codex-apply-patch", + "codex-async-utils", "codex-exec-server", "codex-install-context", "codex-linux-sandbox", @@ -2015,6 +2029,7 @@ dependencies = [ "codex-utils-home-dir", "codex-windows-sandbox", "dotenvy", + "pathdiff", "tempfile", "tokio", ] @@ -2126,15 +2141,14 @@ dependencies = [ "codex-code-mode-protocol", "codex-http-client", "codex-install-context", + "codex-otel", "codex-protocol", - "codex-websocket-client", "futures", "http-body-util", "prost", "reqwest 0.12.28", "serde_json", "tokio", - "tokio-tungstenite 0.28.0", "tokio-util", "tonic", "tower", @@ -2263,6 +2277,7 @@ dependencies = [ "chrono", "clap", "codex-agent-graph-store", + "codex-agent-roles", "codex-analytics", "codex-api", "codex-app-server-protocol", @@ -2283,6 +2298,7 @@ dependencies = [ "codex-feedback", "codex-file-system", "codex-git-utils", + "codex-guardian-context", "codex-history", "codex-hooks", "codex-http-client", @@ -2568,11 +2584,16 @@ name = "codex-feedback" version = "0.0.0" dependencies = [ "anyhow", + "bytes", "codex-http-client", "codex-login", "codex-protocol", + "flate2", + "http 1.4.0", + "httpdate", "mime_guess", "sentry", + "tokio", "tracing", "tracing-subscriber", ] @@ -2668,6 +2689,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-guardian-context" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "serde_json", +] + [[package]] name = "codex-guardian-v2" version = "0.0.0" @@ -2676,15 +2705,20 @@ dependencies = [ "codex-core", "codex-extension-api", "codex-features", + "codex-history", "codex-http-client", "codex-login", "codex-model-provider", + "codex-models-manager", + "codex-network-proxy", "codex-protocol", + "dirs", "http 1.4.0", "serde_json", "thiserror 2.0.18", "tokio", "tracing", + "uuid", ] [[package]] @@ -2830,6 +2864,7 @@ dependencies = [ "globset", "landlock", "libc", + "rustix 1.1.4", "seccompiler", "serde", "serde_json", @@ -3110,6 +3145,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-path-uri", "codex-utils-plugins", + "serde_json", "thiserror 2.0.18", ] @@ -3150,6 +3186,7 @@ dependencies = [ "codex-utils-redacted-string", "codex-utils-string", "encoding_rs", + "gix-url", "globset", "http 1.4.0", "icu_decimal", @@ -3295,6 +3332,7 @@ dependencies = [ "libc", "regex-lite", "serde_json", + "tokio", "tracing", "url", "which 8.0.4", diff --git a/deps/codex b/deps/codex index 4598954..05daa82 160000 --- a/deps/codex +++ b/deps/codex @@ -1 +1 @@ -Subproject commit 45989543c9484106048f2a96144b0443e7c048a1 +Subproject commit 05daa82dea055af698d44f3bc2e5e94a8fdfe764 From 0a52779c8b7c4a473fb5cc096ac6ba34e9bfca80 Mon Sep 17 00:00:00 2001 From: ackingliu Date: Tue, 1 Sep 2026 21:24:43 +0800 Subject: [PATCH 2/4] feat(app): load a long thread's history a page at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paginated threads reject a whole-history read, so opening one failed outright after the codex bump. They now load a bounded tail plus a skeleton of every turn, and the transcript fills in on demand: - `thread_read` branches on `historyMode`. Paginated threads walk `thread/turns/list` + `thread/items/list`; legacy threads keep the whole-history read, which is the only shape their rollout supports. - New `thread_older_page` / `thread_turn_items` continue backwards and fetch one turn, for scrolling up and for jumping via the rail. - Cursor walks copy upstream's `advancing_cursor`, so a server that repeats a cursor ends the walk instead of looping forever. - Items come from bounded item pages, never `itemsView: "full"`, which makes the server walk each turn's items in nested loops inside one request — a turn with hundreds of items blew past the request timeout. - `thread_summary` reads a summary view instead of the whole transcript for one sentence. The turn rail now draws from the turn skeleton rather than the loaded rows, so a long conversation shows its true length immediately instead of growing as history arrives. Hovering an unloaded tick prefetches it; the skeleton already carries the preview text, so previews cost nothing. Also lands the log plumbing that made this diagnosable: captured lines mirror to `/logs/` with 6-hour retention, and every JSON-RPC call records its direction, duration, size and in-flight depth. Three rounds of reading code had blamed the wrong thing each time; the log found the real stall on the first try. `design/HANDOFF-thread-list-stall.md` documents an unrelated outage found along the way — `thread/list` never answers on a long-lived adopted app-server, so the client times out and reconnects every 72s. Root cause unconfirmed; the doc records the evidence, the leading hypothesis, and the dead ends. Co-Authored-By: Claude Fable 5 --- apps/flutter/lib/l10n/app_en.arb | 4 + apps/flutter/lib/l10n/app_zh.arb | 1 + .../lib/l10n/gen/app_localizations.dart | 6 + .../lib/l10n/gen/app_localizations_en.dart | 3 + .../lib/l10n/gen/app_localizations_zh.dart | 3 + apps/flutter/lib/src/bridge_api.dart | 61 ++ apps/flutter/lib/src/bridge_api_rust.dart | 66 +- apps/flutter/lib/src/providers.dart | 38 + apps/flutter/lib/src/rust/api/bridge.dart | 125 +++- apps/flutter/lib/src/rust/frb_generated.dart | 289 ++++++-- .../lib/src/rust/frb_generated.io.dart | 35 + .../lib/src/rust/frb_generated.web.dart | 35 + .../lib/src/screens/app_session_screen.dart | 312 +++++++- .../flutter/lib/src/widgets/turn_minimap.dart | 23 +- apps/flutter/test/fake_bridge_api.dart | 37 + .../test/screens/app_session_test.dart | 197 +++++ crates/pocket-codex-bridge/src/api/bridge.rs | 100 ++- .../src/engine/app_session.rs | 694 +++++++++++++++++- .../pocket-codex-bridge/src/engine/logging.rs | 102 ++- .../pocket-codex-bridge/src/frb_generated.rs | 297 ++++++-- crates/pocket-codex-codex/src/client.rs | 50 +- design/HANDOFF-thread-list-stall.md | 202 +++++ 22 files changed, 2507 insertions(+), 173 deletions(-) create mode 100644 design/HANDOFF-thread-list-stall.md diff --git a/apps/flutter/lib/l10n/app_en.arb b/apps/flutter/lib/l10n/app_en.arb index 2ba9e50..c4c2b76 100644 --- a/apps/flutter/lib/l10n/app_en.arb +++ b/apps/flutter/lib/l10n/app_en.arb @@ -110,6 +110,10 @@ "interrupt": "Interrupt", "thinking": "Thinking…", "emptyConversation": "Send a message to start the conversation", + "olderHistoryHint": "Scroll up to load earlier messages", + "@olderHistoryHint": { + "description": "Leading row of a long conversation whose earlier history hasn't been loaded yet; scrolling to it fetches the previous page." + }, "turnFailed": "Turn didn't finish — the connection dropped or the remote codex failed. Retry, or check codex on the host machine (it may need to be logged in again).", "sandboxHelperUnavailable": "This built-in (自带) session can't start its command sandbox, so the agent can't run commands or read files here. Switch to \"Full access\" mode to run without a sandbox, or connect to an external/remote codex host.", "@sandboxHelperUnavailable": { diff --git a/apps/flutter/lib/l10n/app_zh.arb b/apps/flutter/lib/l10n/app_zh.arb index 7a71b72..30f7bb9 100644 --- a/apps/flutter/lib/l10n/app_zh.arb +++ b/apps/flutter/lib/l10n/app_zh.arb @@ -86,6 +86,7 @@ "interrupt": "打断", "thinking": "思考中…", "emptyConversation": "发送消息开始对话", + "olderHistoryHint": "向上滚动加载更早的消息", "turnFailed": "本轮未完成 —— 连接中断或远程 codex 异常。请重试,或检查主机上的 codex(可能需重新登录)。", "sandboxHelperUnavailable": "此自带会话无法启动命令沙箱,因此智能体在这里无法执行命令或读取文件。请切换到「完全放行」模式(不使用沙箱运行),或连接到外部/远程 codex 主机。", "disconnect": "断开连接", diff --git a/apps/flutter/lib/l10n/gen/app_localizations.dart b/apps/flutter/lib/l10n/gen/app_localizations.dart index 3fb15d2..3a64b87 100644 --- a/apps/flutter/lib/l10n/gen/app_localizations.dart +++ b/apps/flutter/lib/l10n/gen/app_localizations.dart @@ -578,6 +578,12 @@ abstract class AppLocalizations { /// **'Send a message to start the conversation'** String get emptyConversation; + /// Leading row of a long conversation whose earlier history hasn't been loaded yet; scrolling to it fetches the previous page. + /// + /// In en, this message translates to: + /// **'Scroll up to load earlier messages'** + String get olderHistoryHint; + /// No description provided for @turnFailed. /// /// In en, this message translates to: diff --git a/apps/flutter/lib/l10n/gen/app_localizations_en.dart b/apps/flutter/lib/l10n/gen/app_localizations_en.dart index 830e045..9f0cf7f 100644 --- a/apps/flutter/lib/l10n/gen/app_localizations_en.dart +++ b/apps/flutter/lib/l10n/gen/app_localizations_en.dart @@ -279,6 +279,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get emptyConversation => 'Send a message to start the conversation'; + @override + String get olderHistoryHint => 'Scroll up to load earlier messages'; + @override String get turnFailed => 'Turn didn\'t finish — the connection dropped or the remote codex failed. Retry, or check codex on the host machine (it may need to be logged in again).'; diff --git a/apps/flutter/lib/l10n/gen/app_localizations_zh.dart b/apps/flutter/lib/l10n/gen/app_localizations_zh.dart index 30d4eda..1d23c61 100644 --- a/apps/flutter/lib/l10n/gen/app_localizations_zh.dart +++ b/apps/flutter/lib/l10n/gen/app_localizations_zh.dart @@ -275,6 +275,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get emptyConversation => '发送消息开始对话'; + @override + String get olderHistoryHint => '向上滚动加载更早的消息'; + @override String get turnFailed => '本轮未完成 —— 连接中断或远程 codex 异常。请重试,或检查主机上的 codex(可能需重新登录)。'; diff --git a/apps/flutter/lib/src/bridge_api.dart b/apps/flutter/lib/src/bridge_api.dart index 79073d7..ff042b6 100644 --- a/apps/flutter/lib/src/bridge_api.dart +++ b/apps/flutter/lib/src/bridge_api.dart @@ -459,6 +459,8 @@ class ThreadHistory { this.approvalPolicy, this.sandboxMode, this.configConfirmed = false, + this.hasOlder = false, + this.turns = const [], }); /// Conversation items, oldest first. @@ -507,6 +509,50 @@ class ThreadHistory { /// Whether a live `thread/settings/updated` notification has confirmed this /// config (vs only a start/resume snapshot). final bool configConfirmed; + + /// Whether earlier items remain unread — [BridgeApi.appThreadOlderPage] + /// fetches them. False for a thread whose history arrives whole. + final bool hasOlder; + + /// One entry per turn in the WHOLE thread, oldest first, including turns + /// whose items aren't loaded yet. The turn rail shows a conversation's shape, + /// so it needs every turn even before their bodies are read. + final List turns; +} + +/// A turn reduced to what the rail shows: the question, and how it was answered. +class TurnSummary { + /// Creates a turn summary. + const TurnSummary({ + required this.turnId, + this.userText = '', + this.assistantText = '', + this.loaded = false, + }); + + /// Id of the turn, for fetching its items on demand. + final String turnId; + + /// The user's message that opened the turn; empty when it had none. + final String userText; + + /// The turn's final agent message; empty when it produced no prose. + final String assistantText; + + /// Whether this turn's items are already in the transcript. + final bool loaded; +} + +/// One page of older items, and whether history continues before them. +class OlderPage { + /// Creates an older page. + const OlderPage({required this.items, required this.hasOlder}); + + /// Older items, oldest first, to prepend to the transcript. + final List items; + + /// Whether older items still remain. + final bool hasOlder; } /// The server-reported runtime configuration of a thread — what its turns @@ -1222,8 +1268,23 @@ abstract interface class BridgeApi { /// Read a thread's history (items oldest first) and whether a turn is still /// running, so re-opening an in-flight thread restores its live state. + /// + /// A paginated thread returns only its newest turns' items, with + /// [ThreadHistory.hasOlder] set and [ThreadHistory.turns] naming every turn. Future appThreadRead(String serviceKey, String threadId); + /// One page further back through a paginated thread's history. Returns an + /// empty page when the thread reads whole or is already at its start. + Future appThreadOlderPage(String serviceKey, String threadId); + + /// Every item of one turn, oldest first — for jumping to a turn the + /// transcript hasn't scrolled back to yet. + Future> appThreadTurnItems( + String serviceKey, + String threadId, + String turnId, + ); + /// The latest server-reported runtime config for a thread (from its /// start/resume response, kept fresh by `thread/settings/updated` /// notifications), or null when the server hasn't reported any. Reads the diff --git a/apps/flutter/lib/src/bridge_api_rust.dart b/apps/flutter/lib/src/bridge_api_rust.dart index 258a087..50ab643 100644 --- a/apps/flutter/lib/src/bridge_api_rust.dart +++ b/apps/flutter/lib/src/bridge_api_rust.dart @@ -432,20 +432,7 @@ class RustBridgeApi implements BridgeApi { threadId: threadId, ); return ThreadHistory( - items: h.items - .map( - (i) => ThreadItem( - id: i.id, - itemType: i.itemType, - title: i.title, - text: i.text, - images: i.images, - turnId: i.turnId, - turnCompletedAt: i.turnCompletedAt?.toInt(), - turnDurationMs: i.turnDurationMs?.toInt(), - ), - ) - .toList(), + items: h.items.map(_item).toList(), running: h.running, branch: h.branch, cwd: h.cwd, @@ -458,7 +445,58 @@ class RustBridgeApi implements BridgeApi { approvalPolicy: h.approvalPolicy, sandboxMode: h.sandboxMode, configConfirmed: h.configConfirmed, + hasOlder: h.hasOlder, + turns: h.turns + .map( + (t) => TurnSummary( + turnId: t.turnId, + userText: t.userText, + assistantText: t.assistantText, + loaded: t.loaded, + ), + ) + .toList(), + ); + } + + static ThreadItem _item(frb.ThreadItemDto i) => ThreadItem( + id: i.id, + itemType: i.itemType, + title: i.title, + text: i.text, + images: i.images, + turnId: i.turnId, + turnCompletedAt: i.turnCompletedAt?.toInt(), + turnDurationMs: i.turnDurationMs?.toInt(), + ); + + @override + Future appThreadOlderPage( + String serviceKey, + String threadId, + ) async { + final page = await frb.appThreadOlderPage( + serviceKey: serviceKey, + threadId: threadId, + ); + return OlderPage( + items: page.items.map(_item).toList(), + hasOlder: page.hasOlder, + ); + } + + @override + Future> appThreadTurnItems( + String serviceKey, + String threadId, + String turnId, + ) async { + final items = await frb.appThreadTurnItems( + serviceKey: serviceKey, + threadId: threadId, + turnId: turnId, ); + return items.map(_item).toList(); } @override diff --git a/apps/flutter/lib/src/providers.dart b/apps/flutter/lib/src/providers.dart index a2ab013..337caac 100644 --- a/apps/flutter/lib/src/providers.dart +++ b/apps/flutter/lib/src/providers.dart @@ -179,6 +179,41 @@ final pendingRemovalProvider = StateProvider>((ref) => {}); /// /// A failure yields null rather than an error state: a missing gist is a row /// with one less line, not something to interrupt the list for. +/// Caps how many summary fetches run at once. +/// +/// Each bridge call occupies one worker thread of a pool only as wide as the CPU +/// count, and it BLOCKS that thread for the whole round trip. A sidebar with +/// dozens of rows asks for every gist concurrently, which saturated the pool and +/// left nothing for the call that actually matters — opening a conversation — +/// until the summaries drained. Gists are decoration; they queue. +final _summaryGate = _Gate(3); + +/// A counting semaphore: [acquire] resolves once fewer than [limit] holders are +/// active, and the returned callback releases the slot. +class _Gate { + _Gate(this.limit); + + final int limit; + int _active = 0; + final _waiting = >[]; + + Future acquire() async { + if (_active >= limit) { + final wait = Completer(); + _waiting.add(wait); + await wait.future; + } + _active++; + var released = false; + return () { + if (released) return; + released = true; + _active--; + if (_waiting.isNotEmpty) _waiting.removeAt(0).complete(); + }; + } +} + final threadSummaryProvider = FutureProvider.family(( ref, key, @@ -187,12 +222,15 @@ final threadSummaryProvider = FutureProvider.family(( if (sep <= 0) return null; final serviceKey = key.substring(0, sep); final threadId = key.substring(sep + 1); + final release = await _summaryGate.acquire(); try { return await ref .watch(bridgeApiProvider) .appThreadSummary(serviceKey, threadId); } catch (_) { return null; + } finally { + release(); } }); diff --git a/apps/flutter/lib/src/rust/api/bridge.dart b/apps/flutter/lib/src/rust/api/bridge.dart index 875ec24..3fcb0bf 100644 --- a/apps/flutter/lib/src/rust/api/bridge.dart +++ b/apps/flutter/lib/src/rust/api/bridge.dart @@ -6,7 +6,7 @@ import '../frb_generated.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `apply_key`, `current_relay`, `holder_dto`, `meta_follow_update_dto`, `meta_holder_dto`, `meta_liveness_dto`, `meta_thread_item_dto`, `project_config_dto`, `thread_config_dto`, `thread_config_from_dto`, `to_log_dto` +// These functions are ignored because they are not marked as `pub`: `holder_dto`, `item_dto`, `meta_follow_update_dto`, `meta_holder_dto`, `meta_liveness_dto`, `meta_thread_item_dto`, `project_config_dto`, `thread_config_dto`, `thread_config_from_dto`, `to_log_dto` /// Initialise the engine with the platform app-support dir (from Dart's /// path_provider). Must be called once after `RustLib.init()`. @@ -101,8 +101,13 @@ Future importConfig({required String text}) => Future exportConfig() => RustLib.instance.api.crateApiBridgeExportConfig(); -/// Discover services: in account mode from the backend (`/v1/services`), in -/// self-host mode from the relay (applying the stored key first). +/// Discover the services this device can reach on the relay. +/// +/// One query in both modes: an account credential sees only its own namespace, +/// so the relay's listing IS the account's inventory. Reported with BARE `pcx:` +/// keys whatever the mode, because that is the identity the app and its Dart +/// layer use — [`Transport::relay_key`] maps back when the relay is next +/// addressed. Future> discoverServices() => RustLib.instance.api.crateApiBridgeDiscoverServices(); @@ -339,6 +344,9 @@ Future appThreadResume({ /// Read a thread's conversation items (oldest first) and whether a turn is /// still running, so re-opening an in-flight thread restores live state. +/// +/// A paginated thread returns only its newest turns' items — walk further back +/// with [`app_thread_older_page`] — plus a summary of every turn in `turns`. Future appThreadRead({ required String serviceKey, required String threadId, @@ -347,6 +355,29 @@ Future appThreadRead({ threadId: threadId, ); +/// One page further back through a paginated thread's history. +/// +/// Returns an empty page when the thread reads whole or is already at its start. +Future appThreadOlderPage({ + required String serviceKey, + required String threadId, +}) => RustLib.instance.api.crateApiBridgeAppThreadOlderPage( + serviceKey: serviceKey, + threadId: threadId, +); + +/// Every item of one turn, oldest first — for jumping to a turn the transcript +/// hasn't scrolled back to yet. +Future> appThreadTurnItems({ + required String serviceKey, + required String threadId, + required String turnId, +}) => RustLib.instance.api.crateApiBridgeAppThreadTurnItems( + serviceKey: serviceKey, + threadId: threadId, + turnId: turnId, +); + /// The latest server-reported runtime config for a thread (from its /// start/resume response, kept fresh by live `thread/settings/updated` /// notifications), or `None` when the server hasn't reported any. Reads the @@ -506,8 +537,8 @@ Future appForceResume({ /// Remote analogue of [`app_local_sessions`]: list the sessions of the host /// behind `service_key` via its meta tunnel (loopback when this app is the -/// host, broker when remote). Lets a phone see a desktop host's sessions — -/// including those owned by another codex client. +/// host, a relay subscription when remote). Lets a phone see a desktop host's +/// sessions — including those owned by another codex client. Future> metaSessions({required String serviceKey}) => RustLib.instance.api.crateApiBridgeMetaSessions(serviceKey: serviceKey); @@ -1230,8 +1261,8 @@ class ConfigView { /// Signed-in GitHub login (account mode), if any. final String? accountLogin; - /// Signed-in GitHub numeric account id, if any. The UI builds the avatar URL - /// from it; there is no avatar field to fetch. + /// Signed-in GitHub numeric account id, if any. The UI builds the avatar + /// URL from it; there is no avatar field to fetch. final String? accountId; /// Whether an account session token is stored (value withheld). @@ -1622,6 +1653,28 @@ class ModelInfoDto { defaultReasoningEffort == other.defaultReasoningEffort; } +/// One page of older items, and whether history continues before them. +class OlderPageDto { + /// Older items, oldest first, to prepend to the transcript. + final List items; + + /// Whether older items still remain. + final bool hasOlder; + + const OlderPageDto({required this.items, required this.hasOlder}); + + @override + int get hashCode => items.hashCode ^ hasOlder.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OlderPageDto && + runtimeType == other.runtimeType && + items == other.items && + hasOlder == other.hasOlder; +} + /// The host's project-folder config (mirrored for Dart): the roots a remote /// folder browser is confined to, and the default project new sessions open in. class ProjectConfigDto { @@ -1906,6 +1959,15 @@ class ThreadHistoryDto { /// this config (vs only a start/resume snapshot). final bool configConfirmed; + /// Whether earlier items remain unread — [`app_thread_older_page`] fetches + /// them. False for a thread whose history arrives whole. + final bool hasOlder; + + /// One entry per turn in the WHOLE thread, oldest first, including turns + /// whose items aren't loaded yet. The turn rail shows a conversation's + /// shape, so it needs every turn even before their bodies are read. + final List turns; + const ThreadHistoryDto({ required this.items, required this.running, @@ -1920,6 +1982,8 @@ class ThreadHistoryDto { this.approvalPolicy, this.sandboxMode, required this.configConfirmed, + required this.hasOlder, + required this.turns, }); @override @@ -1936,7 +2000,9 @@ class ThreadHistoryDto { modelProvider.hashCode ^ approvalPolicy.hashCode ^ sandboxMode.hashCode ^ - configConfirmed.hashCode; + configConfirmed.hashCode ^ + hasOlder.hashCode ^ + turns.hashCode; @override bool operator ==(Object other) => @@ -1955,7 +2021,9 @@ class ThreadHistoryDto { modelProvider == other.modelProvider && approvalPolicy == other.approvalPolicy && sandboxMode == other.sandboxMode && - configConfirmed == other.configConfirmed; + configConfirmed == other.configConfirmed && + hasOlder == other.hasOlder && + turns == other.turns; } /// One materialised conversation item mirrored for Dart. @@ -2133,6 +2201,45 @@ class ThreadRuntimeConfigDto { confirmedByUpdate == other.confirmedByUpdate; } +/// A turn reduced to what the rail shows. +class TurnSummaryDto { + /// Id of the turn, for fetching its items on demand. + final String turnId; + + /// The user's message that opened the turn; empty when it had none. + final String userText; + + /// The turn's final agent message; empty when it produced no prose. + final String assistantText; + + /// Whether this turn's items are already in the transcript. + final bool loaded; + + const TurnSummaryDto({ + required this.turnId, + required this.userText, + required this.assistantText, + required this.loaded, + }); + + @override + int get hashCode => + turnId.hashCode ^ + userText.hashCode ^ + assistantText.hashCode ^ + loaded.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TurnSummaryDto && + runtimeType == other.runtimeType && + turnId == other.turnId && + userText == other.userText && + assistantText == other.assistantText && + loaded == other.loaded; +} + /// A started web (authorization-code) login, mirrored for Dart. The caller /// opens [`Self::authorize_url`] in a browser, captures the redirect to its /// `redirect_uri`, checks the redirect's `state` equals [`Self::state`], then diff --git a/apps/flutter/lib/src/rust/frb_generated.dart b/apps/flutter/lib/src/rust/frb_generated.dart index 1e72125..5402807 100644 --- a/apps/flutter/lib/src/rust/frb_generated.dart +++ b/apps/flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -1359732961; + int get rustContentHash => -216609835; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -214,6 +214,11 @@ abstract class RustLibApi extends BaseApi { required String serviceKey, }); + Future crateApiBridgeAppThreadOlderPage({ + required String serviceKey, + required String threadId, + }); + Future crateApiBridgeAppThreadRead({ required String serviceKey, required String threadId, @@ -242,6 +247,12 @@ abstract class RustLibApi extends BaseApi { required String threadId, }); + Future> crateApiBridgeAppThreadTurnItems({ + required String serviceKey, + required String threadId, + required String turnId, + }); + Future crateApiBridgeAppTurnInterrupt({ required String serviceKey, required String threadId, @@ -1600,7 +1611,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - Future crateApiBridgeAppThreadRead({ + Future crateApiBridgeAppThreadOlderPage({ required String serviceKey, required String threadId, }) { @@ -1617,6 +1628,41 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { port: port_, ); }, + codec: SseCodec( + decodeSuccessData: sse_decode_older_page_dto, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiBridgeAppThreadOlderPageConstMeta, + argValues: [serviceKey, threadId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiBridgeAppThreadOlderPageConstMeta => + const TaskConstMeta( + debugName: "app_thread_older_page", + argNames: ["serviceKey", "threadId"], + ); + + @override + Future crateApiBridgeAppThreadRead({ + required String serviceKey, + required String threadId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(serviceKey, serializer); + sse_encode_String(threadId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 39, + port: port_, + ); + }, codec: SseCodec( decodeSuccessData: sse_decode_thread_history_dto, decodeErrorData: sse_decode_AnyhowException, @@ -1648,7 +1694,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 39, + funcId: 40, port: port_, ); }, @@ -1680,7 +1726,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(serviceKey, serializer); sse_encode_String(threadId, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 40)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 41)!; }, codec: SseCodec( decodeSuccessData: @@ -1720,7 +1766,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 41, + funcId: 42, port: port_, ); }, @@ -1755,7 +1801,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -1776,6 +1822,43 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["serviceKey", "threadId"], ); + @override + Future> crateApiBridgeAppThreadTurnItems({ + required String serviceKey, + required String threadId, + required String turnId, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(serviceKey, serializer); + sse_encode_String(threadId, serializer); + sse_encode_String(turnId, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 44, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_list_thread_item_dto, + decodeErrorData: sse_decode_AnyhowException, + ), + constMeta: kCrateApiBridgeAppThreadTurnItemsConstMeta, + argValues: [serviceKey, threadId, turnId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiBridgeAppThreadTurnItemsConstMeta => + const TaskConstMeta( + debugName: "app_thread_turn_items", + argNames: ["serviceKey", "threadId", "turnId"], + ); + @override Future crateApiBridgeAppTurnInterrupt({ required String serviceKey, @@ -1792,7 +1875,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 45, port: port_, ); }, @@ -1841,7 +1924,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 46, port: port_, ); }, @@ -1887,7 +1970,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 45)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 47)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -1915,7 +1998,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 48, port: port_, ); }, @@ -1945,7 +2028,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 49, port: port_, ); }, @@ -1977,7 +2060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 50, port: port_, ); }, @@ -2010,7 +2093,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 51, port: port_, ); }, @@ -2041,7 +2124,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 50, + funcId: 52, port: port_, ); }, @@ -2068,7 +2151,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 53, port: port_, ); }, @@ -2096,7 +2179,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 54, port: port_, ); }, @@ -2133,7 +2216,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 55, port: port_, ); }, @@ -2163,7 +2246,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 56, port: port_, ); }, @@ -2190,7 +2273,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 57, port: port_, ); }, @@ -2217,7 +2300,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 58, port: port_, ); }, @@ -2244,7 +2327,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 59, port: port_, ); }, @@ -2271,7 +2354,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 60, port: port_, ); }, @@ -2296,7 +2379,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(name, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 59)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2322,7 +2405,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 62, port: port_, ); }, @@ -2349,7 +2432,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 63, port: port_, ); }, @@ -2377,7 +2460,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 64, port: port_, ); }, @@ -2407,7 +2490,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 63, + funcId: 65, port: port_, ); }, @@ -2441,7 +2524,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 64, + funcId: 66, port: port_, ); }, @@ -2476,7 +2559,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 67, port: port_, ); }, @@ -2510,7 +2593,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 66, + funcId: 68, port: port_, ); }, @@ -2543,7 +2626,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 69, port: port_, ); }, @@ -2578,7 +2661,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 68, + funcId: 70, port: port_, ); }, @@ -2614,7 +2697,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 69, + funcId: 71, port: port_, ); }, @@ -2647,7 +2730,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 70, + funcId: 72, port: port_, ); }, @@ -2687,7 +2770,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 73, port: port_, ); }, @@ -2724,7 +2807,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 74, port: port_, ); }, @@ -2759,7 +2842,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 75, port: port_, ); }, @@ -2792,7 +2875,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 74, + funcId: 76, port: port_, ); }, @@ -2826,7 +2909,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 75, + funcId: 77, port: port_, ); }, @@ -2861,7 +2944,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 76, + funcId: 78, port: port_, ); }, @@ -2898,7 +2981,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 77, + funcId: 79, port: port_, ); }, @@ -2935,7 +3018,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 78, + funcId: 80, port: port_, ); }, @@ -2974,7 +3057,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 79, + funcId: 81, port: port_, ); }, @@ -3005,7 +3088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 80, + funcId: 82, port: port_, ); }, @@ -3033,7 +3116,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 81, + funcId: 83, port: port_, ); }, @@ -3061,7 +3144,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 82, + funcId: 84, port: port_, ); }, @@ -3088,7 +3171,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 83, + funcId: 85, port: port_, ); }, @@ -3517,6 +3600,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (raw as List).map(dco_decode_thread_meta_dto).toList(); } + @protected + List dco_decode_list_turn_summary_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_turn_summary_dto).toList(); + } + @protected LocalSessionDto dco_decode_local_session_dto(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -3566,6 +3655,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + OlderPageDto dco_decode_older_page_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return OlderPageDto( + items: dco_decode_list_thread_item_dto(arr[0]), + hasOlder: dco_decode_bool(arr[1]), + ); + } + @protected String? dco_decode_opt_String(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -3704,8 +3805,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ThreadHistoryDto dco_decode_thread_history_dto(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 13) - throw Exception('unexpected arr length: expect 13 but see ${arr.length}'); + if (arr.length != 15) + throw Exception('unexpected arr length: expect 15 but see ${arr.length}'); return ThreadHistoryDto( items: dco_decode_list_thread_item_dto(arr[0]), running: dco_decode_bool(arr[1]), @@ -3720,6 +3821,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { approvalPolicy: dco_decode_opt_String(arr[10]), sandboxMode: dco_decode_opt_String(arr[11]), configConfirmed: dco_decode_bool(arr[12]), + hasOlder: dco_decode_bool(arr[13]), + turns: dco_decode_list_turn_summary_dto(arr[14]), ); } @@ -3773,6 +3876,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + TurnSummaryDto dco_decode_turn_summary_dto(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + return TurnSummaryDto( + turnId: dco_decode_String(arr[0]), + userText: dco_decode_String(arr[1]), + assistantText: dco_decode_String(arr[2]), + loaded: dco_decode_bool(arr[3]), + ); + } + @protected int dco_decode_u_16(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -4375,6 +4492,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return ans_; } + @protected + List sse_decode_list_turn_summary_dto( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = []; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_turn_summary_dto(deserializer)); + } + return ans_; + } + @protected LocalSessionDto sse_decode_local_session_dto(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -4434,6 +4565,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + OlderPageDto sse_decode_older_page_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_items = sse_decode_list_thread_item_dto(deserializer); + var var_hasOlder = sse_decode_bool(deserializer); + return OlderPageDto(items: var_items, hasOlder: var_hasOlder); + } + @protected String? sse_decode_opt_String(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -4615,6 +4754,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_approvalPolicy = sse_decode_opt_String(deserializer); var var_sandboxMode = sse_decode_opt_String(deserializer); var var_configConfirmed = sse_decode_bool(deserializer); + var var_hasOlder = sse_decode_bool(deserializer); + var var_turns = sse_decode_list_turn_summary_dto(deserializer); return ThreadHistoryDto( items: var_items, running: var_running, @@ -4629,6 +4770,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { approvalPolicy: var_approvalPolicy, sandboxMode: var_sandboxMode, configConfirmed: var_configConfirmed, + hasOlder: var_hasOlder, + turns: var_turns, ); } @@ -4695,6 +4838,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + TurnSummaryDto sse_decode_turn_summary_dto(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_turnId = sse_decode_String(deserializer); + var var_userText = sse_decode_String(deserializer); + var var_assistantText = sse_decode_String(deserializer); + var var_loaded = sse_decode_bool(deserializer); + return TurnSummaryDto( + turnId: var_turnId, + userText: var_userText, + assistantText: var_assistantText, + loaded: var_loaded, + ); + } + @protected int sse_decode_u_16(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -5239,6 +5397,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { } } + @protected + void sse_encode_list_turn_summary_dto( + List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_turn_summary_dto(item, serializer); + } + } + @protected void sse_encode_local_session_dto( LocalSessionDto self, @@ -5276,6 +5446,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(self.defaultReasoningEffort, serializer); } + @protected + void sse_encode_older_page_dto(OlderPageDto self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_thread_item_dto(self.items, serializer); + sse_encode_bool(self.hasOlder, serializer); + } + @protected void sse_encode_opt_String(String? self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -5438,6 +5615,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_String(self.approvalPolicy, serializer); sse_encode_opt_String(self.sandboxMode, serializer); sse_encode_bool(self.configConfirmed, serializer); + sse_encode_bool(self.hasOlder, serializer); + sse_encode_list_turn_summary_dto(self.turns, serializer); } @protected @@ -5484,6 +5663,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bool(self.confirmedByUpdate, serializer); } + @protected + void sse_encode_turn_summary_dto( + TurnSummaryDto self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.turnId, serializer); + sse_encode_String(self.userText, serializer); + sse_encode_String(self.assistantText, serializer); + sse_encode_bool(self.loaded, serializer); + } + @protected void sse_encode_u_16(int self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs diff --git a/apps/flutter/lib/src/rust/frb_generated.io.dart b/apps/flutter/lib/src/rust/frb_generated.io.dart index 2bdc992..bb8d39f 100644 --- a/apps/flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/flutter/lib/src/rust/frb_generated.io.dart @@ -157,6 +157,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_thread_meta_dto(dynamic raw); + @protected + List dco_decode_list_turn_summary_dto(dynamic raw); + @protected LocalSessionDto dco_decode_local_session_dto(dynamic raw); @@ -166,6 +169,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ModelInfoDto dco_decode_model_info_dto(dynamic raw); + @protected + OlderPageDto dco_decode_older_page_dto(dynamic raw); + @protected String? dco_decode_opt_String(dynamic raw); @@ -219,6 +225,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ThreadRuntimeConfigDto dco_decode_thread_runtime_config_dto(dynamic raw); + @protected + TurnSummaryDto dco_decode_turn_summary_dto(dynamic raw); + @protected int dco_decode_u_16(dynamic raw); @@ -411,6 +420,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + List sse_decode_list_turn_summary_dto( + SseDeserializer deserializer, + ); + @protected LocalSessionDto sse_decode_local_session_dto(SseDeserializer deserializer); @@ -420,6 +434,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ModelInfoDto sse_decode_model_info_dto(SseDeserializer deserializer); + @protected + OlderPageDto sse_decode_older_page_dto(SseDeserializer deserializer); + @protected String? sse_decode_opt_String(SseDeserializer deserializer); @@ -481,6 +498,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + TurnSummaryDto sse_decode_turn_summary_dto(SseDeserializer deserializer); + @protected int sse_decode_u_16(SseDeserializer deserializer); @@ -718,6 +738,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_list_turn_summary_dto( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_local_session_dto( LocalSessionDto self, @@ -730,6 +756,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_model_info_dto(ModelInfoDto self, SseSerializer serializer); + @protected + void sse_encode_older_page_dto(OlderPageDto self, SseSerializer serializer); + @protected void sse_encode_opt_String(String? self, SseSerializer serializer); @@ -811,6 +840,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_turn_summary_dto( + TurnSummaryDto self, + SseSerializer serializer, + ); + @protected void sse_encode_u_16(int self, SseSerializer serializer); diff --git a/apps/flutter/lib/src/rust/frb_generated.web.dart b/apps/flutter/lib/src/rust/frb_generated.web.dart index 2383da4..0147fcf 100644 --- a/apps/flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/flutter/lib/src/rust/frb_generated.web.dart @@ -159,6 +159,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected List dco_decode_list_thread_meta_dto(dynamic raw); + @protected + List dco_decode_list_turn_summary_dto(dynamic raw); + @protected LocalSessionDto dco_decode_local_session_dto(dynamic raw); @@ -168,6 +171,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ModelInfoDto dco_decode_model_info_dto(dynamic raw); + @protected + OlderPageDto dco_decode_older_page_dto(dynamic raw); + @protected String? dco_decode_opt_String(dynamic raw); @@ -221,6 +227,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ThreadRuntimeConfigDto dco_decode_thread_runtime_config_dto(dynamic raw); + @protected + TurnSummaryDto dco_decode_turn_summary_dto(dynamic raw); + @protected int dco_decode_u_16(dynamic raw); @@ -413,6 +422,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + List sse_decode_list_turn_summary_dto( + SseDeserializer deserializer, + ); + @protected LocalSessionDto sse_decode_local_session_dto(SseDeserializer deserializer); @@ -422,6 +436,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected ModelInfoDto sse_decode_model_info_dto(SseDeserializer deserializer); + @protected + OlderPageDto sse_decode_older_page_dto(SseDeserializer deserializer); + @protected String? sse_decode_opt_String(SseDeserializer deserializer); @@ -483,6 +500,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + TurnSummaryDto sse_decode_turn_summary_dto(SseDeserializer deserializer); + @protected int sse_decode_u_16(SseDeserializer deserializer); @@ -720,6 +740,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_list_turn_summary_dto( + List self, + SseSerializer serializer, + ); + @protected void sse_encode_local_session_dto( LocalSessionDto self, @@ -732,6 +758,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_model_info_dto(ModelInfoDto self, SseSerializer serializer); + @protected + void sse_encode_older_page_dto(OlderPageDto self, SseSerializer serializer); + @protected void sse_encode_opt_String(String? self, SseSerializer serializer); @@ -813,6 +842,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_turn_summary_dto( + TurnSummaryDto self, + SseSerializer serializer, + ); + @protected void sse_encode_u_16(int self, SseSerializer serializer); diff --git a/apps/flutter/lib/src/screens/app_session_screen.dart b/apps/flutter/lib/src/screens/app_session_screen.dart index bc98a4b..7998ddc 100644 --- a/apps/flutter/lib/src/screens/app_session_screen.dart +++ b/apps/flutter/lib/src/screens/app_session_screen.dart @@ -391,6 +391,20 @@ class _AppSessionState extends ConsumerState bool _takingOver = false; bool _sending = false; bool _atBottom = true; // is the list scrolled to the latest message? + // Every turn of the open thread, oldest first — including turns whose items + // aren't loaded. The rail shows the conversation's shape, so it reads this + // rather than the loaded rows. Empty for a thread that arrived whole, whose + // rows already cover every turn. + List _turnSummaries = const []; + // Whether older items remain on the server for the open thread. + bool _hasOlder = false; + // True while an older page (or a single turn's items) is in flight, so a + // scroll frame can't queue the same fetch twice. + bool _loadingOlder = false; + // True while `_scrollToEnd(force: true)` is re-jumping to the bottom. Those + // jumps fire scroll events from positions that can look like the top of the + // list, which would fetch older history nobody asked for. + bool _settlingToEnd = false; String? _error; VoidCallback? _retry; // action for the error banner's retry button bool _connectionLost = false; @@ -949,6 +963,10 @@ class _AppSessionState extends ConsumerState _editingTitle = false; _items.clear(); _itemIndex.clear(); + // The previous thread's turns and pagination say nothing about this one. + _turnSummaries = const []; + _hasOlder = false; + _loadingOlder = false; _approvals.clear(); _ctx = null; _diff = null; @@ -1063,7 +1081,18 @@ class _AppSessionState extends ConsumerState if (atBottom != _atBottom) setState(() => _atBottom = atBottom); // Publish the visible rows for the turn minimap. Straight onto the notifier // — no setState — so a scroll frame repaints ticks, not the transcript. - if (_listCtl.isAttached) _visibleRows.value = _listCtl.visibleRange; + // Back out the "older history" row so the rail's range is in row indices, + // which is what its ticks are numbered in. + if (_listCtl.isAttached) _visibleRows.value = _visibleRowRange(); + // Reading back past the top of what's loaded fetches the previous page. + // Suppressed while settling to the bottom, whose repeated jumps generate + // scroll frames that would otherwise read as reaching the top on open. + if (!_settlingToEnd && + _hasOlder && + !_loadingOlder && + _scroll.position.pixels <= _scroll.position.minScrollExtent + 200) { + _loadOlder(); + } } /// Read a host-side image so it can render as a thumbnail instead of a @@ -1190,6 +1219,124 @@ class _AppSessionState extends ConsumerState } } + /// Splice [items] into the transcript by turn order, skipping ids already + /// present, and rebuild the id→index map. + /// + /// Older history arrives as a block that belongs before what's shown, and a + /// jumped-to turn lands wherever its turn sits — either way every existing + /// index shifts, so [_itemIndex] is rebuilt rather than patched. + void _spliceTranscriptItems(List items, {required bool atStart}) { + final known = _items.map((i) => i.id).toSet(); + final fresh = []; + for (final item in items) { + if (item.id.isEmpty || known.contains(item.id)) continue; + if (item.itemType == 'userMessage' && isContextFragment(item.text)) { + continue; + } + known.add(item.id); + fresh.add( + TranscriptItem( + id: item.id, + type: item.itemType, + title: item.title, + text: item.text, + images: resolveImageUrls(item.images), + imageUrls: item.images, + turnId: item.turnId, + turnCompletedAt: item.turnCompletedAt, + turnDurationMs: item.turnDurationMs, + ), + ); + } + if (fresh.isEmpty) return; + if (atStart) { + _items.insertAll(0, fresh); + } else { + // Land the block after the last item of the newest earlier turn, so a + // turn fetched out of order still reads in conversation order. + final order = _turnSummaries.map((t) => t.turnId).toList(); + final at = order.indexOf(fresh.first.turnId); + var insertAt = _items.length; + if (at >= 0) { + for (var i = 0; i < _items.length; i++) { + final pos = order.indexOf(_items[i].turnId); + if (pos >= 0 && pos > at) { + insertAt = i; + break; + } + } + } + _items.insertAll(insertAt, fresh); + } + _itemIndex.clear(); + for (var i = 0; i < _items.length; i++) { + _itemIndex[_items[i].id] = i; + } + } + + /// Fetch the page of history before what's shown, keeping the reading + /// position: the list corrects its own offset when content is prepended. + Future _loadOlder() async { + if (_loadingOlder || !_hasOlder || _threadId == null) return; + final tid = _threadId!; + setState(() => _loadingOlder = true); + try { + final page = await ref + .read(bridgeApiProvider) + .appThreadOlderPage(widget.serviceKey, tid); + if (!mounted || _threadId != tid) return; + setState(() { + _spliceTranscriptItems(page.items, atStart: true); + _hasOlder = page.hasOlder; + _markTurnsLoaded(page.items); + _loadingOlder = false; + }); + } catch (_) { + // Older history is an enhancement — a failure leaves the transcript as + // it is, and scrolling up again retries. + if (mounted) setState(() => _loadingOlder = false); + } + } + + /// Fetch one turn's items, for jumping to a turn not yet scrolled back to. + Future _loadTurn(String turnId) async { + if (_loadingOlder || turnId.isEmpty || _threadId == null) return; + if (_turnSummaries.any((t) => t.turnId == turnId && t.loaded)) return; + final tid = _threadId!; + setState(() => _loadingOlder = true); + try { + final items = await ref + .read(bridgeApiProvider) + .appThreadTurnItems(widget.serviceKey, tid, turnId); + if (!mounted || _threadId != tid) return; + setState(() { + _spliceTranscriptItems(items, atStart: false); + _markTurnsLoaded(items); + _loadingOlder = false; + }); + } catch (_) { + if (mounted) setState(() => _loadingOlder = false); + } + } + + /// Mark every turn these items belong to as loaded, so the rail stops + /// treating it as a turn that still needs fetching. + void _markTurnsLoaded(List items) { + final arrived = items.map((i) => i.turnId).toSet(); + if (arrived.isEmpty) return; + _turnSummaries = [ + for (final turn in _turnSummaries) + arrived.contains(turn.turnId) && !turn.loaded + ? TurnSummary( + turnId: turn.turnId, + userText: turn.userText, + assistantText: turn.assistantText, + loaded: true, + ) + : turn, + ]; + } + /// Open an existing thread: resume it into the session (so reads and turns /// resolve — otherwise the server returns "thread not found"), then load /// its history. @@ -1235,6 +1382,9 @@ class _AppSessionState extends ConsumerState setState(() { _loading = false; _replaceTranscriptItems(history.items); + _turnSummaries = history.turns; + _hasOlder = history.hasOlder; + _loadingOlder = false; // Restore the "thinking" state if a turn was still running when we // left: live events (delivered after resume) will finish rendering it. _streaming = history.running; @@ -2712,15 +2862,27 @@ class _AppSessionState extends ConsumerState // maxScrollExtent keeps growing after the first jump. Re-jump to the bottom // each frame until it settles — otherwise a long conversation opens blank // / mid-content until the user scrolls manually. + // Each of those jumps notifies the scroll listener from a position that can + // read as the top of a short list, which would fetch older history the user + // never asked for. Hold the flag until the jumps are done. + _settlingToEnd = true; void settle(int tries) { - if (!_scroll.hasClients) return; + if (!_scroll.hasClients) { + _settlingToEnd = false; + return; + } final before = _scroll.position.maxScrollExtent; _scroll.jumpTo(before); - if (tries <= 0) return; + if (tries <= 0) { + _settlingToEnd = false; + return; + } WidgetsBinding.instance.addPostFrameCallback((_) { if (_scroll.hasClients && _scroll.position.maxScrollExtent > before + 1) { settle(tries - 1); + } else { + _settlingToEnd = false; } }); } @@ -2768,10 +2930,112 @@ class _AppSessionState extends ConsumerState /// One minimap entry per turn: the user's message, and how the turn answered. /// - /// Derived from the collapsed row list rather than `_items`, because the - /// minimap jumps by row index and the two differ — a run of agent prose or a - /// batch of tool calls is several items but one row. + /// When the server enumerated the thread's turns, every one of them gets a + /// tick — including turns whose items aren't loaded, which the rail is meant + /// to show because it represents the whole conversation's shape. Loaded turns + /// resolve to their real row so selecting one scrolls; the rest carry a + /// `rowIndex` of -1 and are fetched on selection. List _turnMinimapItems(List rows) { + if (_turnSummaries.isEmpty) return _turnMinimapItemsFromRows(rows); + // Where each loaded turn's user message ended up among the rows. + final rowOfTurn = {}; + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + if (row is TranscriptItem && row.isUser) { + rowOfTurn.putIfAbsent(row.turnId, () => i); + } + } + final out = []; + for (final turn in _turnSummaries) { + final row = rowOfTurn[turn.turnId]; + final user = _collapseWhitespace( + previewWithoutFileRefs(turn.userText, ''), + ); + // A loaded turn's reply comes from the rows, which carry the whole turn; + // the skeleton's own summary answers for turns not scrolled back to. + final reply = row != null + ? _finalReplyAfter(rows, row) + : (_collapseWhitespace(turn.assistantText).isEmpty + ? null + : _collapseWhitespace(turn.assistantText)); + out.add( + TurnMinimapItem( + rowIndex: row ?? -1, + turnId: turn.turnId, + userText: user, + assistantText: user.isEmpty && reply == null ? null : reply, + ), + ); + } + return out; + } + + /// The visible range in ROW indices, with the leading "older history" row + /// backed out — the rail numbers its ticks by row, not by list position. + (int, int)? _visibleRowRange() { + final range = _listCtl.visibleRange; + if (range == null) return null; + if (!_hasOlder) return range; + final (first, last) = range; + return ((first - 1).clamp(0, 1 << 30), (last - 1).clamp(0, 1 << 30)); + } + + /// A row saying history continues above, which doubles as the loading state + /// while the previous page is in flight. + /// + /// Tappable as well as scroll-triggered: when the loaded page is shorter than + /// the viewport there is nothing to scroll, so reaching the top by scrolling + /// is impossible and this row is the only way back. + Widget _olderHistoryHeader(AppLocalizations l10n) => Padding( + key: const Key('chat-older-history'), + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: _loadingOlder + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : InkWell( + key: const Key('chat-older-history-load'), + onTap: _loadOlder, + mouseCursor: clickable, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + child: Text( + l10n.olderHistoryHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + + /// Jump to a turn the rail selected, fetching it first when the transcript + /// hasn't loaded it yet. + Future _selectTurn(TurnMinimapItem item) async { + if (item.rowIndex >= 0) { + _scrollToRow(item.rowIndex); + return; + } + await _loadTurn(item.turnId); + if (!mounted) return; + // Its row exists now that its items are in; re-derive to find where. + final row = _turnMinimapItems( + _rows, + ).where((entry) => entry.turnId == item.turnId).firstOrNull; + if (row != null && row.rowIndex >= 0) _scrollToRow(row.rowIndex); + } + + /// Rail entries derived from the loaded rows alone — the shape for a thread + /// whose history arrived whole, so the rows already cover every turn. + List _turnMinimapItemsFromRows(List rows) { final out = []; for (var i = 0; i < rows.length; i++) { final row = rows[i]; @@ -2832,9 +3096,15 @@ class _AppSessionState extends ConsumerState double _gutterWidth(double available) => math.max(0, (available - _kColumnWidth) / 2); - /// How many turns the rail would have ticks for — one per user message, the - /// same count [_turnMinimapItems] produces. - int get _turnCount => _items.where((i) => i.isUser).length; + /// How many turns the rail would have ticks for. + /// + /// Counts the whole thread's turns when the server enumerated them, so a long + /// conversation gets its rail immediately instead of only after enough of it + /// has been scrolled back into memory. Falls back to the loaded user messages + /// for a thread that arrived whole. + int get _turnCount => _turnSummaries.isNotEmpty + ? _turnSummaries.length + : _items.where((i) => i.isUser).length; /// Whether the gutter rail can take turn navigation over at [available] width, /// so the corner arrows can stand down rather than offer the same thing twice. @@ -2867,7 +3137,12 @@ class _AppSessionState extends ConsumerState items: _turnMinimapItems(_rows), visibleRange: _visibleRows, gutterWidth: _gutterWidth(width), - onSelect: (item) => _scrollToRow(item.rowIndex), + onSelect: _selectTurn, + // Hovering a turn the transcript hasn't loaded starts fetching + // it, so the jump lands on content rather than a wait. + onPreview: (item) { + if (item.rowIndex < 0) _loadTurn(item.turnId); + }, ), ), // Where the rail has taken the turn jumps, jump-to-latest is all that @@ -2901,7 +3176,9 @@ class _AppSessionState extends ConsumerState void _scrollToRow(int index) { if (!_listCtl.isAttached || !_scroll.hasClients) return; _listCtl.animateToItem( - index: index, + // Callers speak in row indices; the list puts the "older history" row + // ahead of them, so translate once here rather than at each call site. + index: index + (_hasOlder ? 1 : 0), scrollController: _scroll, alignment: 0, duration: (est) => Duration(milliseconds: est.abs() > 2400 ? 420 : 260), @@ -4145,10 +4422,23 @@ class _AppSessionState extends ConsumerState pad, 12, ), + // A leading row when history + // continues above, so a long + // conversation says so instead of + // looking like it starts there. itemCount: rows.length + + (_hasOlder ? 1 : 0) + (_showTyping ? 1 : 0), itemBuilder: (c, i) { + if (_hasOlder) { + if (i == 0) { + return _olderHistoryHeader( + l10n, + ); + } + i -= 1; + } if (i >= rows.length) { return TypingIndicator( key: _externalWriterRunning diff --git a/apps/flutter/lib/src/widgets/turn_minimap.dart b/apps/flutter/lib/src/widgets/turn_minimap.dart index 8511332..29c7f3a 100644 --- a/apps/flutter/lib/src/widgets/turn_minimap.dart +++ b/apps/flutter/lib/src/widgets/turn_minimap.dart @@ -15,12 +15,19 @@ class TurnMinimapItem { required this.rowIndex, required this.userText, this.assistantText, + this.turnId = '', }); /// Index of this turn's user message in the transcript's row list — what the - /// list controller is asked to scroll to. + /// list controller is asked to scroll to. `-1` for a turn the transcript + /// hasn't loaded: the tick still marks where the turn sits in the + /// conversation, but there is no row to scroll to until its items arrive. final int rowIndex; + /// Id of the turn, so selecting a tick whose [rowIndex] is `-1` can fetch it. + /// Empty when the caller derived entries from rows alone. + final String turnId; + /// The user's own message, one line, whitespace already collapsed. final String userText; @@ -116,6 +123,7 @@ class TurnMinimap extends StatefulWidget { required this.visibleRange, required this.gutterWidth, required this.onSelect, + this.onPreview, }); /// The turns, in transcript order. @@ -133,6 +141,12 @@ class TurnMinimap extends StatefulWidget { /// Jump to this turn. final ValueChanged onSelect; + /// Called when the pointer rests on a turn, before any click. Lets the + /// transcript start fetching a turn it hasn't loaded so the jump is instant + /// when the click comes. Optional: the preview itself needs no fetch, since + /// the entry already carries its text. + final ValueChanged? onPreview; + @override State createState() => _TurnMinimapState(); } @@ -338,7 +352,12 @@ class _TurnMinimapState extends State { // re-resolving from an X the rail doesn't govern. if (event.localPosition.dx > hitWidth) return; final next = _indexAt(event.localPosition.dy, railHeight); - if (next != _active) setState(() => _active = next); + if (next != _active) { + setState(() => _active = next); + if (next != null && next < widget.items.length) { + widget.onPreview?.call(widget.items[next]); + } + } }, child: GestureDetector( behavior: HitTestBehavior.translucent, diff --git a/apps/flutter/test/fake_bridge_api.dart b/apps/flutter/test/fake_bridge_api.dart index 6fcc747..9a855d2 100644 --- a/apps/flutter/test/fake_bridge_api.dart +++ b/apps/flutter/test/fake_bridge_api.dart @@ -671,6 +671,43 @@ class FakeBridgeApi implements BridgeApi { String threadId, ) async => readResult; + /// Older pages a paginated thread hands back, oldest batch LAST — each call + /// to [appThreadOlderPage] pops the last one, so seeding + /// `[oldest, middle]` serves `middle` then `oldest`, the order the UI walks. + List> olderPages = []; + + /// Turn ids passed to [appThreadOlderPage], in call order. + int olderPageCalls = 0; + + @override + Future appThreadOlderPage( + String serviceKey, + String threadId, + ) async { + olderPageCalls++; + if (olderPages.isEmpty) { + return const OlderPage(items: [], hasOlder: false); + } + final items = olderPages.removeLast(); + return OlderPage(items: items, hasOlder: olderPages.isNotEmpty); + } + + /// Items each turn hands back, keyed by turn id. + Map> turnItems = {}; + + /// Turn ids passed to [appThreadTurnItems], in call order. + final List turnItemCalls = []; + + @override + Future> appThreadTurnItems( + String serviceKey, + String threadId, + String turnId, + ) async { + turnItemCalls.add(turnId); + return turnItems[turnId] ?? const []; + } + /// Seedable runtime config for the status-bar model indicator tests. ThreadRuntimeConfig? runtimeConfig; diff --git a/apps/flutter/test/screens/app_session_test.dart b/apps/flutter/test/screens/app_session_test.dart index 46819b3..062a0a9 100644 --- a/apps/flutter/test/screens/app_session_test.dart +++ b/apps/flutter/test/screens/app_session_test.dart @@ -26,6 +26,7 @@ import 'package:pocket_codex/src/screens/app_session_screen.dart'; import 'package:pocket_codex/src/ui_prefs.dart'; import 'package:pocket_codex/src/widgets/message_images.dart'; import 'package:pocket_codex/src/widgets/status_dots.dart'; +import 'package:pocket_codex/src/widgets/turn_minimap.dart'; import '../fake_bridge_api.dart'; import '../support/screen_harness.dart'; @@ -5657,4 +5658,200 @@ void main() { }); }); }); + + group('a long thread loads its history a page at a time', () { + // The rail is desktop-only by construction, and the test harness forces + // android. Restored in a finally so the debug-var invariant check passes. + Future onDesktop(Future Function() body) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + await body(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + } + + ThreadItem user(String id, String text, String turn) => ThreadItem( + id: id, + itemType: 'userMessage', + title: '', + text: text, + turnId: turn, + ); + ThreadItem agent(String id, String text, String turn) => ThreadItem( + id: id, + itemType: 'agentMessage', + title: '', + text: text, + turnId: turn, + ); + + /// A thread whose newest turn is loaded and whose earlier turns are not. + Future openPaginated(WidgetTester t) async { + final api = FakeBridgeApi( + config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), + ); + await api.appConnect('pcx:lb7666:app:default', 28080); + api.readResult = ThreadHistory( + items: [ + user('u5', 'newest question', 't5'), + agent('a5', 'newest answer', 't5'), + ], + running: false, + hasOlder: true, + // The server enumerated every turn, including the four not loaded. + // Five of them, so the count clears kTurnMinimapMinItems and the rail + // is the affordance rather than the corner arrows. + turns: const [ + TurnSummary( + turnId: 't1', + userText: 'first question', + assistantText: 'first answer', + ), + TurnSummary( + turnId: 't2', + userText: 'second question', + assistantText: 'second answer', + ), + TurnSummary( + turnId: 't3', + userText: 'third question', + assistantText: 'third answer', + ), + TurnSummary( + turnId: 't4', + userText: 'fourth question', + assistantText: 'fourth answer', + ), + TurnSummary( + turnId: 't5', + userText: 'newest question', + assistantText: 'newest answer', + loaded: true, + ), + ], + ); + await t.pumpWidget( + host( + const AppSessionScreen( + serviceKey: 'pcx:lb7666:app:default', + threadId: 'thread-long', + ), + api, + ), + ); + await t.pumpAndSettle(); + return api; + } + + testWidgets('opening it shows the newest turn, not the whole history', ( + t, + ) async { + await openPaginated(t); + expect(find.text('newest answer'), findsOneWidget); + expect(find.text('first answer'), findsNothing); + // And it says so, rather than looking like the conversation starts here. + expect(find.byKey(const Key('chat-older-history')), findsOneWidget); + }); + + testWidgets('the rail has a tick per turn of the WHOLE thread', (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await openPaginated(t); + // Three turns exist; only one is loaded. A rail derived from loaded rows + // would show one tick and misreport the conversation's shape. + final rail = t.widget(find.byType(TurnMinimap)); + expect(rail.items, hasLength(5)); + expect(rail.items.map((i) => i.turnId), ['t1', 't2', 't3', 't4', 't5']); + // Only the loaded turn resolves to a row; the rest await their items. + expect(rail.items[0].rowIndex, -1); + expect(rail.items[4].rowIndex, greaterThanOrEqualTo(0)); + }); + }); + + testWidgets('an unloaded tick still previews, without fetching', (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t); + final rail = t.widget(find.byType(TurnMinimap)); + // The skeleton carries the text, so hovering costs no round trip. + expect(rail.items[0].userText, 'first question'); + expect(rail.items[0].assistantText, 'first answer'); + expect(api.turnItemCalls, isEmpty); + }); + }); + + testWidgets('selecting an unloaded turn fetches just that turn', (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t); + api.turnItems = { + 't1': [ + user('u1', 'first question', 't1'), + agent('a1', 'first answer', 't1'), + ], + }; + final rail = t.widget(find.byType(TurnMinimap)); + rail.onSelect(rail.items[0]); + await t.pumpAndSettle(); + expect(api.turnItemCalls, ['t1']); + expect(find.text('first answer'), findsOneWidget); + // The newest turn is still there — a jump adds, it doesn't replace. + expect(find.text('newest answer'), findsOneWidget); + }); + }); + + testWidgets('an older page prepends, keeping what was already shown', ( + t, + ) async { + final api = await openPaginated(t); + api.olderPages = [ + [ + user('u2', 'second question', 't2'), + agent('a2', 'second answer', 't2'), + ], + ]; + // Reaching back past the top. Tapped rather than scrolled because the + // loaded page is shorter than the viewport here, so there is nothing to + // scroll — which is exactly why the row is tappable. + await t.tap(find.byKey(const Key('chat-older-history-load'))); + await t.pumpAndSettle(); + expect(api.olderPageCalls, 1); + expect(find.text('second answer'), findsOneWidget); + expect(find.text('newest answer'), findsOneWidget); + // That was the last page, so the header retires. + expect(find.byKey(const Key('chat-older-history')), findsNothing); + }); + + testWidgets('a thread that arrives whole pages nothing', (t) async { + final api = FakeBridgeApi( + config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), + ); + await api.appConnect('pcx:lb7666:app:default', 28080); + // A legacy thread: every item present, no turn enumeration, no older page. + api.readResult = ThreadHistory( + items: [ + user('u1', 'only question', 't1'), + agent('a1', 'only answer', 't1'), + ], + running: false, + ); + await t.pumpWidget( + host( + const AppSessionScreen( + serviceKey: 'pcx:lb7666:app:default', + threadId: 'thread-legacy', + ), + api, + ), + ); + await t.pumpAndSettle(); + expect(find.text('only answer'), findsOneWidget); + expect(find.byKey(const Key('chat-older-history')), findsNothing); + expect(api.olderPageCalls, 0); + }); + }); } diff --git a/crates/pocket-codex-bridge/src/api/bridge.rs b/crates/pocket-codex-bridge/src/api/bridge.rs index b3550d2..8db27e5 100644 --- a/crates/pocket-codex-bridge/src/api/bridge.rs +++ b/crates/pocket-codex-bridge/src/api/bridge.rs @@ -68,7 +68,11 @@ pub fn init_bridge(support_dir: String) -> Result<()> { // layer becomes the global subscriber (codex's later `try_init` is a no-op, // and its events flow through ours too). logging::init(); - runtime::init(PathBuf::from(support_dir)) + let support_dir = PathBuf::from(support_dir); + // Mirror the captured log to disk (last 6 hours), so a hang can be read + // after the fact instead of only in a viewer that was open at the time. + logging::init_file(&support_dir); + runtime::init(support_dir) } /// Current config view (relay/key presence, locale, and account state). @@ -604,6 +608,33 @@ pub struct ThreadHistoryDto { /// Whether a live `thread/settings/updated` notification has confirmed /// this config (vs only a start/resume snapshot). pub config_confirmed: bool, + /// Whether earlier items remain unread — [`app_thread_older_page`] fetches + /// them. False for a thread whose history arrives whole. + pub has_older: bool, + /// One entry per turn in the WHOLE thread, oldest first, including turns + /// whose items aren't loaded yet. The turn rail shows a conversation's + /// shape, so it needs every turn even before their bodies are read. + pub turns: Vec, +} + +/// A turn reduced to what the rail shows. +pub struct TurnSummaryDto { + /// Id of the turn, for fetching its items on demand. + pub turn_id: String, + /// The user's message that opened the turn; empty when it had none. + pub user_text: String, + /// The turn's final agent message; empty when it produced no prose. + pub assistant_text: String, + /// Whether this turn's items are already in the transcript. + pub loaded: bool, +} + +/// One page of older items, and whether history continues before them. +pub struct OlderPageDto { + /// Older items, oldest first, to prepend to the transcript. + pub items: Vec, + /// Whether older items still remain. + pub has_older: bool, } /// The server-reported runtime configuration of a thread — what its turns @@ -904,25 +935,28 @@ pub fn app_thread_resume(service_key: String, thread_id: String) -> Result<()> { app_session::thread_resume(&service_key, &thread_id) } +fn item_dto(i: app_session::ThreadItem) -> ThreadItemDto { + ThreadItemDto { + id: i.id, + item_type: i.item_type, + title: i.title, + text: i.text, + images: i.images, + turn_id: i.turn_id, + turn_completed_at: i.turn_completed_at, + turn_duration_ms: i.turn_duration_ms, + } +} + /// Read a thread's conversation items (oldest first) and whether a turn is /// still running, so re-opening an in-flight thread restores live state. +/// +/// A paginated thread returns only its newest turns' items — walk further back +/// with [`app_thread_older_page`] — plus a summary of every turn in `turns`. pub fn app_thread_read(service_key: String, thread_id: String) -> Result { let h = app_session::thread_read(&service_key, &thread_id)?; Ok(ThreadHistoryDto { - items: h - .items - .into_iter() - .map(|i| ThreadItemDto { - id: i.id, - item_type: i.item_type, - title: i.title, - text: i.text, - images: i.images, - turn_id: i.turn_id, - turn_completed_at: i.turn_completed_at, - turn_duration_ms: i.turn_duration_ms, - }) - .collect(), + items: h.items.into_iter().map(item_dto).collect(), running: h.running, branch: h.branch, cwd: h.cwd, @@ -935,9 +969,45 @@ pub fn app_thread_read(service_key: String, thread_id: String) -> Result Result { + let page = app_session::thread_older_page(&service_key, &thread_id)?; + Ok(OlderPageDto { + items: page.items.into_iter().map(item_dto).collect(), + has_older: page.has_older, + }) +} + +/// Every item of one turn, oldest first — for jumping to a turn the transcript +/// hasn't scrolled back to yet. +pub fn app_thread_turn_items( + service_key: String, + thread_id: String, + turn_id: String, +) -> Result> { + Ok(app_session::thread_turn_items(&service_key, &thread_id, &turn_id)? + .into_iter() + .map(item_dto) + .collect()) +} + /// The latest server-reported runtime config for a thread (from its /// start/resume response, kept fresh by live `thread/settings/updated` /// notifications), or `None` when the server hasn't reported any. Reads the diff --git a/crates/pocket-codex-bridge/src/engine/app_session.rs b/crates/pocket-codex-bridge/src/engine/app_session.rs index 554e6f2..8b93aa0 100644 --- a/crates/pocket-codex-bridge/src/engine/app_session.rs +++ b/crates/pocket-codex-bridge/src/engine/app_session.rs @@ -8,7 +8,7 @@ //! [`crate::engine::runtime`]; we layer the JSON-RPC client on top of it. use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, Mutex}, time::Duration, }; @@ -152,6 +152,56 @@ struct Session { /// the server's `thread/read` doesn't return the in-progress turn's /// items. transcript: Arc>>>, + /// Where each paginated thread's history reading got to, keyed by + /// `threadId`. Paginated threads reject a whole-history read, so + /// [`thread_read`] loads a bounded window and the UI asks for more; the + /// cursors to continue from live here because they are server-opaque and + /// only meaningful in sequence. + pagination: Arc>>, +} + +/// How far back a paginated thread has been read, and where to continue. +/// +/// Cursors are opaque server tokens. A server that repeats one would spin us +/// forever, so every cursor is remembered and a repeat is treated as the end of +/// the history (see [`advancing_cursor`]). +#[derive(Clone, Debug, Default)] +struct ThreadPagination { + /// Cursor for the next (older) page of items, `None` at the start of the + /// thread. + next_item_cursor: Option, + /// Cursors already followed, so a repeat ends the walk instead of looping. + seen_item_cursors: HashSet, + /// Turn ids whose items have been loaded, oldest first. The UI jumps by + /// turn, so it needs to know which turns it can already show. + loaded_turns: Vec, +} + +/// Bridge calls currently occupying an FRB worker thread. Diagnostic only. +static BRIDGE_BUSY: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// Decrements [`BRIDGE_BUSY`] however its scope ends. +struct BusyGuard; + +impl Drop for BusyGuard { + fn drop(&mut self) { + BRIDGE_BUSY.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// The next cursor to follow, or `None` when the walk is done. +/// +/// Ends the walk when the server repeats a cursor it already gave us — +/// otherwise a buggy or racing server turns pagination into an infinite loop. +fn advancing_cursor( + current: Option<&str>, + next: Option, + seen: &mut HashSet, +) -> Option { + if let Some(current) = current { + seen.insert(current.to_string()); + } + next.filter(|next| !next.is_empty() && seen.insert(next.clone())) } impl Drop for Session { @@ -281,6 +331,7 @@ fn establish(service_key: String, local_addr: &str) -> Result<()> { runtime_config, pending_approvals, transcript, + pagination: Arc::new(Mutex::new(HashMap::new())), }); Ok(()) } @@ -361,6 +412,171 @@ fn buffer_item(transcript: &Mutex>>, inbound: &I } } +/// Replace where a thread's paginated reading has got to. +fn set_pagination(service_key: &str, thread_id: &str, state: ThreadPagination) { + if let Some(session) = sessions() + .lock() + .expect("sessions poisoned") + .get(service_key) + { + session + .pagination + .lock() + .expect("pagination poisoned") + .insert(thread_id.to_string(), state); + } +} + +/// Forget a thread's pagination — it reads whole, so there is nothing to page. +fn reset_pagination(service_key: &str, thread_id: &str) { + if let Some(session) = sessions() + .lock() + .expect("sessions poisoned") + .get(service_key) + { + session + .pagination + .lock() + .expect("pagination poisoned") + .remove(thread_id); + } +} + +/// Where a thread's paginated reading has got to, if it is paginated at all. +fn pagination_of(service_key: &str, thread_id: &str) -> Option { + sessions() + .lock() + .expect("sessions poisoned") + .get(service_key)? + .pagination + .lock() + .expect("pagination poisoned") + .get(thread_id) + .cloned() +} + +/// One page of older items, and whether older ones still remain. +#[derive(Clone, Debug)] +pub struct OlderPage { + /// The older items, oldest first, to prepend to the transcript. + pub items: Vec, + /// Whether history continues before these. + pub has_older: bool, +} + +/// Walk one page further back through a paginated thread's history. +/// +/// Returns an empty page when the thread reads whole or is already at its +/// start, so the caller can treat "nothing older" and "not paginated" alike. +pub fn thread_older_page(service_key: &str, thread_id: &str) -> Result { + let client = client_for(service_key)?; + let empty = || OlderPage { + items: Vec::new(), + has_older: false, + }; + let Some(mut state) = pagination_of(service_key, thread_id) else { + return Ok(empty()); + }; + let Some(cursor) = state.next_item_cursor.clone() else { + return Ok(empty()); + }; + let page = fetch_item_page(&client, thread_id, None, Some(cursor.as_str()), ITEM_PAGE_LIMIT)?; + let entries = page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + // Items arrive newest first within the page; the transcript reads the other + // way, and these are prepended as a block. + let mut items = Vec::new(); + for entry in entries.iter().rev() { + let Some(item) = entry.get("item") else { + continue; + }; + let turn_id = entry + .get("turnId") + .and_then(Value::as_str) + .unwrap_or_default(); + let stamp = TurnStamp { + id: turn_id.to_string(), + completed_at: None, + duration_ms: None, + }; + if let Some(parsed) = parse_turn_item(item, &stamp) { + if !state.loaded_turns.iter().any(|id| id == turn_id) { + state.loaded_turns.insert(0, turn_id.to_string()); + } + items.push(parsed); + } + } + let next = page + .get("nextCursor") + .and_then(Value::as_str) + .map(str::to_string); + state.next_item_cursor = + advancing_cursor(Some(cursor.as_str()), next, &mut state.seen_item_cursors); + let has_older = state.next_item_cursor.is_some(); + set_pagination(service_key, thread_id, state); + Ok(OlderPage { + items, + has_older, + }) +} + +/// Every item of one turn, oldest first — for jumping straight to a turn the +/// transcript hasn't scrolled back to yet. +pub fn thread_turn_items( + service_key: &str, + thread_id: &str, + turn_id: &str, +) -> Result> { + let client = client_for(service_key)?; + let mut newest_first = Vec::new(); + let mut cursor: Option = None; + let mut seen = HashSet::new(); + let stamp = TurnStamp { + id: turn_id.to_string(), + completed_at: None, + duration_ms: None, + }; + // Bounded: a single turn can hold hundreds of items, and draining all of + // them serially is what made opening the longest threads time out. Enough + // pages to fill a screen; scrolling covers the rest. + for _ in 0..MAX_TURN_ITEM_PAGES { + let page = + fetch_item_page(&client, thread_id, Some(turn_id), cursor.as_deref(), ITEM_PAGE_LIMIT)?; + let entries = page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if entries.is_empty() { + break; + } + for entry in &entries { + if let Some(parsed) = entry.get("item").and_then(|i| parse_turn_item(i, &stamp)) { + newest_first.push(parsed); + } + } + let next = page + .get("nextCursor") + .and_then(Value::as_str) + .map(str::to_string); + cursor = advancing_cursor(cursor.as_deref(), next, &mut seen); + if cursor.is_none() { + break; + } + } + newest_first.reverse(); + if let Some(mut state) = pagination_of(service_key, thread_id) { + if !state.loaded_turns.iter().any(|id| id == turn_id) { + state.loaded_turns.push(turn_id.to_string()); + set_pagination(service_key, thread_id, state); + } + } + Ok(newest_first) +} + /// The item snapshots this session has streamed for `thread_id`, in stream /// order. Empty when the service has no live session. fn buffered_items(service_key: &str, thread_id: &str) -> Vec { @@ -1116,28 +1332,159 @@ pub struct ThreadHistory { /// Whether a live `thread/settings/updated` has confirmed this config (vs /// only a start/resume snapshot). pub config_confirmed: bool, + /// Whether earlier items remain unread — [`thread_older_page`] can fetch + /// them. Always false for a legacy thread, whose history arrives whole. + pub has_older: bool, + /// One entry per turn in the WHOLE thread, oldest first, even for turns + /// whose items aren't loaded. The turn rail shows a conversation's shape, + /// so it needs every turn — but only a summary of each, not its items. + pub turns: Vec, } -/// Read a thread's materialised conversation items (oldest first) and whether -/// a turn is currently running. -pub fn thread_read(service_key: &str, thread_id: &str) -> Result { - let client = client_for(service_key)?; +/// A turn reduced to what the rail shows: the question, and how it was +/// answered. +#[derive(Clone, Debug)] +pub struct TurnSummary { + pub turn_id: String, + /// The user's message that opened the turn, empty when it had none. + pub user_text: String, + /// The turn's final agent message, empty when it produced no prose. + pub assistant_text: String, + /// Whether this turn's items are already in `ThreadHistory::items`. + pub loaded: bool, +} + +/// How many of the newest turns get their items loaded when a thread opens. +/// Enough to fill a window; the rest arrive as the user scrolls back. +const INITIAL_TURN_LIMIT: u32 = 5; + +/// Items per page when walking back through history. The server caps a page at +/// 100, so asking for more would just be silently clamped. +const ITEM_PAGE_LIMIT: u32 = 100; + +/// Turns per page when fetching the rail's skeleton. Same server cap as items. +const TURN_PAGE_LIMIT: u32 = 100; + +/// Ceiling on item pages drained for ONE turn. A turn with hundreds of items +/// would otherwise hold the socket for as many serial round trips as it takes. +const MAX_TURN_ITEM_PAGES: usize = 3; + +/// Ceiling on skeleton pages fetched while a thread opens. +/// +/// Every page is a serial round trip on the same socket, so this bounds how +/// long the rail's full length can delay the requests queued behind the open — +/// notably `thread/resume`, which timed out at 60s when this walked far enough. +/// At 100 turns a page, five pages already covers a 500-turn conversation; a +/// longer one gets a rail over its most recent 500 turns rather than a stall. +const MAX_TURN_PAGES: usize = 5; + +/// One thread's loaded history, plus how much of it there is. +struct LoadedHistory { + /// Items to show, oldest first. + items: Vec, + /// The raw turn objects the items came from, newest last. Used for the + /// running/active-turn checks, which only concern the newest turn. + turns: Vec, + /// Every turn in the thread, oldest first. + skeletons: Vec, + has_older: bool, +} + +/// Whether an error is the server saying it doesn't know a method — the signal +/// to fall back to an older API rather than surface a failure. +fn is_unknown_method(err: &anyhow::Error) -> bool { + let text = err.to_string().to_lowercase(); + text.contains("method not found") + || text.contains("unknown method") + || text.contains("unsupported method") +} + +/// Read a thread's entire history in one call (the legacy shape). +fn load_whole_history(client: &Arc, thread_id: &str) -> Result { let res = runtime::runtime().block_on( client.request("thread/read", json!({ "threadId": thread_id, "includeTurns": true })), )?; - let thread = res.get("thread"); - let turns = thread + let turns = res + .get("thread") .and_then(|t| t.get("turns")) .and_then(Value::as_array) .cloned() .unwrap_or_default(); - // Flattened for the UI, but each item keeps its turn's id and timing: the - // nesting IS the server's turn boundary, and dropping it forced the UI to - // re-infer turns from the item sequence and to invent its own timestamps. + let skeletons = turns + .iter() + .map(|turn| summarize_turn(turn, true)) + .collect(); + Ok(LoadedHistory { + items: flatten_turns(&turns), + turns, + skeletons, + // The whole history is here, so there is nothing older to ask for. + has_older: false, + }) +} + +/// Reduce a turn object to its rail summary. +fn summarize_turn(turn: &Value, loaded: bool) -> TurnSummary { + let items = turn + .get("items") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + // A summary view carries the turn's first user message and its final agent + // message; a full view carries everything, so pick those two out of it. + let text_of = |want_user: bool| -> String { + let mut found = String::new(); + for item in items { + let kind = item + .get("type") + .or_else(|| item.get("itemType")) + .and_then(Value::as_str) + .unwrap_or_default(); + let is_user = kind.contains("userMessage"); + let is_agent = kind.contains("agentMessage"); + if want_user && is_user { + // The turn's FIRST user message opens it. + return item_plain_text(item); + } + if !want_user && is_agent { + // The turn's LAST agent message concludes it, so keep looking. + found = item_plain_text(item); + } + } + found + }; + TurnSummary { + turn_id: turn + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + user_text: text_of(true), + assistant_text: text_of(false), + loaded, + } +} + +/// Plain text of a message item, for a rail preview. +fn item_plain_text(item: &Value) -> String { + ["text", "message", "content"] + .iter() + .find_map(|key| item.get(*key).and_then(Value::as_str)) + .unwrap_or_default() + .to_string() +} + +/// Flatten server turns into UI items, oldest first. +/// +/// Each item keeps its turn's id and timing: the nesting IS the server's turn +/// boundary, and dropping it forced the UI to re-infer turns from the item +/// sequence and to invent its own timestamps. +fn flatten_turns(turns: &[Value]) -> Vec { let mut items = Vec::new(); - for turn in &turns { - let turn_items = turn.get("items").and_then(Value::as_array); - let Some(turn_items) = turn_items else { continue }; + for turn in turns { + let Some(turn_items) = turn.get("items").and_then(Value::as_array) else { + continue; + }; let stamp = TurnStamp::of(turn); for item in turn_items { if let Some(parsed) = parse_turn_item(item, &stamp) { @@ -1145,6 +1492,274 @@ pub fn thread_read(service_key: &str, thread_id: &str) -> Result } } } + items +} + +/// Fetch one page of turns. `items_view` decides how much of each turn comes +/// back: `"notLoaded"` for bare metadata, `"summary"` for the opening question +/// and final answer, `"full"` for every item. +fn fetch_turn_page( + client: &Arc, + thread_id: &str, + cursor: Option<&str>, + limit: u32, + items_view: &str, +) -> Result { + let mut params = json!({ + "threadId": thread_id, + "limit": limit, + "sortDirection": "desc", + "itemsView": items_view, + }); + if let Some(cursor) = cursor { + params["cursor"] = json!(cursor); + } + runtime::runtime().block_on(client.request("thread/turns/list", params)) +} + +/// Fetch one page of items, newest first. `turn_id` narrows it to a single +/// turn. +fn fetch_item_page( + client: &Arc, + thread_id: &str, + turn_id: Option<&str>, + cursor: Option<&str>, + limit: u32, +) -> Result { + let mut params = json!({ + "threadId": thread_id, + "limit": limit, + "sortDirection": "desc", + }); + if let Some(turn_id) = turn_id { + params["turnId"] = json!(turn_id); + } + if let Some(cursor) = cursor { + params["cursor"] = json!(cursor); + } + runtime::runtime().block_on(client.request("thread/items/list", params)) +} + +/// Every turn in the thread, oldest first, as rail summaries. +/// +/// Walks `thread/turns/list` backwards with a summary view, which the store +/// answers from indexed columns rather than by replaying items — cheap enough +/// to do for the whole thread so the rail can show its true length immediately. +fn fetch_all_turn_summaries(client: &Arc, thread_id: &str) -> Result> { + let mut newest_first = Vec::new(); + let mut cursor: Option = None; + let mut seen = HashSet::new(); + for _ in 0..MAX_TURN_PAGES { + let page = + fetch_turn_page(client, thread_id, cursor.as_deref(), TURN_PAGE_LIMIT, "summary")?; + let turns = page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if turns.is_empty() { + break; + } + for turn in &turns { + newest_first.push(summarize_turn(turn, false)); + } + let next = page + .get("nextCursor") + .and_then(Value::as_str) + .map(str::to_string); + cursor = advancing_cursor(cursor.as_deref(), next, &mut seen); + if cursor.is_none() { + break; + } + } + newest_first.reverse(); + Ok(newest_first) +} + +/// Load the newest slice of a paginated thread, plus a full turn skeleton. +/// +/// The transcript's content comes from ONE bounded item page, never from +/// `itemsView: "full"`. A full view makes the server walk each returned turn's +/// items in nested loops inside a single JSON-RPC call, so one turn with +/// hundreds of items blows past the request timeout and the socket is judged +/// dead — which read as "connection closed" on exactly the longest threads. +fn load_paginated_window( + client: &Arc, + service_key: &str, + thread_id: &str, +) -> Result { + let phase = std::time::Instant::now(); + // Turn shells for timing and status. `notLoaded` keeps this a single indexed + // query per page regardless of how much the turns contain. + let page = fetch_turn_page(client, thread_id, None, INITIAL_TURN_LIMIT, "notLoaded")?; + tracing::debug!( + target: "pocket_codex_bridge::history", + " turn shells in {:?}", phase.elapsed() + ); + let mut turns = page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + // The wire order is newest first; the transcript reads oldest first. + turns.reverse(); + + // The newest items, bounded. This is what the view opens on. + let phase = std::time::Instant::now(); + let items_page = fetch_item_page(client, thread_id, None, None, ITEM_PAGE_LIMIT)?; + tracing::debug!( + target: "pocket_codex_bridge::history", + " newest items in {:?}", phase.elapsed() + ); + let entries = items_page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + // Timing lives on the turn shells, so items pick their turn's stamp up here + // rather than losing the duration footnote the transcript renders. + let stamps: HashMap = turns + .iter() + .map(|turn| { + let stamp = TurnStamp::of(turn); + (stamp.id.clone(), stamp) + }) + .collect(); + let mut items = Vec::new(); + let mut loaded_turns: Vec = Vec::new(); + // Entries arrive newest first; the transcript reads the other way. + for entry in entries.iter().rev() { + let Some(item) = entry.get("item") else { + continue; + }; + let turn_id = entry + .get("turnId") + .and_then(Value::as_str) + .unwrap_or_default(); + let stamp = stamps.get(turn_id).cloned().unwrap_or(TurnStamp { + id: turn_id.to_string(), + completed_at: None, + duration_ms: None, + }); + if let Some(parsed) = parse_turn_item(item, &stamp) { + if !loaded_turns.iter().any(|id| id == turn_id) { + loaded_turns.push(turn_id.to_string()); + } + items.push(parsed); + } + } + + // Every turn, so the rail is full-length from the start. A thread whose + // skeleton can't be read still opens — the rail just falls back to the + // loaded turns. + let phase = std::time::Instant::now(); + let mut skeletons = fetch_all_turn_summaries(client, thread_id).unwrap_or_else(|_| Vec::new()); + tracing::debug!( + target: "pocket_codex_bridge::history", + " {} turn summaries in {:?}", skeletons.len(), phase.elapsed() + ); + if skeletons.is_empty() { + skeletons = turns + .iter() + .map(|turn| summarize_turn(turn, true)) + .collect(); + } else { + for skeleton in &mut skeletons { + skeleton.loaded = loaded_turns.contains(&skeleton.turn_id); + } + } + let has_older = skeletons.iter().any(|s| !s.loaded); + + // Where older history continues: this page's own continuation cursor. + let item_cursor = items_page + .get("nextCursor") + .and_then(Value::as_str) + .filter(|cursor| !cursor.is_empty()) + .map(str::to_string); + set_pagination(service_key, thread_id, ThreadPagination { + next_item_cursor: item_cursor, + seen_item_cursors: HashSet::new(), + loaded_turns, + }); + + Ok(LoadedHistory { + items, + turns, + skeletons, + has_older, + }) +} + +/// Read a thread's materialised conversation items (oldest first) and whether +/// a turn is currently running. +/// +/// Paginated threads (the default for threads created by current servers) +/// reject a whole-history read, so this loads a bounded tail of the transcript +/// plus a skeleton of every turn, and [`thread_older_page`] walks further back +/// on demand. Legacy threads keep the whole-history read: it is the only shape +/// their rollout supports, and paging methods replay the entire file per call. +pub fn thread_read(service_key: &str, thread_id: &str) -> Result { + // Each call occupies one FRB worker thread for its whole duration (the RPCs + // below block rather than yield), so concurrent reads are capped by the pool + // size. Log entry/exit to make a pile-up visible. + let started = std::time::Instant::now(); + let depth = BRIDGE_BUSY.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let _release = BusyGuard; + tracing::info!( + target: "pocket_codex_bridge::history", + "thread_read START thread={thread_id} in_flight={depth}" + ); + let result = thread_read_inner(service_key, thread_id); + match &result { + Ok(history) => tracing::info!( + target: "pocket_codex_bridge::history", + "thread_read DONE thread={thread_id} in {:?} items={} turns={} has_older={}", + started.elapsed(), + history.items.len(), + history.turns.len(), + history.has_older + ), + Err(err) => tracing::error!( + target: "pocket_codex_bridge::history", + "thread_read FAILED thread={thread_id} after {:?}: {err}", started.elapsed() + ), + } + result +} + +fn thread_read_inner(service_key: &str, thread_id: &str) -> Result { + let client = client_for(service_key)?; + // Metadata only. Asking for turns here would fail outright on a paginated + // thread, and the response carries `historyMode`, which decides the path. + let res = runtime::runtime().block_on( + client.request("thread/read", json!({ "threadId": thread_id, "includeTurns": false })), + )?; + let paginated = res + .get("thread") + .and_then(|t| t.get("historyMode")) + .and_then(Value::as_str) + .is_some_and(|mode| mode == "paginated"); + let loaded = if paginated { + match load_paginated_window(&client, service_key, thread_id) { + Ok(loaded) => loaded, + // A server too old to page can still answer the whole-history read. + Err(err) if is_unknown_method(&err) => { + reset_pagination(service_key, thread_id); + load_whole_history(&client, thread_id)? + }, + Err(err) => return Err(err), + } + } else { + reset_pagination(service_key, thread_id); + load_whole_history(&client, thread_id)? + }; + let LoadedHistory { + mut items, + turns, + skeletons, + has_older, + } = loaded; + let thread = res.get("thread"); // Merge in any items this session streamed that `thread/read` didn't return // — an in-progress turn's thinking/tool items are buffered by the forwarder // but the server omits them here — preserving their stream order so a @@ -1226,6 +1841,8 @@ pub fn thread_read(service_key: &str, thread_id: &str) -> Result approval_policy: runtime.approval_policy, sandbox_mode: runtime.sandbox_mode, config_confirmed: runtime.confirmed_by_update, + has_older, + turns: skeletons, }) } @@ -1239,16 +1856,49 @@ pub fn thread_read(service_key: &str, thread_id: &str) -> Result /// shows and this returns just the sentence rather than shipping a transcript /// across the bridge for the caller to trim. pub fn thread_summary(service_key: &str, thread_id: &str) -> Result> { + // Sidebar rows fetch these concurrently, one FRB worker thread each, and the + // pool is only as wide as the CPU count. Counting occupancy here makes a + // saturated pool — which stalls every other bridge call, `thread_read` + // included — visible in the log instead of looking like a hung server. + let depth = BRIDGE_BUSY.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let _release = BusyGuard; + if depth > 4 { + tracing::warn!( + target: "pocket_codex_bridge::history", + "thread_summary thread={thread_id} with {depth} bridge calls in flight (pool holds {})", + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0) + ); + } let client = client_for(service_key)?; - let res = runtime::runtime().block_on( - client.request("thread/read", json!({ "threadId": thread_id, "includeTurns": true })), - )?; - let turns = res - .get("thread") - .and_then(|t| t.get("turns")) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + // A summary view of the newest turns carries each one's final agent message, + // which is exactly the sentence wanted — no need to read the transcript. + // Several turns, not one, because the newest may be a tool-only turn that + // produced no prose. + let turns = match fetch_turn_page(&client, thread_id, None, INITIAL_TURN_LIMIT, "summary") { + Ok(page) => { + let mut turns = page + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + // The walk below reads oldest first and steps backwards. + turns.reverse(); + turns + }, + // Older servers have no paging methods; their history reads whole. + Err(err) if is_unknown_method(&err) => { + let res = runtime::runtime().block_on( + client + .request("thread/read", json!({ "threadId": thread_id, "includeTurns": true })), + )?; + res.get("thread") + .and_then(|t| t.get("turns")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + }, + Err(err) => return Err(err), + }; // Walk backwards: the newest agent message is the interesting one, and // stopping at the first hit avoids parsing a long history twice over. for turn in turns.iter().rev() { diff --git a/crates/pocket-codex-bridge/src/engine/logging.rs b/crates/pocket-codex-bridge/src/engine/logging.rs index 477c943..647a736 100644 --- a/crates/pocket-codex-bridge/src/engine/logging.rs +++ b/crates/pocket-codex-bridge/src/engine/logging.rs @@ -13,8 +13,11 @@ use std::{ collections::VecDeque, + fs::{self, File, OpenOptions}, + io::Write, + path::Path, sync::Mutex, - time::{SystemTime, UNIX_EPOCH}, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use once_cell::sync::OnceCell; @@ -41,8 +44,14 @@ const RING_CAPACITY: usize = 2000; /// reported to Dart as a gap rather than blocking the logger). const CHANNEL_CAPACITY: usize = 1024; +/// How long a log file is kept before it is pruned at startup. +const FILE_RETENTION: Duration = Duration::from_secs(6 * 60 * 60); + static CHANNEL: OnceCell> = OnceCell::new(); static RING: OnceCell>> = OnceCell::new(); +/// Append handle for the on-disk log, `None` when no directory was set (tests) +/// or the file could not be opened. +static FILE: OnceCell>> = OnceCell::new(); /// Install the capture layer as the global subscriber. Idempotent — safe to /// call once at boot; a second call (or codex's own `try_init`) is a no-op. @@ -112,6 +121,84 @@ fn parse_level(raw: &str) -> &'static str { "INFO" } +/// Start writing captured lines to `/logs/pocket-codex-.log`, and +/// drop files older than [`FILE_RETENTION`]. +/// +/// Separate from [`init`] because the support directory isn't known that early. +/// Failure is silent: the in-memory viewer is the primary sink, and losing the +/// file copy must not stop the app from starting. +pub fn init_file(support_dir: &Path) { + let dir = support_dir.join("logs"); + if fs::create_dir_all(&dir).is_err() { + return; + } + prune_old_logs(&dir); + let path = dir.join(format!("pocket-codex-{}.log", today_stamp())); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .ok(); + let opened = file.is_some(); + FILE.get_or_init(|| Mutex::new(file)); + if opened { + tracing::info!(target: "pocket_codex_bridge::logging", "log file: {}", path.display()); + } +} + +/// Delete log files last modified longer ago than [`FILE_RETENTION`]. +fn prune_old_logs(dir: &Path) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let now = SystemTime::now(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with("pocket-codex-") || !name.ends_with(".log") { + continue; + } + let aged = entry + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age > FILE_RETENTION); + if aged { + let _ = fs::remove_file(entry.path()); + } + } +} + +/// `YYYY-MM-DD` in UTC, for the log file name. +fn today_stamp() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = secs / 86_400; + // Civil-from-days (Howard Hinnant's algorithm), so no date dependency here. + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}") +} + +/// Milliseconds since the unix epoch rendered as `HH:MM:SS.mmm` UTC. +fn clock(timestamp_ms: i64) -> String { + let total_ms = timestamp_ms.rem_euclid(86_400_000); + let ms = total_ms % 1000; + let secs = total_ms / 1000; + format!("{:02}:{:02}:{:02}.{ms:03}", secs / 3600, (secs % 3600) / 60, secs % 60) +} + fn emit(line: LogLine) { if let Some(ring) = RING.get() { let mut r = ring.lock().unwrap_or_else(|e| e.into_inner()); @@ -120,6 +207,19 @@ fn emit(line: LogLine) { } r.push_back(line.clone()); } + if let Some(file) = FILE.get() { + let mut guard = file.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(file) = guard.as_mut() { + let _ = writeln!( + file, + "{} {:5} {} {}", + clock(line.timestamp_ms), + line.level, + line.target, + line.message + ); + } + } if let Some(tx) = CHANNEL.get() { // Err just means no viewers are open — the ring already retained it. let _ = tx.send(line); diff --git a/crates/pocket-codex-bridge/src/frb_generated.rs b/crates/pocket-codex-bridge/src/frb_generated.rs index ab345bc..3f75fa8 100644 --- a/crates/pocket-codex-bridge/src/frb_generated.rs +++ b/crates/pocket-codex-bridge/src/frb_generated.rs @@ -42,7 +42,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1359732961; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -216609835; // Section: executor @@ -1394,6 +1394,45 @@ fn wire__crate__api__bridge__app_thread_list_impl( }, ) } +fn wire__crate__api__bridge__app_thread_older_page_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "app_thread_older_page", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_service_key = ::sse_decode(&mut deserializer); + let api_thread_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || { + let output_ok = crate::api::bridge::app_thread_older_page( + api_service_key, + api_thread_id, + )?; + Ok(output_ok) + })(), + ) + } + }, + ) +} fn wire__crate__api__bridge__app_thread_read_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1583,6 +1622,47 @@ fn wire__crate__api__bridge__app_thread_summary_impl( }, ) } +fn wire__crate__api__bridge__app_thread_turn_items_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "app_thread_turn_items", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_service_key = ::sse_decode(&mut deserializer); + let api_thread_id = ::sse_decode(&mut deserializer); + let api_turn_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( + (move || { + let output_ok = crate::api::bridge::app_thread_turn_items( + api_service_key, + api_thread_id, + api_turn_id, + )?; + Ok(output_ok) + })(), + ) + } + }, + ) +} fn wire__crate__api__bridge__app_turn_interrupt_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -3617,6 +3697,18 @@ impl SseDecode for Vec { } } +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = Vec::with_capacity(len_ as usize); + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + impl SseDecode for crate::api::bridge::LocalSessionDto { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -3679,6 +3771,18 @@ impl SseDecode for crate::api::bridge::ModelInfoDto { } } +impl SseDecode for crate::api::bridge::OlderPageDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_items = >::sse_decode(deserializer); + let mut var_hasOlder = ::sse_decode(deserializer); + return crate::api::bridge::OlderPageDto { + items: var_items, + has_older: var_hasOlder, + }; + } +} + impl SseDecode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -3865,6 +3969,8 @@ impl SseDecode for crate::api::bridge::ThreadHistoryDto { let mut var_approvalPolicy = >::sse_decode(deserializer); let mut var_sandboxMode = >::sse_decode(deserializer); let mut var_configConfirmed = ::sse_decode(deserializer); + let mut var_hasOlder = ::sse_decode(deserializer); + let mut var_turns = >::sse_decode(deserializer); return crate::api::bridge::ThreadHistoryDto { items: var_items, running: var_running, @@ -3879,6 +3985,8 @@ impl SseDecode for crate::api::bridge::ThreadHistoryDto { approval_policy: var_approvalPolicy, sandbox_mode: var_sandboxMode, config_confirmed: var_configConfirmed, + has_older: var_hasOlder, + turns: var_turns, }; } } @@ -3947,6 +4055,22 @@ impl SseDecode for crate::api::bridge::ThreadRuntimeConfigDto { } } +impl SseDecode for crate::api::bridge::TurnSummaryDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_turnId = ::sse_decode(deserializer); + let mut var_userText = ::sse_decode(deserializer); + let mut var_assistantText = ::sse_decode(deserializer); + let mut var_loaded = ::sse_decode(deserializer); + return crate::api::bridge::TurnSummaryDto { + turn_id: var_turnId, + user_text: var_userText, + assistant_text: var_assistantText, + loaded: var_loaded, + }; + } +} + impl SseDecode for u16 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4078,83 +4202,89 @@ fn pde_ffi_dispatcher_primary_impl( }, 36 => wire__crate__api__bridge__app_set_thread_name_impl(port, ptr, rust_vec_len, data_len), 37 => wire__crate__api__bridge__app_thread_list_impl(port, ptr, rust_vec_len, data_len), - 38 => wire__crate__api__bridge__app_thread_read_impl(port, ptr, rust_vec_len, data_len), - 39 => wire__crate__api__bridge__app_thread_resume_impl(port, ptr, rust_vec_len, data_len), - 41 => wire__crate__api__bridge__app_thread_start_impl(port, ptr, rust_vec_len, data_len), - 42 => wire__crate__api__bridge__app_thread_summary_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__bridge__app_turn_interrupt_impl(port, ptr, rust_vec_len, data_len), - 44 => wire__crate__api__bridge__app_turn_start_impl(port, ptr, rust_vec_len, data_len), - 46 => wire__crate__api__bridge__codex_auth_status_impl(port, ptr, rust_vec_len, data_len), - 47 => wire__crate__api__bridge__codex_locate_impl(port, ptr, rust_vec_len, data_len), - 48 => wire__crate__api__bridge__codex_login_cancel_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__bridge__codex_login_chatgpt_start_impl( + 38 => { + wire__crate__api__bridge__app_thread_older_page_impl(port, ptr, rust_vec_len, data_len) + }, + 39 => wire__crate__api__bridge__app_thread_read_impl(port, ptr, rust_vec_len, data_len), + 40 => wire__crate__api__bridge__app_thread_resume_impl(port, ptr, rust_vec_len, data_len), + 42 => wire__crate__api__bridge__app_thread_start_impl(port, ptr, rust_vec_len, data_len), + 43 => wire__crate__api__bridge__app_thread_summary_impl(port, ptr, rust_vec_len, data_len), + 44 => { + wire__crate__api__bridge__app_thread_turn_items_impl(port, ptr, rust_vec_len, data_len) + }, + 45 => wire__crate__api__bridge__app_turn_interrupt_impl(port, ptr, rust_vec_len, data_len), + 46 => wire__crate__api__bridge__app_turn_start_impl(port, ptr, rust_vec_len, data_len), + 48 => wire__crate__api__bridge__codex_auth_status_impl(port, ptr, rust_vec_len, data_len), + 49 => wire__crate__api__bridge__codex_locate_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__bridge__codex_login_cancel_impl(port, ptr, rust_vec_len, data_len), + 51 => wire__crate__api__bridge__codex_login_chatgpt_start_impl( port, ptr, rust_vec_len, data_len, ), - 50 => wire__crate__api__bridge__codex_logout_impl(port, ptr, rust_vec_len, data_len), - 51 => { + 52 => wire__crate__api__bridge__codex_logout_impl(port, ptr, rust_vec_len, data_len), + 53 => { wire__crate__api__bridge__codex_prompt_variant_impl(port, ptr, rust_vec_len, data_len) }, - 52 => wire__crate__api__bridge__codex_set_prompt_variant_impl( + 54 => wire__crate__api__bridge__codex_set_prompt_variant_impl( port, ptr, rust_vec_len, data_len, ), - 53 => { + 55 => { wire__crate__api__bridge__codex_setup_provider_impl(port, ptr, rust_vec_len, data_len) }, - 54 => wire__crate__api__bridge__codex_setup_status_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__bridge__discover_services_impl(port, ptr, rust_vec_len, data_len), - 56 => { + 56 => wire__crate__api__bridge__codex_setup_status_impl(port, ptr, rust_vec_len, data_len), + 57 => wire__crate__api__bridge__discover_services_impl(port, ptr, rust_vec_len, data_len), + 58 => { wire__crate__api__bridge__embedded_codex_version_impl(port, ptr, rust_vec_len, data_len) }, - 57 => wire__crate__api__bridge__export_config_impl(port, ptr, rust_vec_len, data_len), - 58 => wire__crate__api__bridge__get_config_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__bridge__import_config_impl(port, ptr, rust_vec_len, data_len), - 61 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len), - 62 => wire__crate__api__bridge__init_bridge_impl(port, ptr, rust_vec_len, data_len), - 63 => wire__crate__api__bridge__log_events_impl(port, ptr, rust_vec_len, data_len), - 64 => wire__crate__api__bridge__meta_force_resume_impl(port, ptr, rust_vec_len, data_len), - 65 => wire__crate__api__bridge__meta_list_dir_impl(port, ptr, rust_vec_len, data_len), - 66 => wire__crate__api__bridge__meta_list_files_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__bridge__meta_project_config_impl(port, ptr, rust_vec_len, data_len), - 68 => wire__crate__api__bridge__meta_read_file_impl(port, ptr, rust_vec_len, data_len), - 69 => { + 59 => wire__crate__api__bridge__export_config_impl(port, ptr, rust_vec_len, data_len), + 60 => wire__crate__api__bridge__get_config_impl(port, ptr, rust_vec_len, data_len), + 62 => wire__crate__api__bridge__import_config_impl(port, ptr, rust_vec_len, data_len), + 63 => wire__crate__api__simple__init_app_impl(port, ptr, rust_vec_len, data_len), + 64 => wire__crate__api__bridge__init_bridge_impl(port, ptr, rust_vec_len, data_len), + 65 => wire__crate__api__bridge__log_events_impl(port, ptr, rust_vec_len, data_len), + 66 => wire__crate__api__bridge__meta_force_resume_impl(port, ptr, rust_vec_len, data_len), + 67 => wire__crate__api__bridge__meta_list_dir_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__bridge__meta_list_files_impl(port, ptr, rust_vec_len, data_len), + 69 => wire__crate__api__bridge__meta_project_config_impl(port, ptr, rust_vec_len, data_len), + 70 => wire__crate__api__bridge__meta_read_file_impl(port, ptr, rust_vec_len, data_len), + 71 => { wire__crate__api__bridge__meta_read_thread_image_impl(port, ptr, rust_vec_len, data_len) }, - 70 => wire__crate__api__bridge__meta_retry_events_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__bridge__meta_session_events_impl(port, ptr, rust_vec_len, data_len), - 72 => { + 72 => wire__crate__api__bridge__meta_retry_events_impl(port, ptr, rust_vec_len, data_len), + 73 => wire__crate__api__bridge__meta_session_events_impl(port, ptr, rust_vec_len, data_len), + 74 => { wire__crate__api__bridge__meta_session_liveness_impl(port, ptr, rust_vec_len, data_len) }, - 73 => wire__crate__api__bridge__meta_session_transcript_impl( + 75 => wire__crate__api__bridge__meta_session_transcript_impl( port, ptr, rust_vec_len, data_len, ), - 74 => wire__crate__api__bridge__meta_sessions_impl(port, ptr, rust_vec_len, data_len), - 75 => wire__crate__api__bridge__meta_set_project_config_impl( + 76 => wire__crate__api__bridge__meta_sessions_impl(port, ptr, rust_vec_len, data_len), + 77 => wire__crate__api__bridge__meta_set_project_config_impl( port, ptr, rust_vec_len, data_len, ), - 76 => { + 78 => { wire__crate__api__bridge__meta_thread_config_get_impl(port, ptr, rust_vec_len, data_len) }, - 77 => { + 79 => { wire__crate__api__bridge__meta_thread_config_set_impl(port, ptr, rust_vec_len, data_len) }, - 78 => wire__crate__api__bridge__meta_upload_file_impl(port, ptr, rust_vec_len, data_len), - 79 => wire__crate__api__bridge__meta_write_file_impl(port, ptr, rust_vec_len, data_len), - 80 => wire__crate__api__bridge__set_key_impl(port, ptr, rust_vec_len, data_len), - 81 => wire__crate__api__bridge__set_locale_impl(port, ptr, rust_vec_len, data_len), - 82 => wire__crate__api__bridge__set_relay_impl(port, ptr, rust_vec_len, data_len), - 83 => wire__crate__api__bridge__subscriptions_impl(port, ptr, rust_vec_len, data_len), + 80 => wire__crate__api__bridge__meta_upload_file_impl(port, ptr, rust_vec_len, data_len), + 81 => wire__crate__api__bridge__meta_write_file_impl(port, ptr, rust_vec_len, data_len), + 82 => wire__crate__api__bridge__set_key_impl(port, ptr, rust_vec_len, data_len), + 83 => wire__crate__api__bridge__set_locale_impl(port, ptr, rust_vec_len, data_len), + 84 => wire__crate__api__bridge__set_relay_impl(port, ptr, rust_vec_len, data_len), + 85 => wire__crate__api__bridge__subscriptions_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -4168,9 +4298,9 @@ fn pde_ffi_dispatcher_sync_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 19 => wire__crate__api__bridge__app_is_connected_impl(ptr, rust_vec_len, data_len), - 40 => wire__crate__api__bridge__app_thread_runtime_config_impl(ptr, rust_vec_len, data_len), - 45 => wire__crate__api__simple__bridge_version_impl(ptr, rust_vec_len, data_len), - 59 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len), + 41 => wire__crate__api__bridge__app_thread_runtime_config_impl(ptr, rust_vec_len, data_len), + 47 => wire__crate__api__simple__bridge_version_impl(ptr, rust_vec_len, data_len), + 61 => wire__crate__api__simple__greet_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -4613,6 +4743,24 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::bridge::OlderPageDto { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [self.items.into_into_dart().into_dart(), self.has_older.into_into_dart().into_dart()] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::bridge::OlderPageDto +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::bridge::OlderPageDto +{ + fn into_into_dart(self) -> crate::api::bridge::OlderPageDto { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::bridge::ProjectConfigDto { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -4780,6 +4928,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::bridge::ThreadHistoryDto { self.approval_policy.into_into_dart().into_dart(), self.sandbox_mode.into_into_dart().into_dart(), self.config_confirmed.into_into_dart().into_dart(), + self.has_older.into_into_dart().into_dart(), + self.turns.into_into_dart().into_dart(), ] .into_dart() } @@ -4873,6 +5023,29 @@ impl flutter_rust_bridge::IntoIntoDart flutter_rust_bridge::for_generated::DartAbi { + [ + self.turn_id.into_into_dart().into_dart(), + self.user_text.into_into_dart().into_dart(), + self.assistant_text.into_into_dart().into_dart(), + self.loaded.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::bridge::TurnSummaryDto +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::bridge::TurnSummaryDto +{ + fn into_into_dart(self) -> crate::api::bridge::TurnSummaryDto { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::bridge::WebLoginStartDto { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ @@ -5270,6 +5443,16 @@ impl SseEncode for Vec { } } +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + impl SseEncode for crate::api::bridge::LocalSessionDto { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -5307,6 +5490,14 @@ impl SseEncode for crate::api::bridge::ModelInfoDto { } } +impl SseEncode for crate::api::bridge::OlderPageDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.items, serializer); + ::sse_encode(self.has_older, serializer); + } +} + impl SseEncode for Option { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -5449,6 +5640,8 @@ impl SseEncode for crate::api::bridge::ThreadHistoryDto { >::sse_encode(self.approval_policy, serializer); >::sse_encode(self.sandbox_mode, serializer); ::sse_encode(self.config_confirmed, serializer); + ::sse_encode(self.has_older, serializer); + >::sse_encode(self.turns, serializer); } } @@ -5490,6 +5683,16 @@ impl SseEncode for crate::api::bridge::ThreadRuntimeConfigDto { } } +impl SseEncode for crate::api::bridge::TurnSummaryDto { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.turn_id, serializer); + ::sse_encode(self.user_text, serializer); + ::sse_encode(self.assistant_text, serializer); + ::sse_encode(self.loaded, serializer); + } +} + impl SseEncode for u16 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/pocket-codex-codex/src/client.rs b/crates/pocket-codex-codex/src/client.rs index 8406994..2c76b87 100644 --- a/crates/pocket-codex-codex/src/client.rs +++ b/crates/pocket-codex-codex/src/client.rs @@ -286,20 +286,64 @@ impl AppClient { params, }; let frame = serde_json::to_string(&req).context("serializing request")?; + let frame_bytes = frame.len(); let (tx, rx) = oneshot::channel(); - self.pending.lock().await.insert(id.clone(), tx); + let in_flight = { + let mut pending = self.pending.lock().await; + pending.insert(id.clone(), tx); + pending.len() + }; + // How many requests are queued on this socket when this one starts. A + // rising number across successive reads is the signature of callers + // outpacing the socket rather than any single request being slow. + tracing::debug!( + target: "pocket_codex_codex::rpc", + "-> {method} id={id} bytes={frame_bytes} in_flight={in_flight}" + ); + let started = std::time::Instant::now(); if let Err(e) = self.sink.lock().await.send(WsMessage::text(frame)).await { self.pending.lock().await.remove(&id); + tracing::warn!( + target: "pocket_codex_codex::rpc", + "!! {method} id={id} send failed after {:?}: {e}", started.elapsed() + ); return Err(anyhow!("sending request `{method}`: {e}")); } match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { - Ok(Ok(result)) => result, - Ok(Err(_)) => Err(anyhow!("request `{method}` cancelled")), + Ok(Ok(result)) => { + let elapsed = started.elapsed(); + let ok = result.is_ok(); + // Slow answers are the interesting ones; a server-side walk over + // a long thread shows up here and nowhere else. + if elapsed > std::time::Duration::from_secs(2) { + tracing::warn!( + target: "pocket_codex_codex::rpc", + "<- {method} id={id} SLOW {elapsed:?} ok={ok}" + ); + } else { + tracing::debug!( + target: "pocket_codex_codex::rpc", + "<- {method} id={id} {elapsed:?} ok={ok}" + ); + } + result + }, + Ok(Err(_)) => { + tracing::warn!( + target: "pocket_codex_codex::rpc", + "<- {method} id={id} cancelled after {:?} (socket closed)", started.elapsed() + ); + Err(anyhow!("request `{method}` cancelled")) + }, Err(_) => { self.pending.lock().await.remove(&id); + tracing::error!( + target: "pocket_codex_codex::rpc", + "<- {method} id={id} TIMED OUT after {:?}", started.elapsed() + ); Err(anyhow!("request `{method}` timed out")) }, } diff --git a/design/HANDOFF-thread-list-stall.md b/design/HANDOFF-thread-list-stall.md new file mode 100644 index 0000000..8a9fa5e --- /dev/null +++ b/design/HANDOFF-thread-list-stall.md @@ -0,0 +1,202 @@ +# Handoff: `thread/list` 无响应导致连接反复重建 + +**日期**:2026-09-01 +**状态**:根因未确认,卡点已收窄到被复用的 app-server 进程 +**分支**:`chore/codex-upstream-sync`(有大量未提交改动,见文末) + +--- + +## 一、用户观察到的现象 + +1. 启动后**第一个会话**能正常加载,之后**所有**会话都很卡并自动失败 +2. 报错为 `app-server connection closed`,有时是 `request 'thread/resume' timed out` +3. 早期版本静置几分钟能自愈;最新一次构建后**不再自愈**,启动即报错 +4. 状态栏同时显示"就绪",与连接已死矛盾 + +--- + +## 二、已确证的硬事实(都有实证,不是推理) + +### 2.1 客户端日志的 72 秒周期 + +日志位置(本次新增的文件落盘,保留 6 小时): + +``` +~/Library/Application Support/io.github.ackingyou.pocketCodex/logs/pocket-codex-.log +``` + +``` +11:57:39 thread/list id=2 SLOW 2.11s ok=true ← 唯一一次成功(= "第一个会话正常") +11:58:41 thread/list id=7 TIMED OUT after 60.00s + 随后所有请求 → "Sending after closing is not allowed" +11:59:09 新建隧道重连 +12:00:10 thread/list id=4 TIMED OUT after 60.00s ← 无限循环 +``` + +**关键:整份日志里没有任何一条 `thread_read`。** 卡死发生在会话列表阶段,本次新增的分页历史读取代码从未被调用到。 + +### 2.2 中继侧完全健康(已 ssh 到 lb7666.top 核实) + +- `pb-mapper admin service list --all`:每个服务 `CONNECTIONS = 2`,配额 16,**无饱和** +- 所有连接 `health: Healthy` +- 隧道建立 `setup_elapsed_ms: 46~57ms` +- `journalctl -u pb-mapper-server`:**零 error、零 warn** +- 服务端与客户端同为 pb-mapper **0.5.0** + +中继日志里 `client forward finished` 的间隔严格 72 秒,误差毫秒级: + +``` +12:24:29 / 12:25:41 / 12:26:53 / 12:28:05 / 12:29:17 ... +``` + +`72 = 60 + 12`,而 60 秒正是我们自己 `crates/pocket-codex-codex/src/client.rs:60` 的 +`REQUEST_TIMEOUT`。**是我们的客户端主动断开**,中继只是忠实记录。 + +### 2.3 每周期只回 573 字节,且每次完全相同 + +``` +forward finish! we send 573 bytes, detail:server->client +``` + +573 字节装不下 70+ 个会话的 `thread/list` 响应,且数值每次一模一样 —— 像是一个固定的 +小响应(疑为 `initialize` 的应答),之后 app-server 再没回过任何东西。 + +### 2.4 被复用的 app-server 进程是空转的 + +`adopting codex app-server already on the listen port, pid=96166` + +对 PID 96166 采样(`sample 96166 3`): + +- 所有 `tokio-rt-worker` 都停在 `parking_lot::condvar::Condvar::wait`(空闲等活) +- CPU **0%**,RSS 约 430–605MB,已存活 **5.5 小时** +- WebSocket 握手仍返回 `101 Switching Protocols`(传输层正常) + +即:**进程收下了请求却不处理**。这解释了为什么重启 app 无效 —— 问题跟着这个被复用的 +进程走,而不是跟着 app。 + +--- + +## 三、当前最强假设(未验证) + +**被复用的 app-server 进程内部劣化,能答 `initialize` 但不答 `thread/list`。** + +支撑:2.3 的 573 字节 + 2.4 的空转采样 + 重启 app 无效。 + +### 关键可疑点:adopt 没有功能性健康检查 + +`crates/pocket-codex-codex/src/process.rs:412-438` + +adopt 的判据只有「端口被占用」+「占用者是 codex 进程」,随后直接返回 +`listener_confirmed: true`。**没有发一个真实请求验证它还能工作。** 一个僵掉的 +app-server 会被无条件复用,且每次启动都复用同一个。 + +--- + +## 四、建议的下一步(按顺序) + +1. **排除实验(最优先)**:`kill 96166`,让 app 拉起一个干净的 app-server 再复现。 + - 恢复 → 确认是长期存活进程劣化。接着查:那个进程为何僵(对它做 `sample` 时抓 + `thread/list` 处理路径)、以及 adopt 是否该加健康探测 + - 不恢复 → 卡点在客户端请求路径,回到 `client.rs` 与隧道层继续查 + +2. 若需在服务端复现,`thread/list` 的处理入口在 + `deps/codex/codex-rs/app-server/src/request_processors/thread_processor.rs`, + 注意它会取 `acquire_thread_list_state_permit()` —— 那是一个全局 + **`Semaphore::new(1)`**(`app-server/src/message_processor.rs:382`)。 + 若某个先前的持有者未释放,`thread/list` 会永久阻塞,与观察到的现象吻合。 + **这条尚未验证**,是最值得先查的一条。 + +3. 顺带一个独立缺陷(与本问题无关,但值得修):socket 健康标志 + `crates/pocket-codex-codex/src/client.rs:242` 在整个 bridge 里**零调用者**。 + 看门狗(15 秒 ping / 20 秒判死,`client.rs:196-216`)判定 socket 已死,结论却从未 + 传到 UI,所以状态栏在连接已死时仍显示"就绪"。 + +--- + +## 五、我走过的弯路(避免重复) + +三个曾被我当作根因、后被实证推翻的结论: + +1. **`itemsView: "full"` 触发服务端嵌套循环** —— 机制真实存在 + (`thread_processor.rs:3213-3217` 逐 turn 串行、内层再按页拉完所有 item),已改为 + `notLoaded` + 独立 item 分页。但日志证明卡死发生在 `thread_read` 之前,**不是本问题的原因**。 + +2. **FRB 线程池被侧栏摘要占满** —— 机制也真实(`Normal` 模式走 + `thread_pool.execute`,池大小 = CPU 核数 = 12,每个调用 `block_on` 真阻塞),已给 + `threadSummaryProvider` 加了并发上限 3(`apps/flutter/lib/src/providers.dart`)。 + 但同样**不是本问题的原因**。 + +3. **pb-mapper 控制连接泄漏** —— **完全错误,已撤回**。这来自一份 8 天前的记忆,但: + - `deps/pb-mapper` 早已不是 submodule,现在是 registry 依赖 `pb-mapper = "0.5.0"` + (`Cargo.toml:49`),`deps/` 下那些目录只是残留 + - 0.5.0 的 client 已用 `JoinSet` 重写控制池 + (`pb-mapper-client-0.5.0/src/server/mod.rs:536`),泄漏已修 + - 服务器实测连接数 2/16,毫无饱和 + +**教训:不要拿旧记忆当结论,先验证。** 我靠读代码猜了三轮,日志一次就定位了。 + +### pb-mapper 的续期与临时 key 逻辑已逐条读过,均健康,不必重查 + +- 控制心跳 2 秒 / 容忍 6 秒 / 租约 15 秒(`pb-mapper-core-0.5.0/src/config.rs:207-215`), + 日志中 `local_server_heartbeat_sent` 正常 +- 临时凭据提前 **30 分钟**续期(`crates/pocket-codex-pb/src/keepalive.rs:33`) +- 凭据缓存带余量检查(`crates/pocket-codex-bridge/src/engine/account.rs:477`) +- 订阅侧 `keep_alive: true`(`crates/pocket-codex-pb/src/session.rs:93`) + +--- + +## 六、环境与工具 + +- 服务器:`ssh ubuntu@lb7666.top`(密码在用户手里;`sshpass` 已装) + - 中继诊断需要: + ```bash + export PB_MAPPER_SERVER=127.0.0.1:7666 # 注意不是 ..._SERVER_ADDR + export MSG_HEADER_KEY=$(sudo cat /var/lib/pb-mapper-server/msg_header_key | tr -d '\n') + sudo -E pb-mapper admin service list --all + ``` +- `gh` CLI 在这台 Mac 上不可用(keyring token 失效 + 公司 iOA 拦截 TLS);推送走 SSH, + PR 用预填 compare URL +- **磁盘极度紧张**:曾降到 581MB。`cargo test --workspace` 需 20G+,会耗尽磁盘, + **请按 crate 分批跑**。可回收:`target/debug/{deps,incremental}`。 + **绝不可删** `target/debug/gn_out/obj/librusty_v8.a`(145MB,此网络环境下无法重新下载) +- Release 构建(单架构,照 CI 做法): + ```bash + export RUSTY_V8_ARCHIVE=$PWD/target/debug/gn_out/obj/librusty_v8.a + export FLUTTER_XCODE_ARCHS=arm64 FLUTTER_XCODE_ONLY_ACTIVE_ARCH=NO + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 CARGO_PROFILE_RELEASE_LTO=false + (cd apps/flutter && fvm flutter build macos --release) + ``` + +--- + +## 七、未提交的改动(21 个文件,全部门禁已过) + +分支 `chore/codex-upstream-sync`,已有提交 `3106f22`(codex 子模块升级 +318 commits, +已推送)。工作区还有未提交内容,分三部分: + +**1. 分页历史加载(功能,本次主要工作)** +- `crates/pocket-codex-bridge/src/engine/app_session.rs`:按 `historyMode` 分流, + 分页会话走 `thread/turns/list` + `thread/items/list`;legacy 保持整体读取; + 新增 `thread_older_page` / `thread_turn_items`;照抄上游 `advancing_cursor` 防游标死循环 +- `crates/pocket-codex-bridge/src/api/bridge.rs` + FRB 生成物:新增 + `app_thread_older_page` / `app_thread_turn_items`,`ThreadHistoryDto` 加 + `has_older` / `turns` +- Dart:刻度栏改为骨架驱动(打开即显示完整会话长度)、滚动到顶与 hover 刻度按需补正文、 + 顶部"更早历史"行(可点击,因内容不足一屏时无法滚动) + +**2. 日志基础设施(本次新增,建议保留)** +- `crates/pocket-codex-bridge/src/engine/logging.rs`:日志落盘 + 6 小时保留 +- `crates/pocket-codex-codex/src/client.rs`:每个 RPC 的方向/耗时/字节数/in-flight 深度, + 慢于 2 秒告警、超时报 error +- `app_session.rs`:`thread_read` 进出与各阶段耗时、bridge 并发深度 + +> 正是这套日志一次定位了 `thread/list`,此前三轮读代码推测全错。 + +**3. 一处防御性改动** +- `apps/flutter/lib/src/providers.dart`:`threadSummaryProvider` 并发上限 3 + +**门禁状态**:`cargo fmt` / `cargo clippy --workspace -D warnings` / Rust 123 测试 / +`dart format` / `flutter analyze` / Flutter 409 测试(含 6 个新增分页测试)全部通过。 + +**尚未验证**:分页功能的端到端 UI 行为。因为本问题(`thread/list` 卡死)导致会话 +列表都加载不出来,分页路径压根没机会执行。 From 0d08203410196d6ed2764a0b25ff75a8da1c4fb9 Mon Sep 17 00:00:00 2001 From: LB7666 Date: Sun, 6 Sep 2026 00:02:57 +0800 Subject: [PATCH 3/4] fix(app): recover stalled sessions and improve transcript scrolling --- Cargo.lock | 2 + .../history_recovery_test.dart | 166 ++++++++ .../lib/src/screens/app_session_screen.dart | 395 +++++++++--------- .../lib/src/widgets/middle_click_scroll.dart | 209 +++++++++ .../flutter/lib/src/widgets/turn_minimap.dart | 62 ++- apps/flutter/test/fake_bridge_api.dart | 9 + .../test/middle_click_scroll_test.dart | 196 +++++++++ .../test/screens/app_session_scroll_test.dart | 73 ++++ .../test/screens/app_session_test.dart | 29 ++ apps/flutter/test/turn_minimap_test.dart | 49 ++- crates/pocket-codex-bridge/Cargo.toml | 4 + .../pocket-codex-bridge/src/engine/account.rs | 95 +++-- .../src/engine/account_relay_tests.rs | 207 +++++++++ .../src/engine/app_session.rs | 90 ++-- .../engine/app_session_pagination_tests.rs | 87 ++++ .../pocket-codex-bridge/src/engine/serve.rs | 15 +- crates/pocket-codex-cli/src/commands/codex.rs | 1 + crates/pocket-codex-cli/src/commands/serve.rs | 37 +- .../examples/app_server_probe.rs | 142 +++++++ crates/pocket-codex-codex/src/client.rs | 204 +++++---- crates/pocket-codex-codex/src/client_tests.rs | 156 +++++++ crates/pocket-codex-codex/src/readiness.rs | 91 +++- .../src/readiness_rpc_tests.rs | 95 +++++ design/HANDOFF-thread-list-stall.md | 151 ++++++- init-submodules.bat | 11 + init-submodules.ps1 | 74 ++++ 26 files changed, 2215 insertions(+), 435 deletions(-) create mode 100644 apps/flutter/integration_test/history_recovery_test.dart create mode 100644 apps/flutter/lib/src/widgets/middle_click_scroll.dart create mode 100644 apps/flutter/test/middle_click_scroll_test.dart create mode 100644 apps/flutter/test/screens/app_session_scroll_test.dart create mode 100644 crates/pocket-codex-bridge/src/engine/account_relay_tests.rs create mode 100644 crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs create mode 100644 crates/pocket-codex-codex/examples/app_server_probe.rs create mode 100644 crates/pocket-codex-codex/src/client_tests.rs create mode 100644 crates/pocket-codex-codex/src/readiness_rpc_tests.rs create mode 100644 init-submodules.bat create mode 100644 init-submodules.ps1 diff --git a/Cargo.lock b/Cargo.lock index 115dfd7..377840d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9468,6 +9468,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "flutter_rust_bridge", + "futures", "once_cell", "pocket-codex-account-proto", "pocket-codex-api-proxy", @@ -9481,6 +9482,7 @@ dependencies = [ "serde_json", "tokio", "tokio-rustls", + "tokio-tungstenite 0.28.0", "toml 0.8.23", "tracing", "tracing-subscriber", diff --git a/apps/flutter/integration_test/history_recovery_test.dart b/apps/flutter/integration_test/history_recovery_test.dart new file mode 100644 index 0000000..64dd865 --- /dev/null +++ b/apps/flutter/integration_test/history_recovery_test.dart @@ -0,0 +1,166 @@ +// Live desktop regression: real bridge -> relay -> app-server -> transcript. +// Use an isolated CODEX_HOME copy: resuming a thread can update its metadata. +// Run with --dart-define-from-file containing PCX_RELAY, PCX_KEY, PCX_SERVICE, +// PCX_HISTORY_THREAD (paginated, >100 items) and PCX_SECOND_THREAD (legacy). +// Both threads must be visible in thread/list; publish the matching meta +// service too, as the screen restores its per-thread configuration from it. +// This test never starts a model turn. + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:pocket_codex/l10n/gen/app_localizations.dart'; +import 'package:pocket_codex/src/bridge_api.dart'; +import 'package:pocket_codex/src/bridge_api_rust.dart'; +import 'package:pocket_codex/src/providers.dart'; +import 'package:pocket_codex/src/rust/api/bridge.dart' as frb; +import 'package:pocket_codex/src/rust/frb_generated.dart'; +import 'package:pocket_codex/src/screens/app_session_screen.dart'; +import 'package:pocket_codex/src/ui_prefs.dart'; +import 'package:super_sliver_list/super_sliver_list.dart'; +import 'package:window_manager/window_manager.dart'; + +const _relay = String.fromEnvironment('PCX_RELAY'); +const _key = String.fromEnvironment('PCX_KEY'); +const _service = String.fromEnvironment('PCX_SERVICE'); +const _paginated = String.fromEnvironment('PCX_HISTORY_THREAD'); +const _legacy = String.fromEnvironment('PCX_SECOND_THREAD'); + +class _MemoryPrefs extends UiPrefsStore { + @override + Future build() async => const UiPrefs(); +} + +class _ObservedBridge extends RustBridgeApi { + final histories = {}; + final olderPages = []; + + @override + Future appThreadRead( + String serviceKey, + String threadId, + ) async { + final history = await super.appThreadRead(serviceKey, threadId); + histories[threadId] = history; + return history; + } + + @override + Future appThreadOlderPage( + String serviceKey, + String threadId, + ) async { + final page = await super.appThreadOlderPage(serviceKey, threadId); + olderPages.add(page); + return page; + } +} + +Future _until(WidgetTester tester, bool Function() ready) async { + final deadline = DateTime.now().add(const Duration(seconds: 90)); + while (!ready() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } + expect(ready(), isTrue, reason: 'live history operation did not complete'); + await Future.delayed(const Duration(milliseconds: 100)); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + testWidgets( + 'repeated thread opens and older history through the real relay', + (tester) async { + if ([_relay, _key, _service, _paginated, _legacy].any((v) => v.isEmpty)) { + markTestSkipped('isolated live-history configuration not provided'); + return; + } + await RustLib.init(); + await windowManager.ensureInitialized(); + await windowManager.show(); + await windowManager.focus(); + final dir = await Directory.systemTemp.createTemp('pcx-history-it-'); + await frb.initBridge(supportDir: dir.path); + await frb.setRelay(relay: _relay); + await frb.setKey(key: _key); + final api = _ObservedBridge(); + await api.appConnect(_service, 0); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + await api.appDisconnect(_service); + // The native logger keeps its file open until process exit on Windows. + }); + + for (var round = 0; round < 3; round++) { + final threads = await api.appThreadList(_service); + expect(threads.any((thread) => thread.id == _paginated), isTrue); + expect(threads.any((thread) => thread.id == _legacy), isTrue); + } + + for (final threadId in [_paginated, _legacy, _paginated]) { + api.histories.remove(threadId); + await tester.pumpWidget( + ProviderScope( + overrides: [ + bridgeApiProvider.overrideWithValue(api), + uiPrefsProvider.overrideWith(_MemoryPrefs.new), + ], + child: MaterialApp( + locale: const Locale('zh'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: AppSessionScreen( + key: ValueKey(threadId), + serviceKey: _service, + threadId: threadId, + home: true, + ), + ), + ), + ); + await _until(tester, () => api.histories.containsKey(threadId)); + await _until( + tester, + () => find.byType(SuperListView).evaluate().isNotEmpty, + ); + expect(api.histories[threadId]!.items, isNotEmpty); + expect(api.appIsConnected(_service), isTrue); + expect(tester.takeException(), isNull); + debugPrint( + 'Live history rendered: ${api.histories[threadId]!.items.length} items', + ); + } + + expect(api.histories[_paginated]!.turns, isNotEmpty); + expect(api.histories[_paginated]!.hasOlder, isTrue); + // Opening settles the transcript at its end over several layout frames. + await Future.delayed(const Duration(milliseconds: 500)); + final pagesBeforeScroll = api.olderPages.length; + final transcript = tester.widget( + find.byType(SuperListView), + ); + transcript.controller!.jumpTo(0); + final olderButton = find.byKey(const Key('chat-older-history-load')); + await _until( + tester, + () => + api.olderPages.length > pagesBeforeScroll || + olderButton.hitTestable().evaluate().isNotEmpty, + ); + if (api.olderPages.length == pagesBeforeScroll) { + await tester.tap(olderButton); + } + await _until(tester, () => api.olderPages.length > pagesBeforeScroll); + expect(api.olderPages.last.items, isNotEmpty); + expect(api.appIsConnected(_service), isTrue); + expect(tester.takeException(), isNull); + debugPrint( + 'Live older history loaded: ${api.olderPages.last.items.length} items', + ); + }, + timeout: const Timeout(Duration(minutes: 8)), + ); +} diff --git a/apps/flutter/lib/src/screens/app_session_screen.dart b/apps/flutter/lib/src/screens/app_session_screen.dart index 7998ddc..0f4157f 100644 --- a/apps/flutter/lib/src/screens/app_session_screen.dart +++ b/apps/flutter/lib/src/screens/app_session_screen.dart @@ -43,6 +43,7 @@ import 'package:pocket_codex/src/widgets/folder_tree_picker.dart'; import 'package:pocket_codex/src/widgets/links.dart'; import 'package:pocket_codex/src/widgets/loading.dart'; import 'package:pocket_codex/src/widgets/message_images.dart'; +import 'package:pocket_codex/src/widgets/middle_click_scroll.dart'; import 'package:pocket_codex/src/widgets/project_menu.dart'; import 'package:pocket_codex/src/widgets/status_dots.dart'; import 'package:pocket_codex/src/widgets/takeover_dialog.dart'; @@ -637,7 +638,7 @@ class _AppSessionState extends ConsumerState _healthTimer = Timer.periodic(const Duration(seconds: 12), (_) { if (!mounted || _reconnecting) return; if (!ref.read(bridgeApiProvider).appIsConnected(widget.serviceKey)) { - _autoReconnect(); + _onStreamClosed(); } }); } @@ -1179,7 +1180,11 @@ class _AppSessionState extends ConsumerState if (!mounted) return; // The event stream closing means the socket dropped — recover automatically // rather than leaving the session silently dead. - setState(() => _streaming = false); + setState(() { + _streaming = false; + _connectionLost = true; + }); + _publishLinkState(down: true); _autoReconnect(); } @@ -2768,6 +2773,11 @@ class _AppSessionState extends ConsumerState if (_openLoadRetries.containsKey(_kCwdSeed)) await _seedDefaultCwd(); if (_rate == null) unawaited(_loadQuota()); _loadGit(); // the working tree may have moved on while we were away + // Content loaders retain old data on failure. A timed-out RPC can + // therefore close this new connection without throwing out of them. + if (!api.appIsConnected(widget.serviceKey)) { + throw StateError('app-server connection closed during reconnect'); + } if (mounted) { setState(() { _reconnecting = false; @@ -4345,203 +4355,212 @@ class _AppSessionState extends ConsumerState children: [ _statusBar(l10n), Expanded( - child: Stack( - key: const Key('chat-conversation-layer'), - children: [ - Positioned.fill( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 250), - child: _loading - ? const ChatLoadingSkeleton(key: ValueKey('chat-loading')) - : KeyedSubtree( - key: const ValueKey('chat-content'), - child: _items.isEmpty && !_showTyping - // A brand-new conversation (no thread yet) gets a richer - // guidance view with tappable starter prompts; an empty - // resumed thread keeps the plain hint. - ? (_threadId == null - ? _newSessionGuidance(l10n) - : Center( - child: Text( - l10n.emptyConversation, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.outline, + child: MiddleClickScroll( + key: ValueKey(_threadId), + controller: _scroll, + child: Stack( + key: const Key('chat-conversation-layer'), + children: [ + Positioned.fill( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _loading + ? const ChatLoadingSkeleton( + key: ValueKey('chat-loading'), + ) + : KeyedSubtree( + key: const ValueKey('chat-content'), + child: _items.isEmpty && !_showTyping + // A brand-new conversation (no thread yet) gets a richer + // guidance view with tappable starter prompts; an empty + // resumed thread keeps the plain hint. + ? (_threadId == null + ? _newSessionGuidance(l10n) + : Center( + child: Text( + l10n.emptyConversation, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.outline, + ), + ), + )) + // One SelectionArea over the whole conversation so text can be + // drag-selected and copied (desktop drag, mobile long-press) — + // per-message actions appear on hover instead of always-on. The + // list is centered with a max width so it reads well even when + // both side panes are collapsed on a wide screen. + : Stack( + children: [ + // Full-width scroll area so the scrollbar sits at + // the window's right edge instead of floating at + // the centred column's edge; the conversation + // column itself stays centred via horizontal + // padding computed from the available width. + SelectionArea( + child: LayoutBuilder( + builder: (context, constraints) { + // The same gutter the turn rail sits + // in, so the two never disagree about + // where the column ends. + final side = _gutterWidth( + constraints.maxWidth, + ); + final pad = side < 16 ? 16.0 : side; + // Materialize the collapsed timeline ONCE per + // build: `_rows` is a getter that re-scans + // `_items` on every access, so reading it for + // itemCount and again per itemBuilder was + // O(n²) per frame. Hoisting it here keeps each + // build O(n). + final rows = _rows; + // SuperListView (super_sliver_list) replaces + // ListView.builder to stabilize the scrollbar: + // it derives scroll extent from per-item + // estimates reconciled against real heights as + // rows pass through the cache area, instead of + // the single running-average estimate that + // makes a plain ListView's thumb jump with the + // wide row-height variance here. Same lazy + // virtualization, same ScrollController — only + // visible rows build, so streaming stays cheap. + return SuperListView.builder( + controller: _scroll, + listController: _listCtl, + padding: EdgeInsets.fromLTRB( + pad, + 12, + pad, + 12, ), - ), - )) - // One SelectionArea over the whole conversation so text can be - // drag-selected and copied (desktop drag, mobile long-press) — - // per-message actions appear on hover instead of always-on. The - // list is centered with a max width so it reads well even when - // both side panes are collapsed on a wide screen. - : Stack( - children: [ - // Full-width scroll area so the scrollbar sits at - // the window's right edge instead of floating at - // the centred column's edge; the conversation - // column itself stays centred via horizontal - // padding computed from the available width. - SelectionArea( - child: LayoutBuilder( - builder: (context, constraints) { - // The same gutter the turn rail sits - // in, so the two never disagree about - // where the column ends. - final side = _gutterWidth( - constraints.maxWidth, - ); - final pad = side < 16 ? 16.0 : side; - // Materialize the collapsed timeline ONCE per - // build: `_rows` is a getter that re-scans - // `_items` on every access, so reading it for - // itemCount and again per itemBuilder was - // O(n²) per frame. Hoisting it here keeps each - // build O(n). - final rows = _rows; - // SuperListView (super_sliver_list) replaces - // ListView.builder to stabilize the scrollbar: - // it derives scroll extent from per-item - // estimates reconciled against real heights as - // rows pass through the cache area, instead of - // the single running-average estimate that - // makes a plain ListView's thumb jump with the - // wide row-height variance here. Same lazy - // virtualization, same ScrollController — only - // visible rows build, so streaming stays cheap. - return SuperListView.builder( - controller: _scroll, - listController: _listCtl, - padding: EdgeInsets.fromLTRB( - pad, - 12, - pad, - 12, - ), - // A leading row when history - // continues above, so a long - // conversation says so instead of - // looking like it starts there. - itemCount: - rows.length + - (_hasOlder ? 1 : 0) + - (_showTyping ? 1 : 0), - itemBuilder: (c, i) { - if (_hasOlder) { - if (i == 0) { - return _olderHistoryHeader( - l10n, + // A leading row when history + // continues above, so a long + // conversation says so instead of + // looking like it starts there. + itemCount: + rows.length + + (_hasOlder ? 1 : 0) + + (_showTyping ? 1 : 0), + itemBuilder: (c, i) { + if (_hasOlder) { + if (i == 0) { + return _olderHistoryHeader( + l10n, + ); + } + i -= 1; + } + if (i >= rows.length) { + return TypingIndicator( + key: _externalWriterRunning + ? const Key( + 'chat-external-output-indicator', + ) + : null, + elapsed: _fmtElapsed( + _elapsedSecs, + ), + ); + } + final row = rows[i]; + // Stable keys let the sliver's + // extent-reconciliation track each row + // across rebuilds (streaming upserts, + // collapse-into-group transitions) instead + // of recycling element/state by position — + // which otherwise churns measured heights. + // A group keys off its first item's stable + // id plus length so expand/collapse and + // run-growth produce a fresh measurement. + if (row is TurnWork) { + // Keyed on the first item alone, + // NOT the length: a running turn + // grows an item at a time, and + // re-keying on each would discard + // the fold's expanded state mid- + // turn — exactly while the user is + // watching it work. + return TurnWorkCard( + key: ValueKey( + 'w:${row.items.first.id}', + ), + work: row, + ); + } + if (row is ActivityGroup) { + return GroupedActivityCard( + key: ValueKey( + 'g:${row.items.first.id}:' + '${row.items.length}', + ), + group: row, + ); + } + // A merged reply renders through the + // same view as a single one, so the two + // can't drift apart: it is presented as + // one item whose text is the whole turn. + if (row is AgentTurn) { + return MessageView( + key: ValueKey( + 't:${row.items.first.id}:' + '${row.items.length}', + ), + item: TranscriptItem( + id: row.items.first.id, + type: 'agentMessage', + text: row.text, + streaming: row.streaming, + turnId: row + .items + .first + .turnId, + turnCompletedAt: + row.completedAt, + ), + hostImageLoader: + _loadHostImage, ); } - i -= 1; - } - if (i >= rows.length) { - return TypingIndicator( - key: _externalWriterRunning - ? const Key( - 'chat-external-output-indicator', - ) - : null, - elapsed: _fmtElapsed( - _elapsedSecs, - ), - ); - } - final row = rows[i]; - // Stable keys let the sliver's - // extent-reconciliation track each row - // across rebuilds (streaming upserts, - // collapse-into-group transitions) instead - // of recycling element/state by position — - // which otherwise churns measured heights. - // A group keys off its first item's stable - // id plus length so expand/collapse and - // run-growth produce a fresh measurement. - if (row is TurnWork) { - // Keyed on the first item alone, - // NOT the length: a running turn - // grows an item at a time, and - // re-keying on each would discard - // the fold's expanded state mid- - // turn — exactly while the user is - // watching it work. - return TurnWorkCard( - key: ValueKey( - 'w:${row.items.first.id}', - ), - work: row, - ); - } - if (row is ActivityGroup) { - return GroupedActivityCard( - key: ValueKey( - 'g:${row.items.first.id}:' - '${row.items.length}', - ), - group: row, - ); - } - // A merged reply renders through the - // same view as a single one, so the two - // can't drift apart: it is presented as - // one item whose text is the whole turn. - if (row is AgentTurn) { return MessageView( key: ValueKey( - 't:${row.items.first.id}:' - '${row.items.length}', - ), - item: TranscriptItem( - id: row.items.first.id, - type: 'agentMessage', - text: row.text, - streaming: row.streaming, - turnId: - row.items.first.turnId, - turnCompletedAt: - row.completedAt, + (row as TranscriptItem).id, ), + item: row, hostImageLoader: _loadHostImage, ); - } - return MessageView( - key: ValueKey( - (row as TranscriptItem).id, - ), - item: row, - hostImageLoader: _loadHostImage, - ); - }, - ); - }, + }, + ); + }, + ), ), - ), - // Turn navigation. On a window wide enough - // to leave a gutter this is the tick rail - // beside the conversation — hover a turn to - // preview it, click to jump. Narrower, and - // on touch, it stays the bottom-right - // cluster, which also carries jump-to-latest - // in both cases. - Positioned.fill(child: _turnNavOverlay()), - ], - ), - ), - ), - ), - if (runningPlan != null) - Positioned( - left: 0, - right: 0, - bottom: 8, - child: _turnProgress(runningPlan, l10n), + // Turn navigation. On a window wide enough + // to leave a gutter this is the tick rail + // beside the conversation — hover a turn to + // preview it, click to jump. Narrower, and + // on touch, it stays the bottom-right + // cluster, which also carries jump-to-latest + // in both cases. + Positioned.fill(child: _turnNavOverlay()), + ], + ), + ), + ), ), - ], + if (runningPlan != null) + Positioned( + left: 0, + right: 0, + bottom: 8, + child: _turnProgress(runningPlan, l10n), + ), + ], + ), ), ), // Inline server requests: a `request_user_input` elicitation renders as diff --git a/apps/flutter/lib/src/widgets/middle_click_scroll.dart b/apps/flutter/lib/src/widgets/middle_click_scroll.dart new file mode 100644 index 0000000..e31e051 --- /dev/null +++ b/apps/flutter/lib/src/widgets/middle_click_scroll.dart @@ -0,0 +1,209 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +/// Browser-style vertical auto-scroll for a mouse-equipped transcript. +/// +/// Click the middle button, then move above or below the anchor to scroll. +/// Another click, Escape, the wheel, or leaving the viewport stops scrolling. +/// Holding the middle button while moving also works and stops on release. +class MiddleClickScroll extends StatefulWidget { + /// Wraps the viewport driven by [controller]. + const MiddleClickScroll({ + super.key, + required this.controller, + required this.child, + }); + + /// The vertical transcript's scroll controller. + final ScrollController controller; + + /// The transcript viewport and its overlays. + final Widget child; + + @override + State createState() => _MiddleClickScrollState(); +} + +class _MiddleClickScrollState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + static const _deadZone = 12.0; + late final Ticker _ticker; + Offset? _anchor; + Offset _pointer = Offset.zero; + Duration? _elapsed; + bool _held = false; + bool _dragged = false; + + @override + void initState() { + super.initState(); + _ticker = createTicker(_tick); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + if (_anchor != null) HardwareKeyboard.instance.removeHandler(_onKey); + _ticker.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(MiddleClickScroll oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) _stop(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) _stop(); + } + + void _down(PointerDownEvent event) { + if (_anchor != null) { + _stop(); + return; + } + if (event.kind != PointerDeviceKind.mouse || + event.buttons != kMiddleMouseButton || + widget.controller.positions.length != 1) { + return; + } + final position = widget.controller.position; + if (!position.hasContentDimensions || + position.maxScrollExtent <= position.minScrollExtent) { + return; + } + setState(() { + _anchor = _pointer = event.localPosition; + _held = true; + _dragged = false; + _elapsed = null; + }); + HardwareKeyboard.instance.addHandler(_onKey); + _ticker.start(); + } + + void _move(PointerEvent event) { + final anchor = _anchor; + if (anchor == null) return; + _pointer = event.localPosition; + if (_held && (_pointer - anchor).distance > _deadZone) _dragged = true; + } + + void _up(PointerUpEvent event) { + if (_held && _dragged) _stop(); + _held = false; + } + + void _signal(PointerSignalEvent event) { + if (_anchor == null) return; + _stop(); + // The active overlay catches this first wheel event; pass it through the + // normal resolver so switching back to wheel scrolling loses no movement. + if (event is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver.register(event, (_) { + if (widget.controller.positions.length == 1) { + widget.controller.position.pointerScroll(event.scrollDelta.dy); + } + }); + } + } + + bool _onKey(KeyEvent event) { + if (event is! KeyDownEvent || + event.logicalKey != LogicalKeyboardKey.escape) { + return false; + } + _stop(); + return true; + } + + void _stop() { + if (_anchor == null) return; + _ticker.stop(); + HardwareKeyboard.instance.removeHandler(_onKey); + setState(() => _anchor = null); + _held = false; + } + + void _tick(Duration elapsed) { + final previous = _elapsed; + _elapsed = elapsed; + if (previous == null || _anchor == null) return; + if (widget.controller.positions.length != 1) { + _stop(); + return; + } + final offset = _pointer.dy - _anchor!.dy; + final distance = math.max(0.0, offset.abs() - _deadZone); + if (distance == 0) return; + final speed = math.min(2400.0, distance * 8 + distance * distance / 32); + // A delayed frame must not turn into a large jump after a window resumes. + final seconds = ((elapsed - previous).inMicroseconds / 1000000).clamp( + 0.0, + 0.05, + ); + widget.controller.position.pointerScroll(offset.sign * speed * seconds); + } + + @override + Widget build(BuildContext context) { + final anchor = _anchor; + final scheme = Theme.of(context).colorScheme; + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _down, + onPointerMove: _move, + onPointerUp: _up, + onPointerCancel: (_) => _stop(), + onPointerSignal: _signal, + child: MouseRegion( + onHover: _move, + onExit: (_) => _stop(), + child: Stack( + fit: StackFit.passthrough, + children: [ + widget.child, + if (anchor != null) ...[ + const Positioned.fill( + child: MouseRegion( + cursor: SystemMouseCursors.allScroll, + child: ColoredBox(color: Colors.transparent), + ), + ), + Positioned( + left: anchor.dx - 14, + top: anchor.dy - 14, + child: IgnorePointer( + child: ExcludeSemantics( + child: Container( + key: const Key('middle-click-scroll-anchor'), + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: scheme.surface, + border: Border.all(color: scheme.outline), + ), + child: Icon( + Icons.unfold_more, + size: 20, + color: scheme.onSurface, + ), + ), + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/flutter/lib/src/widgets/turn_minimap.dart b/apps/flutter/lib/src/widgets/turn_minimap.dart index 29c7f3a..6a31754 100644 --- a/apps/flutter/lib/src/widgets/turn_minimap.dart +++ b/apps/flutter/lib/src/widgets/turn_minimap.dart @@ -156,23 +156,12 @@ class _TurnMinimapState extends State { /// Drives the width falloff and the preview. int? _active; - /// The tick last jumped to, kept after the pointer leaves. - /// - /// A click used to clear [_active], which collapsed the rail to its resting - /// width at the exact moment the user landed — so the rail stopped saying - /// where in the conversation they now were. This keeps the mark without - /// keeping the preview card, which genuinely should go: it would hang over the - /// turn just navigated to. - int? _landed; - - /// The tick to emphasise: what the pointer is on, else where we last landed. - int? get _marked => _active ?? _landed; - /// Whether the pointer is anywhere near the rail. Only used to fade the rail /// in on a window too narrow to keep it resting. bool _hovering = false; final _focus = FocusNode(debugLabel: 'turn-minimap'); + final _previewKey = GlobalKey(); @override void dispose() { @@ -184,10 +173,8 @@ class _TurnMinimapState extends State { void didUpdateWidget(TurnMinimap old) { super.didUpdateWidget(old); // A turn was removed (a rewind, a reload) — an index past the end would - // otherwise resolve to nothing and leave a stuck preview, or a mark on a - // tick that no longer exists. + // otherwise resolve to nothing and leave a stuck preview. _active = _clampIndex(_active); - _landed = _clampIndex(_landed); } /// [index] pulled back inside the current turn list, or null when there are no @@ -251,12 +238,10 @@ class _TurnMinimapState extends State { if (item == null) return; widget.onSelect(item); // Drop focus after a jump so the preview doesn't hang over the place the - // user just navigated to. The tick stays marked via `_landed`, so the rail - // remains open and still says where they are. + // user just navigated to. The visible range keeps marking their position. _focus.unfocus(); setState(() { _active = null; - _landed = index; }); } @@ -303,9 +288,7 @@ class _TurnMinimapState extends State { (widget.items.length - 1) * _kTickSpacing, ); final railHeight = math.min(natural, available); - // Visible while a tick is marked, which now includes one just jumped to - // — so a narrow-gutter rail does not vanish the instant you use it. - final open = _marked != null; + final open = _active != null; return Align( alignment: Alignment.centerLeft, child: Padding( @@ -342,15 +325,22 @@ class _TurnMinimapState extends State { child: MouseRegion( cursor: clickable, onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() { - _hovering = false; - _active = null; - }), + onExit: (_) => _leave(), onHover: (event) { - // Past the resting strip the pointer is over the preview card, which - // owns its own text selection — keep the current tick rather than - // re-resolving from an X the rail doesn't govern. - if (event.localPosition.dx > hitWidth) return; + if (event.localPosition.dx > hitWidth) { + final card = _previewKey.currentContext?.findRenderObject(); + // The expanded box also covers empty space beside other ticks. + // Only the actual card should keep a preview open off the rail. + if (card is RenderBox && + card.hasSize && + (Offset.zero & card.size).contains( + card.globalToLocal(event.position), + )) { + return; + } + _leave(); + return; + } final next = _indexAt(event.localPosition.dy, railHeight); if (next != _active) { setState(() => _active = next); @@ -378,10 +368,18 @@ class _TurnMinimapState extends State { ); } + void _leave() { + if (!_hovering && _active == null) return; + setState(() { + _hovering = false; + _active = null; + }); + } + /// The ticks. Each repaints on scroll through [TurnMinimap.visibleRange] /// alone, so following a streaming reply never rebuilds the transcript. List _ticks(double railHeight, ColorScheme scheme) { - final active = _marked; + final active = _active; return [ for (var i = 0; i < widget.items.length; i++) Positioned( @@ -457,7 +455,7 @@ class _TurnMinimapState extends State { top: railHeight * fraction, child: FractionalTranslation( translation: Offset(0, align), - child: _TurnPreviewCard(item: item, width: width), + child: _TurnPreviewCard(key: _previewKey, item: item, width: width), ), ); } @@ -465,7 +463,7 @@ class _TurnMinimapState extends State { /// The floating preview: what the user asked, and how the turn answered. class _TurnPreviewCard extends StatelessWidget { - const _TurnPreviewCard({required this.item, required this.width}); + const _TurnPreviewCard({super.key, required this.item, required this.width}); final TurnMinimapItem item; diff --git a/apps/flutter/test/fake_bridge_api.dart b/apps/flutter/test/fake_bridge_api.dart index 9a855d2..3cb2d59 100644 --- a/apps/flutter/test/fake_bridge_api.dart +++ b/apps/flutter/test/fake_bridge_api.dart @@ -508,8 +508,17 @@ class FakeBridgeApi implements BridgeApi { /// socket), then resets — to exercise the picker's reconnect-and-retry path. bool failNextThreadList = false; + /// Simulate a backend whose handshake works but whose first RPC kills the link. + bool disconnectOnThreadList = false; + @override Future> appThreadList(String serviceKey) async { + if (disconnectOnThreadList) { + _appConnected.remove(serviceKey); + throw StateError( + 'request `thread/list` timed out; app-server connection closed', + ); + } if (failNextThreadList) { failNextThreadList = false; throw StateError('Trying to work with closed connection'); diff --git a/apps/flutter/test/middle_click_scroll_test.dart b/apps/flutter/test/middle_click_scroll_test.dart new file mode 100644 index 0000000..cf9ef1e --- /dev/null +++ b/apps/flutter/test/middle_click_scroll_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocket_codex/src/widgets/middle_click_scroll.dart'; + +const _anchor = Offset(250, 250); +final _indicator = find.byKey(const Key('middle-click-scroll-anchor')); + +Future _pump(WidgetTester t, {int count = 100}) async { + final controller = ScrollController( + initialScrollOffset: count > 10 ? 500 : 0, + ); + addTearDown(controller.dispose); + await t.pumpWidget( + MaterialApp( + home: Scaffold( + body: MiddleClickScroll( + controller: controller, + child: SelectionArea( + child: ListView.builder( + controller: controller, + itemExtent: 60, + itemCount: count, + itemBuilder: (_, i) => Text('message $i'), + ), + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + return controller; +} + +Future _middle(WidgetTester t, {bool hold = false}) async { + final mouse = await t.createGesture( + kind: PointerDeviceKind.mouse, + buttons: kMiddleMouseButton, + ); + await mouse.addPointer(location: _anchor); + addTearDown(mouse.removePointer); + await mouse.down(_anchor); + await t.pump(); + if (!hold) { + await mouse.up(); + await t.pump(); + } + return mouse; +} + +Future _frames(WidgetTester t) async { + for (var i = 0; i < 12; i++) { + await t.pump(const Duration(milliseconds: 16)); + } +} + +void main() { + testWidgets('middle click scrolls in either direction and has a dead zone', ( + t, + ) async { + final controller = await _pump(t); + final mouse = await _middle(t); + expect(_indicator, findsOneWidget); + await _frames(t); + expect(controller.offset, 500); + + await mouse.moveTo(_anchor + const Offset(0, 80)); + await _frames(t); + final lower = controller.offset; + expect(lower, greaterThan(500)); + + await mouse.moveTo(_anchor - const Offset(0, 80)); + await _frames(t); + expect(controller.offset, lessThan(lower)); + + await mouse.moveTo(_anchor + const Offset(0, 5)); + await _frames(t); + final resting = controller.offset; + await _frames(t); + expect(controller.offset, resting); + + await mouse.down(_anchor); + await mouse.up(); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + }); + + testWidgets('holding middle scrolls and releasing after movement stops', ( + t, + ) async { + final controller = await _pump(t); + final mouse = await _middle(t, hold: true); + await mouse.moveTo(_anchor + const Offset(0, 80)); + await _frames(t); + expect(controller.offset, greaterThan(500)); + await mouse.up(); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + final stopped = controller.offset; + await _frames(t); + expect(controller.offset, stopped); + }); + + testWidgets('Escape and leaving the viewport cancel auto-scroll', (t) async { + final controller = await _pump(t); + final mouse = await _middle(t); + await mouse.moveTo(_anchor + const Offset(0, 80)); + await _frames(t); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + final stopped = controller.offset; + await _frames(t); + expect(controller.offset, stopped); + + await mouse.down(_anchor); + await mouse.up(); + await t.pump(); + expect(_indicator, findsOneWidget); + await mouse.moveTo(const Offset(2000, 2000)); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + }); + + testWidgets('the wheel exits auto-scroll without losing its movement', ( + t, + ) async { + final controller = await _pump(t); + await _middle(t); + await t.sendEventToBinding( + const PointerScrollEvent( + kind: PointerDeviceKind.mouse, + position: _anchor, + scrollDelta: Offset(0, 120), + ), + ); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + expect(controller.offset, 620); + }); + + testWidgets( + 'window focus loss stops scrolling and disposal removes handlers', + (t) async { + final controller = await _pump(t); + final mouse = await _middle(t); + await mouse.moveTo(_anchor + const Offset(0, 80)); + await _frames(t); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + final stopped = controller.offset; + await _frames(t); + expect(controller.offset, stopped); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + + await mouse.down(_anchor); + await mouse.up(); + await t.pump(); + expect(_indicator, findsOneWidget); + await t.pumpWidget(const SizedBox.shrink()); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }, + ); + + testWidgets('short content and primary clicks do not start auto-scroll', ( + t, + ) async { + await _pump(t, count: 2); + await _middle(t); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + await _pump(t); + await t.tapAt(_anchor, kind: PointerDeviceKind.mouse); + await t.pumpAndSettle(); + expect(_indicator, findsNothing); + }); + + testWidgets('auto-scroll stays within the transcript boundaries', (t) async { + final controller = await _pump(t); + controller.jumpTo(controller.position.maxScrollExtent - 1); + final mouse = await _middle(t); + await mouse.moveTo(_anchor + const Offset(0, 80)); + await _frames(t); + expect(controller.offset, controller.position.maxScrollExtent); + controller.jumpTo(1); + await mouse.moveTo(_anchor - const Offset(0, 80)); + await _frames(t); + expect(controller.offset, controller.position.minScrollExtent); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + }); +} diff --git a/apps/flutter/test/screens/app_session_scroll_test.dart b/apps/flutter/test/screens/app_session_scroll_test.dart new file mode 100644 index 0000000..fee1162 --- /dev/null +++ b/apps/flutter/test/screens/app_session_scroll_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocket_codex/src/bridge_api.dart'; +import 'package:pocket_codex/src/screens/app_session_screen.dart'; +import 'package:pocket_codex/src/widgets/middle_click_scroll.dart'; + +import '../fake_bridge_api.dart'; +import '../support/screen_harness.dart'; + +void main() { + testWidgets('middle-click reads back through the real transcript viewport', ( + t, + ) async { + AppSessionScreen.debugResetThreadMemory(); + t.view.devicePixelRatio = 1; + t.view.physicalSize = const Size(1200, 900); + addTearDown(t.view.reset); + final api = FakeBridgeApi( + config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), + ); + const service = 'pcx:lb7666:app:default'; + await api.appConnect(service, 28080); + api.readResult = ThreadHistory( + items: [ + for (var i = 0; i < 80; i++) + ThreadItem( + id: 'item-$i', + turnId: 'turn-${i ~/ 2}', + itemType: i.isEven ? 'userMessage' : 'agentMessage', + title: '', + text: 'Message $i\n\n${'Conversation history for reading. ' * 15}', + ), + ], + running: false, + ); + await t.pumpWidget( + host( + const AppSessionScreen(serviceKey: service, threadId: 'scroll-history'), + api, + ), + ); + await t.pumpAndSettle(); + final viewport = t.getRect( + find.byKey(const Key('chat-conversation-layer')), + ); + final controller = t + .widget(find.byType(MiddleClickScroll)) + .controller; + final before = controller.offset; + expect(before, greaterThan(500)); + final mouse = await t.createGesture( + kind: PointerDeviceKind.mouse, + buttons: kMiddleMouseButton, + ); + await mouse.addPointer(location: viewport.center); + addTearDown(mouse.removePointer); + await mouse.down(viewport.center); + await mouse.up(); + await t.pump(); + expect(find.byKey(const Key('middle-click-scroll-anchor')), findsOneWidget); + await mouse.moveTo(viewport.center - const Offset(0, 100)); + for (var i = 0; i < 15; i++) { + await t.pump(const Duration(milliseconds: 16)); + } + expect(controller.offset, lessThan(before)); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + expect(find.byKey(const Key('middle-click-scroll-anchor')), findsNothing); + expect(t.takeException(), isNull); + }); +} diff --git a/apps/flutter/test/screens/app_session_test.dart b/apps/flutter/test/screens/app_session_test.dart index 062a0a9..213c659 100644 --- a/apps/flutter/test/screens/app_session_test.dart +++ b/apps/flutter/test/screens/app_session_test.dart @@ -1591,6 +1591,35 @@ void main() { expect(find.byKey(const Key('conv-tile-a1')), findsOneWidget); }); + testWidgets('A reconnect stays disconnected when the first RPC times out', ( + t, + ) async { + final api = FakeBridgeApi( + config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), + ); + const service = 'pcx:lb7666:app:default'; + await api.appConnect(service, 28080); + await t.pumpWidget(host(const AppSessionScreen(serviceKey: service), api)); + await t.pumpAndSettle(); + api.disconnectOnThreadList = true; + await api.appDisconnect(service); + await t.pump(); + await t.pump(const Duration(seconds: 13)); + for (var i = 0; i < 5; i++) { + await t.pump(const Duration(seconds: 2)); + await t.pump(); + } + expect(api.appIsConnected(service), isFalse); + expect( + find.text('就绪'), + findsNothing, + reason: 'an initialize response alone must not restore the ready badge', + ); + expect(api.appConnectCount, greaterThan(2)); + await t.pumpWidget(const SizedBox.shrink()); + await t.pump(const Duration(seconds: 10)); + }); + testWidgets('The activity view groups by day and summarizes each row', ( t, ) async { diff --git a/apps/flutter/test/turn_minimap_test.dart b/apps/flutter/test/turn_minimap_test.dart index 87c5894..461b7d0 100644 --- a/apps/flutter/test/turn_minimap_test.dart +++ b/apps/flutter/test/turn_minimap_test.dart @@ -61,7 +61,7 @@ double _tickWidthAt(WidgetTester t, int index) => /// Hover the rail at [fraction] of its height, which is how the widget resolves /// which tick the pointer is on. -Future _hoverTick(WidgetTester t, double fraction) async { +Future _hoverTick(WidgetTester t, double fraction) async { final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); final gesture = await t.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); @@ -70,6 +70,7 @@ Future _hoverTick(WidgetTester t, double fraction) async { Offset(rail.left + 4, rail.top + rail.height * fraction), ); await t.pumpAndSettle(); + return gesture; } void main() { @@ -207,6 +208,35 @@ void main() { await gesture.moveTo(const Offset(399, 599)); await t.pumpAndSettle(); expect(find.text('question 0'), findsNothing); + expect(_tickWidthAt(t, 0), 7); + expect(t.getSize(find.byKey(const Key('turn-minimap-rail'))).width, 40); + }); + + testWidgets('leaving for empty space beside the preview restores the rail', ( + t, + ) async { + await _pump(t, items: _items(50)); + final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); + final gesture = await _hoverTick(t, 0); + expect(find.byKey(const Key('turn-minimap-preview')), findsOneWidget); + + // Inside the old expanded hit box, but far below the actual preview card. + await gesture.moveTo(Offset(rail.left + 200, rail.bottom - 2)); + await t.pumpAndSettle(); + expect(find.byKey(const Key('turn-minimap-preview')), findsNothing); + expect(_tickWidthAt(t, 0), 7); + expect(t.getSize(find.byKey(const Key('turn-minimap-rail'))).width, 40); + }); + + testWidgets('moving into the actual preview keeps it open', (t) async { + await _pump(t, items: _items(50)); + final gesture = await _hoverTick(t, 0); + await gesture.moveTo( + t.getCenter(find.byKey(const Key('turn-minimap-preview'))), + ); + await t.pumpAndSettle(); + expect(find.byKey(const Key('turn-minimap-preview')), findsOneWidget); + expect(_tickWidthAt(t, 0), 22); }); testWidgets('an on-screen turn is marked whatever the pointer is doing', ( @@ -340,14 +370,16 @@ void main() { ); }); - testWidgets('a click leaves the rail open with the landed tick marked', ( + testWidgets('a clicked tick loses its hover width when the pointer leaves', ( t, ) async { - // The jump used to clear the active tick, collapsing the rail to its resting - // width at the very moment the user arrived — so it stopped saying where in - // the conversation they now were. final items = _items(5); - await _pump(t, items: items); + await _pump( + t, + items: items, + visible: (items.last.rowIndex, items.last.rowIndex), + ); + final restingWidth = _tickWidthAt(t, items.length - 1); final rail = t.getRect(find.byKey(const Key('turn-minimap-rail'))); final gesture = await t.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); @@ -363,9 +395,10 @@ void main() { expect( _tickWidthAt(t, items.length - 1), - greaterThan(_tickWidthAt(t, 0)), - reason: 'the tick just jumped to stays the widest', + restingWidth, + reason: 'only the viewport highlight remains after pointer exit', ); + expect(t.getSize(find.byKey(const Key('turn-minimap-rail'))).width, 40); // The preview card is the one thing that must NOT survive the jump: it would // hang over the turn the user just navigated to. expect(find.byKey(const Key('turn-minimap-preview')), findsNothing); diff --git a/crates/pocket-codex-bridge/Cargo.toml b/crates/pocket-codex-bridge/Cargo.toml index fbfc07f..9fb0ccc 100644 --- a/crates/pocket-codex-bridge/Cargo.toml +++ b/crates/pocket-codex-bridge/Cargo.toml @@ -51,6 +51,10 @@ once_cell = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +[dev-dependencies] +futures = { workspace = true } +tokio-tungstenite = { workspace = true } + # Windows + macOS only: pull codex's app-server IN-PROCESS so the app can # self-host without an external codex install (the `embedded-codex` feature # compiles codex into the bridge). Target-gated, so mobile (android/ios) never diff --git a/crates/pocket-codex-bridge/src/engine/account.rs b/crates/pocket-codex-bridge/src/engine/account.rs index 0a98ae0..30a19ae 100644 --- a/crates/pocket-codex-bridge/src/engine/account.rs +++ b/crates/pocket-codex-bridge/src/engine/account.rs @@ -5,8 +5,8 @@ //! The app never sees the relay's ADMINISTRATOR key. It holds a backend-issued //! session token (a JWT in the 0600 `config.toml`), the opaque refresh token, //! and a short-lived relay credential the relay confines to this account's -//! namespace. After [`relay_credential`] the backend is off the path entirely — -//! see [`crate::engine::transport`]. +//! namespace. The backend is off the data path, but credential issuance and +//! renewal still require it — see [`crate::engine::transport`]. //! //! Pure async logic (no flutter_rust_bridge); the `api` layer drives it on the //! engine runtime. @@ -448,8 +448,8 @@ fn unix_now() -> i64 { /// This account's relay credential, from cache when it is still good. /// -/// The last thing the app needs the backend for: everything after it — -/// register, subscribe, every byte — is app↔relay. +/// Register, subscribe, and every session byte are app↔relay. Issuing and +/// renewing the temporary credential still require the backend. /// /// Cached because the app asks for this on every subscribe, probe, and key /// derivation, and a round trip per call would put the backend back on a hot @@ -464,29 +464,12 @@ fn unix_now() -> i64 { /// the previous account's namespace. Keying on `(backend, account id)` means a /// different signed-in identity simply misses the cache. pub async fn relay_credential(support_dir: &Path) -> Result { - let config = load_config(support_dir)?; - let owner = CacheOwner::of(&config); - // Held across the fetch, which is what makes a burst of first-time callers - // cost one request rather than one each — the same reason [`refresh_lock`] - // exists for the session token. - let mut cache = relay_cache().lock().await; - if let Some((cached_owner, cached)) = cache.as_ref() { - // Same identity, and still enough life left that a caller can open a - // tunnel with what we hand back. - if *cached_owner == owner - && cached.expires_at > (unix_now() + RELAY_CACHE_MARGIN_SECS).max(0) as u64 - { - return Ok(cached.clone()); - } - } - let fetched = fetch_relay_credential(support_dir).await?; - *cache = Some((owner, fetched.clone())); - Ok(fetched) + relay_cache().credential(support_dir, false).await } /// Discard the cached relay credential, so the next call re-fetches. pub async fn forget_relay_credential() { - *relay_cache().lock().await = None; + *relay_cache().current.lock().await = None; } /// Who a cached credential belongs to. @@ -513,15 +496,58 @@ impl CacheOwner { } } -/// Treat a credential with less than this remaining as due for renewal rather -/// than handing it out. -const RELAY_CACHE_MARGIN_SECS: i64 = 5 * 60; +#[derive(Default)] +struct RelayCache { + current: tokio::sync::Mutex>, + fetching: tokio::sync::Mutex<()>, +} -type RelayCache = tokio::sync::Mutex>; +impl RelayCache { + async fn valid_for(&self, owner: &CacheOwner) -> Option { + self.current + .lock() + .await + .as_ref() + .and_then(|(cached_owner, cached)| { + // Renewal runs in the background. An issuer outage must not stop + // new tunnels while the relay still accepts this credential. + (*cached_owner == *owner && cached.expires_at > unix_now().max(0) as u64) + .then(|| cached.clone()) + }) + } + + async fn credential( + &self, + support_dir: &Path, + refresh: bool, + ) -> Result { + let owner = CacheOwner::of(&load_config(support_dir)?); + if !refresh { + if let Some(cached) = self.valid_for(&owner).await { + return Ok(cached); + } + } + // Serialize issuer requests without locking out readers of a valid + // credential. A slow or failed renewal leaves the old value usable. + let _fetch = self.fetching.lock().await; + let owner = CacheOwner::of(&load_config(support_dir)?); + if !refresh { + if let Some(cached) = self.valid_for(&owner).await { + return Ok(cached); + } + } + let fetched = fetch_relay_credential(support_dir).await?; + if CacheOwner::of(&load_config(support_dir)?) != owner { + bail!("account changed while fetching the relay credential; retry"); + } + *self.current.lock().await = Some((owner, fetched.clone())); + Ok(fetched) + } +} fn relay_cache() -> &'static RelayCache { static CACHE: OnceCell = OnceCell::new(); - CACHE.get_or_init(|| tokio::sync::Mutex::new(None)) + CACHE.get_or_init(RelayCache::default) } /// Ask the backend for a relay credential, bypassing the cache. @@ -576,17 +602,14 @@ pub fn start_credential_refresh(support_dir: &Path, expires_at: u64) { let support = support_dir.to_path_buf(); pocket_codex_pb::keep_credential_alive(expires_at, move || { let support = support.clone(); - async move { - // Dropping the cache first is what makes this a REFRESH: the backend - // renews the same credential and returns the later expiry, and going - // through the cached path would just hand back the value we are - // trying to extend. - forget_relay_credential().await; - Ok(relay_credential(&support).await?.expires_at) - } + async move { Ok(relay_cache().credential(&support, true).await?.expires_at) } }); } +#[cfg(test)] +#[path = "account_relay_tests.rs"] +mod relay_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pocket-codex-bridge/src/engine/account_relay_tests.rs b/crates/pocket-codex-bridge/src/engine/account_relay_tests.rs new file mode 100644 index 0000000..219ed8d --- /dev/null +++ b/crates/pocket-codex-bridge/src/engine/account_relay_tests.rs @@ -0,0 +1,207 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, +}; + +use super::*; + +struct TestAccount(PathBuf); + +impl TestAccount { + fn new(backend: &str) -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("pcx-relay-{}-{id}", std::process::id())); + let token = format!( + "header.{}.signature", + URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{}}}"#, unix_now() + 3600)) + ); + let mut config = Config::default(); + config.set_account_backend(backend); + config.set_account_session(&token, "test-refresh", "alice", Some("alice".into())); + save_config(&dir, &config).expect("save test account"); + Self(dir) + } + + async fn cache(&self, expires_at: u64) -> Arc { + Arc::new(RelayCache { + current: tokio::sync::Mutex::new(Some(( + CacheOwner::of(&load_config(&self.0).expect("read test account")), + credential(expires_at), + ))), + fetching: tokio::sync::Mutex::new(()), + }) + } + + fn switch_account(&self) { + let mut config = load_config(&self.0).expect("read test account"); + let token = config.account_token().expect("test token").to_owned(); + config.set_account_session(&token, "bob-refresh", "bob", Some("bob".into())); + save_config(&self.0, &config).expect("switch test account"); + } +} + +impl Drop for TestAccount { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn credential(expires_at: u64) -> RelayCredentialResponse { + RelayCredentialResponse { + relay_addr: "127.0.0.1:7666".into(), + credential: "test-relay-credential".into(), + namespace: "alice".into(), + expires_at, + } +} + +async fn backend( + response: Option, +) -> (String, Arc, Arc, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test backend"); + let url = format!("http://{}", listener.local_addr().expect("backend address")); + let received = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let task = tokio::spawn({ + let received = received.clone(); + let release = release.clone(); + async move { + let (mut socket, _) = listener.accept().await.expect("accept credential request"); + let mut request = Vec::new(); + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let mut chunk = [0; 1024]; + let count = socket + .read(&mut chunk) + .await + .expect("read credential request"); + assert!(count > 0, "request closed before headers"); + request.extend_from_slice(&chunk[..count]); + } + assert!(request.starts_with(b"GET /v1/relay HTTP/1.1\r\n")); + received.notify_one(); + release.notified().await; + let (status, body) = match response { + Some(value) => { + ("200 OK", serde_json::to_string(&value).expect("encode credential")) + }, + None => ("503 Service Unavailable", String::new()), + }; + socket + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: \ + {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await + .expect("reply to credential request"); + } + }); + (url, received, release, task) +} + +#[tokio::test] +async fn failed_renewal_keeps_valid_credentials_available_even_while_backend_stalls() { + let (url, received, release, server) = backend(None).await; + let account = TestAccount::new(&url); + // Inside the old five-minute cutoff, but still accepted by the relay. + let old = credential(unix_now() as u64 + 60); + let cache = account.cache(old.expires_at).await; + let refresh = tokio::spawn({ + let cache = cache.clone(); + let support = account.0.clone(); + async move { cache.credential(&support, true).await } + }); + tokio::time::timeout(Duration::from_secs(5), received.notified()) + .await + .expect("renewal reached backend"); + let during = + tokio::time::timeout(Duration::from_millis(500), cache.credential(&account.0, false)) + .await + .expect("cached reads must not wait for backend") + .expect("old credential remains valid"); + assert_eq!(during, old); + release.notify_one(); + assert!(refresh.await.expect("renewal task").is_err()); + server.await.expect("backend task"); + assert_eq!( + cache + .credential(&account.0, false) + .await + .expect("cached credential"), + old + ); +} + +#[tokio::test] +async fn expired_credentials_require_the_issuer() { + let account = TestAccount::new("http://127.0.0.1:0"); + let cache = account.cache(unix_now() as u64).await; + assert!(cache.credential(&account.0, false).await.is_err()); +} + +#[tokio::test] +async fn another_account_cannot_use_the_cached_credential_during_an_outage() { + let account = TestAccount::new("http://127.0.0.1:0"); + let cache = account.cache(unix_now() as u64 + 3600).await; + account.switch_account(); + assert!(cache.credential(&account.0, false).await.is_err()); +} + +#[tokio::test] +async fn successful_renewal_replaces_the_cached_expiry() { + let renewed = credential(unix_now() as u64 + 7200); + let (url, _, release, server) = backend(Some(renewed.clone())).await; + let account = TestAccount::new(&url); + let cache = account.cache(unix_now() as u64 + 60).await; + release.notify_one(); + assert_eq!( + cache + .credential(&account.0, true) + .await + .expect("renew credential"), + renewed + ); + server.await.expect("backend task"); + assert_eq!( + cache + .credential(&account.0, false) + .await + .expect("cached credential"), + renewed + ); +} + +#[tokio::test] +async fn an_account_switch_during_renewal_discards_the_response() { + let (url, received, release, server) = + backend(Some(credential(unix_now() as u64 + 7200))).await; + let account = TestAccount::new(&url); + let cache = account.cache(unix_now() as u64 + 60).await; + let refresh = tokio::spawn({ + let cache = cache.clone(); + let support = account.0.clone(); + async move { cache.credential(&support, true).await } + }); + tokio::time::timeout(Duration::from_secs(5), received.notified()) + .await + .expect("renewal reached backend"); + account.switch_account(); + release.notify_one(); + let error = refresh + .await + .expect("renewal task") + .expect_err("identity changed"); + assert!(error.to_string().contains("account changed")); + server.await.expect("backend task"); + assert!(cache.credential(&account.0, false).await.is_err()); +} diff --git a/crates/pocket-codex-bridge/src/engine/app_session.rs b/crates/pocket-codex-bridge/src/engine/app_session.rs index 8b93aa0..4997070 100644 --- a/crates/pocket-codex-bridge/src/engine/app_session.rs +++ b/crates/pocket-codex-bridge/src/engine/app_session.rs @@ -655,34 +655,11 @@ pub fn probe_reason(service_key: String, local_port: u16, transport: &Transport) /// /// `local_addr` is a plain `host:port` this process can reach directly. For a /// service THIS machine hosts itself, pass its loopback app-listen address to -/// health-check the backend with no relay hop — a real handshake, so a wedged -/// or half-open codex (port still `accept`ing but never answering RPC) reads -/// `false` where a bare TCP-connect check would falsely read "online". +/// health-check the backend with no relay hop — initialized thread RPCs, so a +/// wedged or half-open codex (port still `accept`ing but never answering RPC) +/// reads `false` where a bare TCP-connect check would falsely read "online". pub fn probe_endpoint(local_addr: &str) -> bool { - let ws_url = format!("ws://{local_addr}"); - let outcome = runtime::runtime().block_on(async { - let (client, _notify_rx) = tokio::time::timeout(PROBE_TIMEOUT, AppClient::connect(&ws_url)) - .await - .context("probe: connect timed out")??; - tokio::time::timeout( - PROBE_TIMEOUT, - client.request( - "initialize", - json!({ - "clientInfo": { - "name": "pocket-codex", - "title": "Pocket-Codex", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": { "experimentalApi": true }, - }), - ), - ) - .await - .context("probe: initialize timed out")??; - Ok::<(), anyhow::Error>(()) - }); - outcome.is_ok() + probe_endpoint_error(local_addr).is_none() } /// Why a probe failed, or `None` when it succeeded. @@ -693,37 +670,14 @@ pub fn probe_endpoint(local_addr: &str) -> bool { /// rejects the handshake, e.g. a missing or stale authentication code, is the /// common case). pub fn probe_endpoint_error(local_addr: &str) -> Option { - let ws_url = format!("ws://{local_addr}"); - let outcome = runtime::runtime().block_on(async { - let (client, _notify_rx) = tokio::time::timeout(PROBE_TIMEOUT, AppClient::connect(&ws_url)) - .await - .context("probe: connect timed out")??; - tokio::time::timeout( + runtime::runtime() + .block_on(pocket_codex_codex::readiness::probe_rpc( + &format!("ws://{local_addr}"), PROBE_TIMEOUT, - client.request( - "initialize", - json!({ - "clientInfo": { - "name": "pocket-codex", - "title": "Pocket-Codex", - "version": env!("CARGO_PKG_VERSION"), - }, - "capabilities": { "experimentalApi": true }, - }), - ), - ) - .await - .context("probe: initialize timed out")??; - Ok::<(), anyhow::Error>(()) - }); - match outcome { - Ok(()) => None, - // The chain carries the transport's own words (the relay's HTTP status - // and body), which is the only place the real reason survives. - Err(e) => Some(format!("{e:#}")), - } + )) + .err() + .map(|error| format!("{error:#}")) } - /// Reachability of a remote API proxy: a transient tunnel plus a minimal HTTP /// request. The HTTP counterpart of [`probe_reason`], as a bare bool because an /// API proxy has no handshake whose failure would need explaining. @@ -1467,11 +1421,22 @@ fn summarize_turn(turn: &Value, loaded: bool) -> TurnSummary { /// Plain text of a message item, for a rail preview. fn item_plain_text(item: &Value) -> String { - ["text", "message", "content"] + if let Some(text) = ["text", "message", "content"] .iter() .find_map(|key| item.get(*key).and_then(Value::as_str)) + { + return text.to_string(); + } + item.get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") + }) .unwrap_or_default() - .to_string() } /// Flatten server turns into UI items, oldest first. @@ -1668,14 +1633,15 @@ fn load_paginated_window( skeleton.loaded = loaded_turns.contains(&skeleton.turn_id); } } - let has_older = skeletons.iter().any(|s| !s.loaded); - // Where older history continues: this page's own continuation cursor. let item_cursor = items_page .get("nextCursor") .and_then(Value::as_str) .filter(|cursor| !cursor.is_empty()) .map(str::to_string); + // Seeing one item from every turn does not mean every item was loaded: + // even a single turn can fill several pages. The item cursor is authoritative. + let has_older = item_cursor.is_some(); set_pagination(service_key, thread_id, ThreadPagination { next_item_cursor: item_cursor, seen_item_cursors: HashSet::new(), @@ -2914,6 +2880,10 @@ fn recursive_text(v: &Value) -> Option { } } +#[cfg(test)] +#[path = "app_session_pagination_tests.rs"] +mod pagination_tests; + #[cfg(test)] mod tests { diff --git a/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs b/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs new file mode 100644 index 0000000..7c069f2 --- /dev/null +++ b/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs @@ -0,0 +1,87 @@ +use futures::{SinkExt, StreamExt}; +use tokio::net::TcpListener; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use super::*; + +fn load_window(next_cursor: Value) -> LoadedHistory { + runtime::init(std::env::temp_dir()).expect("pagination test operation"); + let turn = json!({"id": "turn-1", "status": "completed", "items": [ + {"id": "user-1", "type": "userMessage", "content": [ + {"type": "text", "text": "First part"}, + {"type": "text", "text": "Second part"} + ]}, + {"id": "agent-1", "type": "agentMessage", "text": "Answer"} + ]}); + let replies = [ + ("thread/turns/list", json!({"data": [turn.clone()], "nextCursor": null})), + ( + "thread/items/list", + json!({"data": [{"turnId": "turn-1", "item": turn["items"][1]}], "nextCursor": next_cursor}), + ), + ("thread/turns/list", json!({"data": [turn], "nextCursor": null})), + ]; + let (client, peer) = runtime::runtime().block_on(async { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("pagination test operation"); + let url = format!("ws://{}", listener.local_addr().expect("pagination test operation")); + let peer = tokio::spawn(async move { + let mut socket = accept_async( + listener + .accept() + .await + .expect("pagination test operation") + .0, + ) + .await + .expect("pagination test operation"); + for (method, result) in replies { + let frame = socket + .next() + .await + .expect("pagination test operation") + .expect("pagination test operation") + .into_text() + .expect("pagination test operation"); + let request: Value = + serde_json::from_str(&frame).expect("pagination test operation"); + assert_eq!(request["method"], method); + socket + .send(Message::text(json!({"id": request["id"], "result": result}).to_string())) + .await + .expect("pagination test operation"); + } + socket + }); + let (client, _) = AppClient::connect(&url) + .await + .expect("pagination test operation"); + (Arc::new(client), peer) + }); + let history = load_paginated_window(&client, "pagination-test", "thread-1") + .expect("pagination test operation"); + runtime::runtime() + .block_on(peer) + .expect("pagination test operation"); + history +} + +#[test] +fn a_single_long_turn_keeps_its_older_item_pages_reachable() { + let history = load_window(json!("older-items")); + assert_eq!(history.skeletons.len(), 1); + assert!(history.has_older, "the latest turn can span multiple item pages"); +} + +#[test] +fn exhausted_item_cursor_ends_pagination() { + assert!(!load_window(Value::Null).has_older); +} + +#[test] +fn turn_summary_reads_the_upstream_user_content_array() { + let history = load_window(Value::Null); + assert_eq!(history.skeletons[0].user_text, "First part\nSecond part"); + assert_eq!(history.skeletons[0].assistant_text, "Answer"); +} diff --git a/crates/pocket-codex-bridge/src/engine/serve.rs b/crates/pocket-codex-bridge/src/engine/serve.rs index 823302a..65d931c 100644 --- a/crates/pocket-codex-bridge/src/engine/serve.rs +++ b/crates/pocket-codex-bridge/src/engine/serve.rs @@ -1439,7 +1439,7 @@ async fn health_watchdog(local_addr: String, spawn_opts: SpawnOptions) { let mut restart_failures: u32 = 0; loop { tokio::time::sleep(HEALTH_INTERVAL).await; - if probe_ready(&client, &url).await { + if probe_ready(&client, &url).await && probe_thread_rpc(&local_addr).await { consecutive = 0; restart_failures = 0; continue; @@ -1492,6 +1492,16 @@ async fn probe_ready(client: &reqwest::Client, url: &str) -> bool { matches!(client.get(url).send().await, Ok(resp) if resp.status().is_success()) } +async fn probe_thread_rpc(local_addr: &str) -> bool { + let result = + pocket_codex_codex::readiness::probe_rpc(&format!("ws://{local_addr}"), READY_TIMEOUT) + .await; + if let Err(error) = &result { + tracing::warn!(%local_addr, error = %format!("{error:#}"), "app-server functional health probe failed"); + } + result.is_ok() +} + /// Poll `/readyz` until it answers 2xx or `timeout` elapses — the async, /// in-runtime sibling of `pocket_codex_codex::wait_for_readyz` (which parks a /// thread). @@ -1537,7 +1547,7 @@ async fn embedded_health_watchdog(name: String, local_addr: String) { let mut restart_failures: u32 = 0; loop { tokio::time::sleep(HEALTH_INTERVAL).await; - if probe_ready(&client, &url).await { + if probe_ready(&client, &url).await && probe_thread_rpc(&local_addr).await { consecutive = 0; restart_failures = 0; continue; @@ -1708,6 +1718,7 @@ mod tests { #[test] fn startup_failure_error_carries_log_tail_and_port_hint() { let failure = StartupFailure { + rpc_error: None, process_exited: true, port_in_use: true, listen: "ws://127.0.0.1:18080".to_string(), diff --git a/crates/pocket-codex-cli/src/commands/codex.rs b/crates/pocket-codex-cli/src/commands/codex.rs index bbb54a1..e6606f6 100644 --- a/crates/pocket-codex-cli/src/commands/codex.rs +++ b/crates/pocket-codex-cli/src/commands/codex.rs @@ -140,6 +140,7 @@ mod tests { #[test] fn startup_failure_error_carries_log_tail_and_port_hint() { let failure = StartupFailure { + rpc_error: None, process_exited: true, port_in_use: true, listen: "ws://127.0.0.1:18080".to_string(), diff --git a/crates/pocket-codex-cli/src/commands/serve.rs b/crates/pocket-codex-cli/src/commands/serve.rs index 91d9334..b82f7f7 100644 --- a/crates/pocket-codex-cli/src/commands/serve.rs +++ b/crates/pocket-codex-cli/src/commands/serve.rs @@ -311,19 +311,19 @@ fn websocket_listen_addr(listen: &str) -> Option { .map(ToOwned::to_owned) } -/// Background task (account mode): probe the codex app-server's `/readyz` and -/// restart it when it stops responding, so turns recover without operator -/// intervention. +/// Background task (account mode): probe HTTP readiness and initialized +/// thread RPCs, restarting the app-server after consecutive failures. /// -/// `/readyz` reflects the HTTP acceptor's liveness, so this recovers a codex -/// that has fully crashed or stopped accepting (process gone, connection -/// refused, hung acceptor) — the "registered on the relay but the remote is -/// dead" case. It does NOT catch a codex that still accepts connections but has -/// wedged deeper (a hung model turn keeps `/readyz` green); detecting that -/// would need a turn-level probe and is out of scope here. +/// `thread/list` catches a stalled request dispatcher even when `/readyz` +/// and WebSocket pongs remain healthy. It does not run a model turn, so a +/// single hung generation on an otherwise responsive server is not restarted. async fn codex_health_watchdog(local_addr: String, spawn_opts: SpawnOptions) { let url = format!("http://{local_addr}/readyz"); - let client = match reqwest::Client::builder().timeout(HEALTH_TIMEOUT).build() { + let client = match reqwest::Client::builder() + .timeout(HEALTH_TIMEOUT) + .no_proxy() + .build() + { Ok(client) => client, // Building a loopback HTTP client should never fail; if it somehow does // there is nothing useful the watchdog can do, so bow out quietly. @@ -333,11 +333,24 @@ async fn codex_health_watchdog(local_addr: String, spawn_opts: SpawnOptions) { let mut restart_failures: u32 = 0; loop { tokio::time::sleep(HEALTH_INTERVAL).await; - let healthy = matches!( + let http_ready = matches!( client.get(&url).send().await, Ok(resp) if resp.status().is_success() ); - if healthy { + let rpc_ready = if http_ready { + let result = pocket_codex_codex::readiness::probe_rpc( + &format!("ws://{local_addr}"), + READY_TIMEOUT, + ) + .await; + if let Err(error) = &result { + tracing::warn!(%local_addr, error = %format!("{error:#}"), "app-server functional health probe failed"); + } + result.is_ok() + } else { + false + }; + if rpc_ready { consecutive = 0; restart_failures = 0; continue; diff --git a/crates/pocket-codex-codex/examples/app_server_probe.rs b/crates/pocket-codex-codex/examples/app_server_probe.rs new file mode 100644 index 0000000..84c31b8 --- /dev/null +++ b/crates/pocket-codex-codex/examples/app_server_probe.rs @@ -0,0 +1,142 @@ +//! Reproduce app-server stalls without the relay or Flutter. +//! +//! `cargo run -p pocket-codex-codex --example app_server_probe -- ` +//! adds repeated list/read requests to an existing server. Build with +//! `--features embedded-codex` and append `--embedded` to start an isolated +//! listener using the same Codex configuration as the desktop host. +//! `--resume` also resumes threads before reading history; use a copied +//! `CODEX_HOME` for this mode. `--hold` keeps the listener up for a UI client. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use pocket_codex_codex::client::AppClient; +use serde_json::{json, Value}; + +async fn request(client: &AppClient, method: &str, params: Value) -> Result { + let started = Instant::now(); + let result = client.request(method, params).await; + println!( + "{method}: {:?}, ok={}, alive={}", + started.elapsed(), + result.is_ok(), + client.is_alive() + ); + result.with_context(|| format!("{method} failed after {:?}", started.elapsed())) +} + +fn main() -> Result<()> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_stack_size(8 * 1024 * 1024) + .build()? + .block_on(run()) +} + +async fn run() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + let url = args + .first() + .context("expected ws://host:port [--embedded] [--resume] [--hold]")?; + if args.iter().any(|arg| arg == "--embedded") { + #[cfg(feature = "embedded-codex")] + { + let listen = url.clone(); + tokio::spawn(async move { + if let Err(error) = pocket_codex_codex::embedded::run(&listen).await { + eprintln!("embedded app-server: {error:#}"); + } + }); + } + #[cfg(not(feature = "embedded-codex"))] + anyhow::bail!("--embedded requires --features embedded-codex"); + } + + for round in 1..=5 { + let deadline = Instant::now() + Duration::from_secs(20); + let (client, mut events) = loop { + match AppClient::connect(url).await { + Ok(connection) => break connection, + Err(error) if Instant::now() >= deadline => return Err(error), + Err(_) => tokio::time::sleep(Duration::from_millis(200)).await, + } + }; + let notifications = tokio::spawn(async move { while events.recv().await.is_some() {} }); + println!("connection {round}"); + request( + &client, + "initialize", + json!({ + "clientInfo": {"name": "pocket-codex", "version": env!("CARGO_PKG_VERSION")}, + "capabilities": {"experimentalApi": true} + }), + ) + .await?; + for _ in 0..3 { + let list = + request(&client, "thread/list", json!({"limit": 100, "sortKey": "updated_at"})) + .await?; + if let Some(threads) = list["data"].as_array() { + println!("threads={}", threads.len()); + for thread in threads.iter().skip(round - 1).take(3) { + if args.iter().any(|arg| arg == "--resume") { + request(&client, "thread/resume", json!({"threadId": thread["id"]})) + .await?; + } + let metadata = request( + &client, + "thread/read", + json!({ + "threadId": thread["id"], "includeTurns": false + }), + ) + .await?; + if args.iter().any(|arg| arg == "--resume") { + read_history(&client, &metadata["thread"]).await?; + } + } + } + } + drop(client); + notifications.await?; + } + if args.iter().any(|arg| arg == "--hold") { + println!("probe complete; waiting for Ctrl+C"); + tokio::signal::ctrl_c().await?; + } + Ok(()) +} + +async fn read_history(client: &AppClient, thread: &Value) -> Result<()> { + if thread["historyMode"] != "paginated" { + request(client, "thread/read", json!({"threadId": thread["id"], "includeTurns": true})) + .await?; + return Ok(()); + } + for view in ["notLoaded", "summary"] { + request( + client, + "thread/turns/list", + json!({ + "threadId": thread["id"], "limit": 100, "sortDirection": "desc", "itemsView": view + }), + ) + .await?; + } + let mut cursor = Value::Null; + for _ in 0..3 { + let page = request( + client, + "thread/items/list", + json!({ + "threadId": thread["id"], "limit": 100, "sortDirection": "desc", "cursor": cursor + }), + ) + .await?; + cursor = page["nextCursor"].clone(); + if cursor.is_null() { + break; + } + } + Ok(()) +} diff --git a/crates/pocket-codex-codex/src/client.rs b/crates/pocket-codex-codex/src/client.rs index 2c76b87..8e31a44 100644 --- a/crates/pocket-codex-codex/src/client.rs +++ b/crates/pocket-codex-codex/src/client.rs @@ -17,8 +17,8 @@ use std::{ collections::HashMap, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Arc, + atomic::{AtomicU64, Ordering}, + Arc, Mutex as StdMutex, }, time::Duration, }; @@ -37,6 +37,7 @@ use tokio::{ use tokio_tungstenite::{ connect_async, tungstenite::Message as WsMessage, MaybeTlsStream, WebSocketStream, }; +use tokio_util::sync::CancellationToken; use crate::protocol::{Message, Notification, Request, RequestId, Response}; @@ -60,7 +61,7 @@ pub struct Inbound { const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); type WsSink = SplitSink>, WsMessage>; -type Pending = Arc>>>>; +type Pending = Arc>>>>; /// token (stringified id) → original [`RequestId`], so a server request can be /// answered with the exact id type (int stays int) it arrived with. type ServerReqs = Arc>>; @@ -85,11 +86,7 @@ pub struct AppClient { next_id: AtomicU64, reader: JoinHandle<()>, keepalive: JoinHandle<()>, - /// Cleared by the keepalive watchdog when the socket goes silent past - /// [`LIVENESS_DEADLINE`] (a half-open connection). Callers poll - /// [`AppClient::is_alive`] so a wedged-but-not-yet-closed socket is treated - /// as dead and reconnected, instead of hanging until a request times out. - healthy: Arc, + closed: CancellationToken, } impl Drop for AppClient { @@ -108,7 +105,8 @@ impl AppClient { .with_context(|| format!("connecting app-server websocket {ws_url}"))?; let (sink, mut read) = stream.split(); - let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + let sink = Arc::new(Mutex::new(sink)); + let pending: Pending = Arc::new(StdMutex::new(HashMap::new())); let server_reqs: ServerReqs = Arc::new(Mutex::new(HashMap::new())); let (notify_tx, notify_rx) = mpsc::unbounded_channel(); @@ -116,13 +114,21 @@ impl AppClient { // frame (data or Pong), and the keepalive watchdog uses it to tell a // live-but-quiet socket from a dead half-open one. let activity = Arc::new(AtomicU64::new(0)); - let healthy = Arc::new(AtomicBool::new(true)); + let closed = CancellationToken::new(); let reader_pending = Arc::clone(&pending); let reader_server_reqs = Arc::clone(&server_reqs); let reader_activity = Arc::clone(&activity); + let reader_closed = closed.clone(); let reader = tokio::spawn(async move { - while let Some(frame) = read.next().await { + loop { + let frame = tokio::select! { + _ = reader_closed.cancelled() => break, + frame = read.next() => match frame { + Some(frame) => frame, + None => break, + }, + }; // Any frame — including the Pong answering our keepalive Ping — // proves the socket's read half is alive. reader_activity.fetch_add(1, Ordering::Relaxed); @@ -138,12 +144,12 @@ impl AppClient { }; match msg { Message::Response(r) => { - if let Some(tx) = take_pending(&reader_pending, &r.id).await { + if let Some(tx) = take_pending(&reader_pending, &r.id) { let _ = tx.send(Ok(r.result)); } }, Message::Error(e) => { - if let Some(tx) = take_pending(&reader_pending, &e.id).await { + if let Some(tx) = take_pending(&reader_pending, &e.id) { let _ = tx.send(Err(anyhow!("{}", e.error.message))); } }, @@ -175,13 +181,9 @@ impl AppClient { } // Connection closed: fail every in-flight request so callers don't // hang on a oneshot that will never resolve. - let mut map = reader_pending.lock().await; - for (_, tx) in map.drain() { - let _ = tx.send(Err(anyhow!("app-server connection closed"))); - } + close_connection(&reader_closed, &reader_pending); }); - let sink = Arc::new(Mutex::new(sink)); // Keepalive + liveness watchdog. Each tick pings (keeping the relay // tunnel warm so a backgrounded session isn't idle-closed) then waits a // bounded window for ANY return frame. A healthy socket answers the Ping @@ -192,32 +194,42 @@ impl AppClient { // reconnect) and best-effort close the sink to unwedge the reader. let keepalive_sink = Arc::clone(&sink); let keepalive_activity = Arc::clone(&activity); - let keepalive_healthy = Arc::clone(&healthy); + let keepalive_closed = closed.clone(); + let keepalive_pending = Arc::clone(&pending); let keepalive = tokio::spawn(async move { let mut tick = tokio::time::interval(KEEPALIVE_INTERVAL); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); tick.tick().await; // consume the immediate first tick loop { - tick.tick().await; + tokio::select! { + _ = keepalive_closed.cancelled() => break, + _ = tick.tick() => {}, + } let before = keepalive_activity.load(Ordering::Relaxed); - let sent = keepalive_sink - .lock() - .await - .send(WsMessage::Ping(Vec::new().into())) - .await; - if sent.is_err() { - keepalive_healthy.store(false, Ordering::Relaxed); + let sent = tokio::select! { + _ = keepalive_closed.cancelled() => break, + sent = tokio::time::timeout(LIVENESS_DEADLINE, async { + keepalive_sink.lock().await.send(WsMessage::Ping(Vec::new().into())).await + }) => sent, + }; + if !matches!(sent, Ok(Ok(()))) { break; } // Give the Pong (or any traffic) a bounded window to arrive. - tokio::time::sleep(LIVENESS_DEADLINE).await; + tokio::select! { + _ = keepalive_closed.cancelled() => break, + _ = tokio::time::sleep(LIVENESS_DEADLINE) => {}, + } if keepalive_activity.load(Ordering::Relaxed) == before { // No return frame within the deadline → half-open/dead. - keepalive_healthy.store(false, Ordering::Relaxed); - let _ = keepalive_sink.lock().await.close().await; break; } } + close_connection(&keepalive_closed, &keepalive_pending); + let _ = tokio::time::timeout(LIVENESS_DEADLINE, async { + keepalive_sink.lock().await.close().await + }) + .await; }); Ok(( @@ -228,18 +240,18 @@ impl AppClient { next_id: AtomicU64::new(1), reader, keepalive, - healthy, + closed, }, notify_rx, )) } /// Whether the socket is still considered live. Goes `false` once the - /// keepalive watchdog sees the connection go silent past - /// [`LIVENESS_DEADLINE`] — a half-open socket that still accepts writes. - /// Higher layers poll this to reconnect instead of hanging on a dead link. + /// reader closes, a write fails, a request times out, or the keepalive + /// watchdog sees silence past [`LIVENESS_DEADLINE`]. Higher layers poll + /// this to reconnect instead of reusing an unresponsive connection. pub fn is_alive(&self) -> bool { - self.healthy.load(Ordering::Relaxed) + !self.closed.is_cancelled() } /// Answer a server→client request (identified by the `request_id` token @@ -255,18 +267,14 @@ impl AppClient { result, }; let frame = serde_json::to_string(&resp).context("serializing response")?; - self.sink - .lock() - .await - .send(WsMessage::text(frame)) - .await - .map_err(|e| anyhow!("sending response: {e}")) + self.send_frame(frame).await.context("sending response") } /// Send a JSON-RPC request and await its result, erroring on timeout, a /// JSON-RPC error response, or a dropped connection. pub async fn request(&self, method: &str, params: Value) -> Result { - self.request_inner(method, Some(params)).await + self.request_inner(method, Some(params), REQUEST_TIMEOUT) + .await } /// Like [`request`](Self::request) but omits the `params` field entirely. @@ -274,10 +282,15 @@ impl AppClient { /// upstream (`Option<()>`, skipped when absent) and reject an empty `{}` /// body as invalid params, so they must be sent with no `params` key. pub async fn request_no_params(&self, method: &str) -> Result { - self.request_inner(method, None).await + self.request_inner(method, None, REQUEST_TIMEOUT).await } - async fn request_inner(&self, method: &str, params: Option) -> Result { + async fn request_inner( + &self, + method: &str, + params: Option, + timeout: Duration, + ) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed).to_string(); let req = Request { jsonrpc: None, @@ -290,10 +303,17 @@ impl AppClient { let (tx, rx) = oneshot::channel(); let in_flight = { - let mut pending = self.pending.lock().await; + let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner()); + if !self.is_alive() { + return Err(anyhow!("app-server connection closed")); + } pending.insert(id.clone(), tx); pending.len() }; + let _registration = PendingRequest { + pending: Arc::clone(&self.pending), + id: id.clone(), + }; // How many requests are queued on this socket when this one starts. A // rising number across successive reads is the signature of callers // outpacing the socket rather than any single request being slow. @@ -303,17 +323,17 @@ impl AppClient { ); let started = std::time::Instant::now(); - if let Err(e) = self.sink.lock().await.send(WsMessage::text(frame)).await { - self.pending.lock().await.remove(&id); - tracing::warn!( - target: "pocket_codex_codex::rpc", - "!! {method} id={id} send failed after {:?}: {e}", started.elapsed() - ); - return Err(anyhow!("sending request `{method}`: {e}")); - } - - match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { - Ok(Ok(result)) => { + // Bound the write and sink lock too: a half-open peer can stop reading + // before the request has even reached its response wait. + let exchange = async { + self.send_frame(frame) + .await + .with_context(|| format!("sending request `{method}`"))?; + rx.await + .map_err(|_| anyhow!("app-server connection closed"))? + }; + match tokio::time::timeout(timeout, exchange).await { + Ok(result) => { let elapsed = started.elapsed(); let ok = result.is_ok(); // Slow answers are the interesting ones; a server-side walk over @@ -331,20 +351,13 @@ impl AppClient { } result }, - Ok(Err(_)) => { - tracing::warn!( - target: "pocket_codex_codex::rpc", - "<- {method} id={id} cancelled after {:?} (socket closed)", started.elapsed() - ); - Err(anyhow!("request `{method}` cancelled")) - }, Err(_) => { - self.pending.lock().await.remove(&id); + close_connection(&self.closed, &self.pending); tracing::error!( target: "pocket_codex_codex::rpc", "<- {method} id={id} TIMED OUT after {:?}", started.elapsed() ); - Err(anyhow!("request `{method}` timed out")) + Err(anyhow!("request `{method}` timed out; app-server connection closed")) }, } } @@ -357,19 +370,66 @@ impl AppClient { params: Some(params), }; let frame = serde_json::to_string(¬e).context("serializing notification")?; - self.sink - .lock() - .await - .send(WsMessage::text(frame)) + self.send_frame(frame) .await - .map_err(|e| anyhow!("sending notification `{method}`: {e}")) + .with_context(|| format!("sending notification `{method}`")) + } + + async fn send_frame(&self, frame: String) -> Result<()> { + if !self.is_alive() { + return Err(anyhow!("app-server connection closed")); + } + let result = tokio::select! { + biased; + _ = self.closed.cancelled() => return Err(anyhow!("app-server connection closed")), + result = tokio::time::timeout(REQUEST_TIMEOUT, async { + self.sink.lock().await.send(WsMessage::text(frame)).await + }) => result, + }; + match result { + Ok(Ok(())) => Ok(()), + result => { + close_connection(&self.closed, &self.pending); + Err(anyhow!("app-server connection closed while sending: {result:?}")) + }, + } } } -async fn take_pending(pending: &Pending, id: &RequestId) -> Option>> { +fn take_pending(pending: &Pending, id: &RequestId) -> Option>> { let key = match id { RequestId::String(s) => s.clone(), RequestId::Number(n) => n.to_string(), }; - pending.lock().await.remove(&key) + pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&key) +} + +fn close_connection(closed: &CancellationToken, pending: &Pending) { + closed.cancel(); + for (_, tx) in pending.lock().unwrap_or_else(|e| e.into_inner()).drain() { + let _ = tx.send(Err(anyhow!("app-server connection closed"))); + } +} + +// Synchronous removal also runs when an outer timeout or an aborted caller +// drops the request future. No pending lock is held across an await. +struct PendingRequest { + pending: Pending, + id: String, +} + +impl Drop for PendingRequest { + fn drop(&mut self) { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.id); + } } + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/pocket-codex-codex/src/client_tests.rs b/crates/pocket-codex-codex/src/client_tests.rs new file mode 100644 index 0000000..094bf49 --- /dev/null +++ b/crates/pocket-codex-codex/src/client_tests.rs @@ -0,0 +1,156 @@ +use serde_json::json; +use tokio::net::TcpListener; +use tokio_tungstenite::{accept_async, WebSocketStream}; + +use super::*; + +async fn connection() -> (AppClient, mpsc::UnboundedReceiver, WebSocketStream) { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test operation"); + let url = format!("ws://{}", listener.local_addr().expect("test operation")); + let (client, server) = tokio::join!(AppClient::connect(&url), async { + accept_async(listener.accept().await.expect("test operation").0) + .await + .expect("test operation") + }); + let (client, inbound) = client.expect("test operation"); + (client, inbound, server) +} + +#[tokio::test] +async fn peer_close_marks_dead_and_rejects_future_requests() { + let (client, mut inbound, mut server) = connection().await; + server.close(None).await.expect("test operation"); + assert!(tokio::time::timeout(Duration::from_secs(1), inbound.recv()) + .await + .expect("test operation") + .is_none()); + assert!(!client.is_alive(), "a closed reader must never advertise a healthy socket"); + let error = client + .request("thread/list", json!({})) + .await + .expect_err("operation must fail"); + assert!(error.to_string().contains("connection closed"), "{error:#}"); +} + +#[tokio::test] +async fn cancelling_a_request_removes_its_pending_entry() { + let (client, _inbound, mut server) = connection().await; + let client = Arc::new(client); + let requesting = Arc::clone(&client); + let task = tokio::spawn(async move { requesting.request("thread/list", json!({})).await }); + server + .next() + .await + .expect("test operation") + .expect("test operation"); + task.abort(); + assert!(task.await.expect_err("operation must fail").is_cancelled()); + assert!( + client.pending.lock().expect("test operation").is_empty(), + "cancelled probes must not leak in-flight requests" + ); + assert!(client.is_alive(), "caller cancellation alone does not prove the socket died"); +} + +#[tokio::test] +async fn rpc_timeout_closes_even_a_peer_that_keeps_sending_pongs() { + let (client, mut inbound, mut server) = connection().await; + let peer = tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(10)); + loop { + tokio::select! { + frame = server.next() => match frame { + Some(Ok(WsMessage::Text(_))) | Some(Ok(WsMessage::Ping(_))) => {}, + _ => break, + }, + _ = tick.tick() => { + if server.send(WsMessage::Pong(Vec::new().into())).await.is_err() { + break; + } + } + } + } + }); + let (timed_out, other) = tokio::join!( + client.request_inner("thread/list", Some(json!({})), Duration::from_millis(100)), + client.request("thread/read", json!({"threadId": "other"})), + ); + assert!(timed_out + .expect_err("operation must fail") + .to_string() + .contains("timed out")); + assert!(other + .expect_err("operation must fail") + .to_string() + .contains("connection closed")); + assert!(!client.is_alive()); + assert!(tokio::time::timeout(Duration::from_secs(1), inbound.recv()) + .await + .expect("test operation") + .is_none()); + assert!(client.pending.lock().expect("test operation").is_empty()); + peer.abort(); +} + +#[tokio::test] +async fn request_deadline_includes_waiting_for_the_writer() { + let (client, _inbound, _server) = connection().await; + let _blocked_writer = client.sink.lock().await; + let result = tokio::time::timeout( + Duration::from_secs(1), + client.request_inner("thread/list", Some(json!({})), Duration::from_millis(50)), + ) + .await + .expect("test operation"); + assert!(result + .expect_err("operation must fail") + .to_string() + .contains("timed out")); + assert!(!client.is_alive()); + assert!(client.pending.lock().expect("test operation").is_empty()); +} + +#[tokio::test] +async fn rpc_errors_preserve_a_working_connection() { + let (client, _inbound, mut server) = connection().await; + let peer = tokio::spawn(async move { + for result in [ + json!({"error": {"code": -32602, "message": "invalid thread"}}), + json!({"result": {"data": []}}), + ] { + let frame = server + .next() + .await + .expect("test operation") + .expect("test operation") + .into_text() + .expect("test operation"); + let request: Value = serde_json::from_str(&frame).expect("test operation"); + let mut response = result; + response["id"] = request["id"].clone(); + server + .send(WsMessage::text(response.to_string())) + .await + .expect("test operation"); + } + server + }); + assert!(client + .request("thread/read", json!({})) + .await + .expect_err("operation must fail") + .to_string() + .contains("invalid thread")); + assert!(client.is_alive()); + assert_eq!( + client + .request("thread/list", json!({})) + .await + .expect("test operation")["data"], + json!([]) + ); + let _server = peer.await.expect("test operation"); + assert!(client.is_alive()); +} diff --git a/crates/pocket-codex-codex/src/readiness.rs b/crates/pocket-codex-codex/src/readiness.rs index fdeedd8..9a5634c 100644 --- a/crates/pocket-codex-codex/src/readiness.rs +++ b/crates/pocket-codex-codex/src/readiness.rs @@ -12,7 +12,8 @@ //! //! [`verify_ready`] closes that gap: after a spawn it polls the app-server's //! `/readyz` endpoint (the same one the health watchdog probes) until it -//! answers, the launch has provably failed, or the timeout elapses. On +//! answers, the launch has provably failed, or the timeout elapses. Reused +//! listeners must also answer `initialize` and `thread/list`. On //! failure it returns a [`StartupFailure`] carrying the tail of *this run's* //! log output (via [`crate::SpawnReport::log_offset`]) and whether that //! output points at an address-already-in-use bind error, so the CLI can @@ -43,7 +44,9 @@ use std::{ time::{Duration, Instant}, }; +use anyhow::Context as _; use pocket_codex_core::process::{pid_running, probe_host}; +use serde_json::json; use crate::process::{spawn, ws_host_port, SpawnOptions, SpawnReport}; @@ -99,6 +102,9 @@ const TAIL_MAX_LINES: usize = 20; /// picks it). #[derive(Debug)] pub struct StartupFailure { + /// Functional RPC probe failure when an adopted listener answered HTTP + /// but could not initialize and list a thread. + pub rpc_error: Option, /// The spawned process was observed dead while nothing served the listen /// address — it exited during startup (as opposed to still running but /// unresponsive). @@ -157,7 +163,9 @@ impl StartupFailure { impl fmt::Display for StartupFailure { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.process_exited { + if let Some(error) = &self.rpc_error { + write!(f, "codex app-server on {} failed its readiness probe: {error}", self.listen) + } else if self.process_exited { write!(f, "codex app-server exited during startup (listen {})", self.listen) } else { write!(f, "codex app-server did not become ready on {} in time", self.listen) @@ -173,9 +181,10 @@ impl std::error::Error for StartupFailure {} /// Polls `/readyz` on the listen address until it answers 2xx (`Ok`), the /// launch provably failed — child gone AND the bind error already in the log /// (`Err`, fast) — or `timeout` elapses (`Err`). A reused/adopted server -/// skips the process-death check — there is no fresh child to watch — but is -/// still probed, so adopting a wedged listener fails the launch instead of -/// publishing it. Unix-socket transports have no HTTP endpoint and are only +/// skips the process-death check — there is no fresh child to watch — but +/// must also answer initialized thread RPCs within the remaining budget, so +/// adopting a wedged listener fails the launch instead of publishing it. +/// Unix-socket transports have no HTTP endpoint and are only /// watched briefly for the fatal-bind-error signal. /// /// When [`crate::spawn`]'s own port wait already ran dry without seeing a @@ -194,6 +203,15 @@ pub fn verify_ready(report: &SpawnReport, timeout: Duration) -> Result<(), Start let mut observed_dead = false; loop { let down = match probe_readyz(&host, port) { + Probe::Ready if report.reused => { + let dial_host = probe_host(&host); + let authority = if dial_host.contains(':') { + format!("[{dial_host}]:{port}") + } else { + format!("{dial_host}:{port}") + }; + return verify_reused_rpc(report, &format!("ws://{authority}"), deadline); + }, Probe::Ready => return Ok(()), // Something is listening — a booting server; keep waiting. Probe::NotReady => false, @@ -220,6 +238,58 @@ pub fn verify_ready(report: &SpawnReport, timeout: Duration) -> Result<(), Start } } +/// Verify that an app-server handles initialized RPCs, within one overall +/// budget. Neither an HTTP health response nor `initialize` alone exercises +/// the thread request path. No model turn is started by this probe. +pub async fn probe_rpc(ws_url: &str, budget: Duration) -> anyhow::Result<()> { + let deadline = tokio::time::Instant::now() + budget; + let (client, _events) = + tokio::time::timeout_at(deadline, crate::client::AppClient::connect(ws_url)) + .await + .context("probe: websocket connect timed out")??; + tokio::time::timeout_at( + deadline, + client.request( + "initialize", + json!({ + "clientInfo": {"name": "pocket-codex", "version": env!("CARGO_PKG_VERSION")}, + "capabilities": {"experimentalApi": true} + }), + ), + ) + .await + .context("probe: initialize timed out")??; + tokio::time::timeout_at(deadline, client.request("thread/list", json!({"limit": 1}))) + .await + .context("probe: thread/list timed out")??; + Ok(()) +} + +fn verify_reused_rpc( + report: &SpawnReport, + ws_url: &str, + deadline: Instant, +) -> Result<(), StartupFailure> { + // verify_ready is synchronous and can be called from a Tokio runtime. + // A separate thread avoids nesting block_on inside that caller's runtime. + let result = thread::scope(|scope| { + scope + .spawn(|| -> anyhow::Result<()> { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(probe_rpc(ws_url, deadline.saturating_duration_since(Instant::now()))) + }) + .join() + .unwrap_or_else(|_| Err(anyhow::anyhow!("readiness probe thread panicked"))) + }); + result.map_err(|error| { + let mut failure = failure(report, false); + failure.rpc_error = Some(format!("{error:#}")); + failure + }) +} + /// Poll `/readyz` on `host:port` until it answers 2xx (`true`), or `timeout` /// elapses (`false`). /// @@ -349,6 +419,7 @@ pub(crate) fn bind_failure_logged(log_file: &Path, from_offset: u64) -> bool { fn failure(report: &SpawnReport, process_exited: bool) -> StartupFailure { let log_tail = read_log_tail(&report.info.log_file, report.log_offset); StartupFailure { + rpc_error: None, process_exited, port_in_use: mentions_addr_in_use(&log_tail), listen: report.info.listen.clone(), @@ -467,6 +538,10 @@ fn mentions_addr_in_use(lines: &[String]) -> bool { }) } +#[cfg(test)] +#[path = "readiness_rpc_tests.rs"] +mod rpc_tests; + #[cfg(test)] mod tests { use std::net::TcpListener; @@ -554,12 +629,12 @@ mod tests { } #[test] - fn verify_ready_passes_on_a_2xx_readyz() { + fn verify_ready_passes_on_a_fresh_2xx_readyz() { let port = fake_readyz("HTTP/1.1 200 OK"); let report = report( format!("ws://127.0.0.1:{port}"), std::process::id(), - true, + false, PathBuf::from("does-not-exist.log"), ); assert!(verify_ready(&report, Duration::from_secs(5)).is_ok()); @@ -702,6 +777,7 @@ mod tests { #[test] fn diagnosis_renders_log_location_tail_and_hint() { let failure = StartupFailure { + rpc_error: None, process_exited: true, port_in_use: true, listen: "ws://127.0.0.1:18080".to_string(), @@ -719,6 +795,7 @@ mod tests { #[test] fn diagnosis_without_a_port_conflict_stays_hint_free() { let failure = StartupFailure { + rpc_error: None, process_exited: false, port_in_use: false, listen: "ws://127.0.0.1:18080".to_string(), diff --git a/crates/pocket-codex-codex/src/readiness_rpc_tests.rs b/crates/pocket-codex-codex/src/readiness_rpc_tests.rs new file mode 100644 index 0000000..b70939c --- /dev/null +++ b/crates/pocket-codex-codex/src/readiness_rpc_tests.rs @@ -0,0 +1,95 @@ +use futures::{SinkExt, StreamExt}; +use pocket_codex_core::state::CodexProcessInfo; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +use super::*; + +async fn adopted_server(answer_list: bool, wildcard: bool) -> Result<(), StartupFailure> { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake server"); + let addr = listener.local_addr().expect("listen address"); + let peer = tokio::spawn(async move { + let (mut http, _) = listener.accept().await.expect("HTTP connection"); + let mut request = [0u8; 512]; + let received = http.read(&mut request).await.expect("read readyz"); + assert!(received > 0); + http.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .expect("readyz response"); + drop(http); + let mut ws = accept_async(listener.accept().await.expect("RPC connection").0) + .await + .expect("websocket handshake"); + for method in ["initialize", "thread/list"] { + let text = ws + .next() + .await + .expect("RPC frame") + .expect("valid frame") + .into_text() + .expect("text frame"); + let request: Value = serde_json::from_str(&text).expect("JSON request"); + assert_eq!(request["method"], method); + if method == "thread/list" && !answer_list { + std::future::pending::<()>().await; + } + ws.send(Message::text( + json!({"id": request["id"], "result": {"data": []}}).to_string(), + )) + .await + .expect("RPC response"); + } + ws + }); + let report = SpawnReport { + info: CodexProcessInfo { + pid: std::process::id(), + listen: if wildcard { + format!("ws://0.0.0.0:{}", addr.port()) + } else { + format!("ws://{addr}") + }, + log_file: PathBuf::from("no-child-log"), + started_at: String::new(), + }, + reused: true, + log_offset: 0, + listener_confirmed: true, + }; + let budget = if answer_list { Duration::from_secs(3) } else { Duration::from_millis(200) }; + let result = tokio::task::spawn_blocking(move || verify_ready(&report, budget)) + .await + .expect("readiness task"); + if answer_list { + peer.await.expect("fake server completed"); + } else { + peer.abort(); + } + result +} + +#[tokio::test] +async fn adopted_listener_must_answer_a_real_thread_request() { + let failure = adopted_server(false, false) + .await + .expect_err("wedged listener must not be adopted"); + assert!(failure.to_string().contains("thread/list timed out")); + assert!(!failure.process_exited); +} + +#[tokio::test] +async fn functional_adopted_listener_is_preserved() { + adopted_server(true, false) + .await + .expect("healthy listener should be reused"); +} + +#[tokio::test] +async fn adopted_wildcard_listener_is_probed_over_loopback() { + adopted_server(true, true) + .await + .expect("wildcard listener should be reused"); +} diff --git a/design/HANDOFF-thread-list-stall.md b/design/HANDOFF-thread-list-stall.md index 8a9fa5e..617546f 100644 --- a/design/HANDOFF-thread-list-stall.md +++ b/design/HANDOFF-thread-list-stall.md @@ -1,8 +1,127 @@ # Handoff: `thread/list` 无响应导致连接反复重建 -**日期**:2026-09-01 -**状态**:根因未确认,卡点已收窄到被复用的 app-server 进程 -**分支**:`chore/codex-upstream-sync`(有大量未提交改动,见文末) +**更新日期**:2026-09-05(下方保留 09-01 的故障现场记录) +**状态**:已修复可确定复现的连接状态、复用探测与分页缺陷;原 Mac 进程内部阻塞的根因仍未确认 +**分支**:`chore/codex-upstream-sync`,交接时的分页与日志改动已在 `0a52779` 提交 + +## 本次续接结果(2026-09-05) + +### 已确认并修复 + +- `AppClient` 原先仅在 ping 看门狗判死时清除健康标志。正常关闭、写入失败、 + RPC 超时后仍可能被 bridge 当作在线;请求超时也没有覆盖写锁等待。 + 现在统一关闭状态、终止事件流并唤醒全部等待请求,取消请求时清理 pending。 + 普通业务 RPC 错误不关闭连接。见 `crates/pocket-codex-codex/src/client.rs:253`、 + `crates/pocket-codex-codex/src/client.rs:288`、 + `crates/pocket-codex-codex/src/client_tests.rs:1`。 +- Flutter 的内容加载函数会保留旧数据并吞掉异常;自动重连因此可能在新连接的 + 第一个 `thread/list` 已失败后仍宣告就绪。现在流关闭立即发布离线状态, + 恢复完成前再次检查连接。已先用测试复现误报,再验证修复。 + 见 `apps/flutter/lib/src/screens/app_session_screen.dart:1178`、 + `apps/flutter/lib/src/screens/app_session_screen.dart:2778`。 +- 复用旧进程必须在一个总超时内通过 WebSocket、`initialize`、`thread/list(limit=1)`; + HTTP 200 和 pong 不能单独证明请求处理正常。`0.0.0.0` 监听通过回环地址探测。 + 运行中 CLI/桌面托管进程的既有看门狗也使用功能探测,连续三次失败进入既有 + 重启流程。没有启动模型 turn;启动时拒绝复用不健康的外部进程,仍保留不擅自 + 杀掉该进程的原有边界。见 `crates/pocket-codex-codex/src/readiness.rs:244`、 + `crates/pocket-codex-codex/src/readiness_rpc_tests.rs:1`、 + `crates/pocket-codex-bridge/src/engine/serve.rs:1442`。 +- 分页历史的 `has_older` 以 item 分页游标为准。原逻辑在只有一个长 turn 时可能 + 错误隐藏更早的 item;同时支持上游 user message 的 `content[]` 文本摘要。 + 两项均有失败前后的回归证据。见 + `crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs:1`。 +- 账号中继凭证续期原先先清空缓存,backend 失联后连尚未过期的凭证也无法复用。 + 现在续期成功后才替换缓存,网络请求与缓存读取使用不同锁;后台续期卡住时, + 同一账号仍能立即使用有效凭证,过期与账号切换仍拒绝复用。5 个回归测试覆盖 + 失联、请求挂起、过期、成功续期与途中切换账号。见 + `crates/pocket-codex-bridge/src/engine/account_relay_tests.rs:1`。 + +### Windows UI 补充修复 + +- 会话正文接入中键自动滚动:点按后上下移动鼠标控制方向和速度;按住中键移动 + 也可滚动,松开停止。再次点击、Esc、滚轮、离开正文或窗口失焦都会停止。 + 沿用现有 ScrollController 和滚动通知,较早历史加载及阅读位置追踪仍生效。 + 见 `apps/flutter/lib/src/widgets/middle_click_scroll.dart:13`。 +- 分页游标不再把最后点击的 tick 持续当作悬停位置;离开游标与实际预览卡片后 + 恢复宽度,保留可视区的位置高亮。展开区域中的透明空白也会结束悬停。 + 见 `apps/flutter/lib/src/widgets/turn_minimap.dart:325`。 +- 两个游标缺陷先由回归测试确认失败,修复后 19 项游标测试通过;另有 7 项 + 中键行为测试和 1 项真实会话页面/虚拟列表测试,覆盖方向、停止条件与边界。 + Windows 正式 Release 构建成功,启动后已显示真实账号的会话正文。自动鼠标 + 操作遇到窗口正在被用户操作的提示,未继续争用输入;交互行为由上述测试验证。 + +### 真实环境证据与限制 + +- Windows 本机使用固定子模块 `05daa82d` 编译出的嵌入式 app-server,复制当前 + Codex 的 185 个会话(168 legacy、17 paginated)及两个 SQLite 数据库。 + SQLite 使用在线 backup;副本中的 rollout 路径已指向副本。未复制登录凭据, + 未向模型发送 turn,未修改真实会话库。 +- 第一轮 5 次新连接、15 次 `thread/list`、45 次 metadata read 全部成功; + 第二轮加入 resume、legacy 正文、paginated turns/items 分页也成功。 + 因此本机新进程加真实数据副本**没有复现原始持续卡死**,不能据此确认 Mac + 长期存活进程内部阻塞的原因,更不能声称原故障已得到原位验证。 +- 可重跑的独立诊断程序: + `crates/pocket-codex-codex/examples/app_server_probe.rs:1`。 + 使用 `--features embedded-codex`、`--embedded` 启动固定版本;`--resume` 会更新 + 被恢复会话的元数据,应搭配独立 `CODEX_HOME` 副本。 +- `lb7666.top` 中继经 SSH 只读检查仍正常;本次没有部署或重启生产中继。 + UI 验收使用 WSL pb-mapper 0.5.0 临时中继与 Windows 本地发布端。 +- 真实 UI 回归入口:`apps/flutter/integration_test/history_recovery_test.dart:1`。 + 配置需给出一个超过 100 items 的 paginated 会话和一个 legacy 会话,覆盖反复 + list、打开 paginated → legacy → paginated、滚动/点击加载更早历史。 + 宿主机原生 Windows Profile 实测依次渲染 **100、82、100 items**,随后加载 + **41 个更早 items**,全程保持连接,测试通过。临时中继还需发布对应的真实 + `meta` 服务,否则可选配置的订阅等待会推迟正文显示。 + 测试入口只挂会话页面,不初始化正式入口的系统托盘,也不套用完整应用主题; + 它不能替代 `main.dart` + `pubspec-desktop.yaml` 的完整桌面启动验收。 +- 随后按桌面 manifest 构建正式 Windows Release:产物包含 Figtree、Geist Mono、 + Noto Sans SC(17,772,300 bytes)及 Windows 托盘 ICO。正式界面明亮/暗黑主题 + 切换正常,验收后恢复原来的明亮设置;关闭主窗口后进程保持运行,重新启动 + 可唤回同一窗口及 PID。 +- 正式账号模式的冷启动另遇到 `/auth/refresh` 的 HTTPS 证书过期,发生在 + 获取中继凭证之前,不能归为 `thread/list` 或 pb-mapper 数据通道故障。 + SSH 只读核验:backend `:8443` 使用的独立 PEM 在 **2026-09-02** 到期, + Caddy `:443` 的证书已续到 **2026-10-31**;两者没有同步。 + 已将 backend 的副本更新为 Caddy 当前有效证书,保持 `pcx:pcx`、0640, + 并重启账号服务;启用默认 TLS 校验的 `/healthz` 验证通过。原证书备份于 + 服务器 `/etc/pocket-codex/tls-backup-20260905/`。未重启生产中继。 + 自动证书同步任务尚未部署,后续续签仍需同步此独立副本。 +- 包含缓存修复的正式 Windows Release 重编成功并启动。真实账号日志确认 + 已直连 relay、`thread/resume`(2.13s)与 `thread/read`(2.75s)成功。 + Windows 首次网络访问的防火墙弹窗由用户自行处理;后续已观察到正式界面的 + 真实会话正文,亮暗主题及关闭到托盘/重新唤回的验证见上文。 +- **当前架构边界**:自建 relay 模式直接使用 relay + key;账号模式的 HTTPS + 负责登录、领取与续期凭证,会话数据直接走 pb-mapper。App 凭证缓存仍只在 + 内存,冷启动仍需 backend;backend 发放的凭证 TTL 为 24 小时,到期会被 + relay 拒绝并断开隧道。因此当前实现是“backend 不在数据路径上”,并非 + “登录一次后永久不依赖 backend”。本次缓存修复没有改变凭证 TTL 或信任机制。 + +### 验证记录 + +- WSL:`cargo clippy --workspace --all-targets --locked -- -D warnings` 通过; + `cargo test --workspace --locked` **289 passed,6 ignored**(已有手动/在线测试)。 +- Windows:`cargo check -p pocket_codex_bridge --locked` 通过,确实编译嵌入式路径; + Flutter 3.44.0 Windows Release 构建成功。 +- Flutter:pub get、全量 dart format、analyze 通过;单元/组件测试 + **425 passed,3 skipped**(包含后续中键滚动与游标修复)。 +- Rust 全部第一方包格式检查通过,Windows checkout 使用 + `-- --config newline_style=Auto` 保留已有 CRLF;默认 Unix 换行检查会对未改动文件 + 报整文件换行差异,没有批量改写这些文件,也没有格式化 `deps/`。 +- 原生 UI 集成测试:`flutter drive --driver=test_driver/integration_test.dart + --target=integration_test/history_recovery_test.dart -d windows --profile + --dart-define-from-file=<隔离配置文件>` 通过。驱动使用实时帧,并显示/聚焦 Windows + 窗口,避免逐帧等待因窗口在后台而挂起。配置键见测试文件顶部;不要选子代理或 + 归档会话,它们会被上游默认列表过滤。 +- 本机原始输出保存在忽略目录 `target/handoff-validation/`,包含真实会话副本, + **不可提交或上传**。 + +### 如原症状再次出现 + +在故障原进程上先运行独立 probe(不加 `--embedded`),记录 initialize/list 是否 +分别成功及耗时,再对同一进程抓栈。保留原进程证据后才做旧进程/新进程对照; +09-01 的 PID 96166 只是历史记录,不应按该 PID 直接杀当前机器的进程。 + +以下为历史现场,时间和环境均指 2026-09-01 的 Mac。 --- @@ -71,8 +190,8 @@ forward finish! we send 573 bytes, detail:server->client - CPU **0%**,RSS 约 430–605MB,已存活 **5.5 小时** - WebSocket 握手仍返回 `101 Switching Protocols`(传输层正常) -即:**进程收下了请求却不处理**。这解释了为什么重启 app 无效 —— 问题跟着这个被复用的 -进程走,而不是跟着 app。 +这些采样支持被复用进程可能不再处理请求的假设,但没有记录具体请求进入处理器的 +证据;仅凭空闲线程栈不能确认阻塞位置。 --- @@ -94,22 +213,18 @@ app-server 会被无条件复用,且每次启动都复用同一个。 ## 四、建议的下一步(按顺序) -1. **排除实验(最优先)**:`kill 96166`,让 app 拉起一个干净的 app-server 再复现。 +1. **历史排除实验建议**:先确认仍是当时的故障进程,再停止它,让 app 拉起干净进程复现。 - 恢复 → 确认是长期存活进程劣化。接着查:那个进程为何僵(对它做 `sample` 时抓 `thread/list` 处理路径)、以及 adopt 是否该加健康探测 - 不恢复 → 卡点在客户端请求路径,回到 `client.rs` 与隧道层继续查 -2. 若需在服务端复现,`thread/list` 的处理入口在 - `deps/codex/codex-rs/app-server/src/request_processors/thread_processor.rs`, - 注意它会取 `acquire_thread_list_state_permit()` —— 那是一个全局 - **`Semaphore::new(1)`**(`app-server/src/message_processor.rs:382`)。 - 若某个先前的持有者未释放,`thread/list` 会永久阻塞,与观察到的现象吻合。 - **这条尚未验证**,是最值得先查的一条。 +2. **09-05 更正**:固定子模块中 `thread/list` 不直接获取此前描述的全局 + `thread_list_state` permit,不能把该信号量作为已定位的根因。 + 应沿实际 request dispatcher 和 `thread_list` 调用链结合现场栈继续排查。 -3. 顺带一个独立缺陷(与本问题无关,但值得修):socket 健康标志 - `crates/pocket-codex-codex/src/client.rs:242` 在整个 bridge 里**零调用者**。 - 看门狗(15 秒 ping / 20 秒判死,`client.rs:196-216`)判定 socket 已死,结论却从未 - 传到 UI,所以状态栏在连接已死时仍显示"就绪"。 +3. **09-05 更正**:`AppClient::is_alive()` 在 bridge 有调用者;真正可复现的问题 + 是多个关闭路径没有更新标志,以及 UI 重连过程中吞掉加载失败后仍标为就绪。 + 修复和测试见本文顶部。 --- @@ -169,10 +284,10 @@ app-server 会被无条件复用,且每次启动都复用同一个。 --- -## 七、未提交的改动(21 个文件,全部门禁已过) +## 七、历史交接改动(已提交到 `0a52779`) 分支 `chore/codex-upstream-sync`,已有提交 `3106f22`(codex 子模块升级 +318 commits, -已推送)。工作区还有未提交内容,分三部分: +已推送)。当时的工作区内容后续已提交到 `0a52779`,分三部分: **1. 分页历史加载(功能,本次主要工作)** - `crates/pocket-codex-bridge/src/engine/app_session.rs`:按 `historyMode` 分流, diff --git a/init-submodules.bat b/init-submodules.bat new file mode 100644 index 0000000..85feb40 --- /dev/null +++ b/init-submodules.bat @@ -0,0 +1,11 @@ +@echo off +chcp 65001 >nul +cd /d "%~dp0" +where pwsh >nul 2>&1 +if %ERRORLEVEL%==0 ( + pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0init-submodules.ps1" +) else ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0init-submodules.ps1" +) +echo. +pause diff --git a/init-submodules.ps1 b/init-submodules.ps1 new file mode 100644 index 0000000..dea03a6 --- /dev/null +++ b/init-submodules.ps1 @@ -0,0 +1,74 @@ +$ErrorActionPreference = 'Continue' +Set-Location -LiteralPath $PSScriptRoot + +Write-Host "" +Write-Host "=== Pocket-Codex 子模块初始化 ===" -ForegroundColor Cyan +Write-Host "仓库目录: $PSScriptRoot" +Write-Host "" + +# Check Git before modifying the local repository configuration. +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { + Write-Host "找不到 git,请先安装 Git for Windows: https://git-scm.com/download/win" -ForegroundColor Red + exit 1 +} +git --version + +# Codex requires long-path support on Windows. +Write-Host "" +Write-Host "--> 为本仓库开启长路径支持" -ForegroundColor DarkGray +git config core.longpaths true + +# First use the SSH URL recorded in .gitmodules. +Write-Host "" +Write-Host "--> 尝试 1/2: 使用 SSH 地址拉取子模块" -ForegroundColor Yellow +Write-Host " (deps/codex 仓库较大, 可能要几分钟, 请耐心等待)" -ForegroundColor DarkGray +Write-Host "" +git submodule sync --recursive +git submodule update --init --recursive --progress +$ok = ($LASTEXITCODE -eq 0) + +# Retry over HTTPS if SSH is unavailable. +if (-not $ok) { + Write-Host "" + Write-Host "--> SSH 方式失败, 尝试 2/2: 改用 HTTPS 重试" -ForegroundColor Yellow + Write-Host "" + git submodule sync --recursive + git -c 'url.https://github.com/.insteadOf=git@github.com:' submodule update --init --recursive --progress + $ok = ($LASTEXITCODE -eq 0) + if ($ok) { + Write-Host "" + Write-Host "本次拉取已使用 HTTPS,未修改仓库的 SSH 地址。" -ForegroundColor DarkGray + } +} + +# Codex is the only submodule; pb-mapper now comes from the registry. +Write-Host "" +Write-Host "=== 结果 ===" -ForegroundColor Cyan +$allGood = $ok +foreach ($d in 'codex') { + $p = Join-Path $PSScriptRoot "deps\$d" + $n = 0 + if (Test-Path -LiteralPath $p) { + $n = @(Get-ChildItem -LiteralPath $p -Force -ErrorAction SilentlyContinue).Count + } + if ($n -gt 0) { + Write-Host (" [ OK ] deps/{0} ({1} 项)" -f $d, $n) -ForegroundColor Green + } else { + Write-Host (" [ 空 ] deps/{0}" -f $d) -ForegroundColor Red + $allGood = $false + } +} + +Write-Host "" +Write-Host "--- git submodule status ---" -ForegroundColor DarkGray +git submodule status --recursive + +Write-Host "" +if ($allGood) { + Write-Host "子模块已就绪。下一步可以跑:" -ForegroundColor Green + Write-Host " cargo build -p pocket_codex_bridge" -ForegroundColor Green +} else { + Write-Host "子模块初始化失败。请查看上面的完整错误输出。" -ForegroundColor Red +} +Write-Host "" +if (-not $allGood) { exit 1 } From 7c483a77f64158fa8357604427c864b80c2fbfef Mon Sep 17 00:00:00 2001 From: LB7666 Date: Sun, 6 Sep 2026 00:49:02 +0800 Subject: [PATCH 4/4] fix(app): address pagination and runtime review findings --- apps/flutter/lib/src/providers.dart | 21 +-- apps/flutter/lib/src/rust/api/bridge.dart | 3 +- .../screens/app_session/history_merge.dart | 83 +++++++++ .../lib/src/screens/app_session_screen.dart | 157 ++++++++++------- apps/flutter/test/fake_bridge_api.dart | 8 + apps/flutter/test/history_merge_test.dart | 71 ++++++++ .../test/screens/app_session_test.dart | 143 ++++++++++++++- crates/pocket-codex-bridge/src/api/bridge.rs | 8 +- .../src/engine/app_session.rs | 70 ++++---- .../engine/app_session_pagination_tests.rs | 163 ++++++++++++++++-- .../pocket-codex-bridge/src/engine/logging.rs | 128 ++++++++++---- .../src/engine/logging_tests.rs | 95 ++++++++++ .../pocket-codex-bridge/src/frb_generated.rs | 12 +- design/HANDOFF-thread-list-stall.md | 27 +++ 14 files changed, 821 insertions(+), 168 deletions(-) create mode 100644 apps/flutter/lib/src/screens/app_session/history_merge.dart create mode 100644 apps/flutter/test/history_merge_test.dart create mode 100644 crates/pocket-codex-bridge/src/engine/logging_tests.rs diff --git a/apps/flutter/lib/src/providers.dart b/apps/flutter/lib/src/providers.dart index 337caac..29e3d7e 100644 --- a/apps/flutter/lib/src/providers.dart +++ b/apps/flutter/lib/src/providers.dart @@ -169,23 +169,8 @@ final codexSetupStatusProvider = FutureProvider((ref) async { /// (a still-running host re-registers, and hiding it would strand a live entry). final pendingRemovalProvider = StateProvider>((ref) => {}); -/// A conversation's one-line summary, fetched on demand and cached. -/// -/// Keyed by `serviceKey|threadId`. Deliberately NOT autoDispose: a row scrolled -/// out and back must not re-read the thread, and the whole point of fetching -/// lazily is to pay for each conversation at most once per session. The read is -/// expensive (a full thread history server-side), so the activity view asks for -/// these only for rows it actually renders. -/// -/// A failure yields null rather than an error state: a missing gist is a row -/// with one less line, not something to interrupt the list for. -/// Caps how many summary fetches run at once. -/// -/// Each bridge call occupies one worker thread of a pool only as wide as the CPU -/// count, and it BLOCKS that thread for the whole round trip. A sidebar with -/// dozens of rows asks for every gist concurrently, which saturated the pool and -/// left nothing for the call that actually matters — opening a conversation — -/// until the summaries drained. Gists are decoration; they queue. +// Bound background RPC traffic. The async summary bridge uses a separate +// blocking executor, so these requests consume no interactive FRB worker slots. final _summaryGate = _Gate(3); /// A counting semaphore: [acquire] resolves once fewer than [limit] holders are @@ -214,6 +199,8 @@ class _Gate { } } +/// A conversation's one-line summary, cached by `serviceKey|threadId`. +/// Kept when a row leaves the viewport; failures leave the summary absent. final threadSummaryProvider = FutureProvider.family(( ref, key, diff --git a/apps/flutter/lib/src/rust/api/bridge.dart b/apps/flutter/lib/src/rust/api/bridge.dart index 3fcb0bf..0e69779 100644 --- a/apps/flutter/lib/src/rust/api/bridge.dart +++ b/apps/flutter/lib/src/rust/api/bridge.dart @@ -357,7 +357,8 @@ Future appThreadRead({ /// One page further back through a paginated thread's history. /// -/// Returns an empty page when the thread reads whole or is already at its start. +/// Returns an empty page when the thread reads whole or is already at its +/// start. Future appThreadOlderPage({ required String serviceKey, required String threadId, diff --git a/apps/flutter/lib/src/screens/app_session/history_merge.dart b/apps/flutter/lib/src/screens/app_session/history_merge.dart new file mode 100644 index 0000000..81fa127 --- /dev/null +++ b/apps/flutter/lib/src/screens/app_session/history_merge.dart @@ -0,0 +1,83 @@ +import 'package:pocket_codex/src/screens/app_session/transcript_model.dart'; + +typedef _TurnKey = ({String turn, String? item}); + +/// Merge a chronological page with previously loaded, possibly disjoint turns. +/// Shared item IDs anchor overlapping pages without replacing live snapshots. +List mergeHistoryItems( + List current, + List incoming, { + required Iterable turnOrder, + required bool olderPage, +}) { + Map<_TurnKey, List> group(List items) { + final groups = <_TurnKey, List>{}; + for (final item in items) { + // Optimistic user rows may not have a server turn ID yet. Keep each in + // place rather than grouping unrelated anonymous rows into one turn. + final key = ( + turn: item.turnId, + item: item.turnId.isEmpty ? item.id : null, + ); + (groups[key] ??= []).add(item); + } + return groups; + } + + final old = group(current); + final pages = group(incoming); + final knownTurns = turnOrder + .map((turn) => (turn: turn, item: null as String?)) + .toSet(); + final existingOrder = old.keys.toList(); + final firstKnown = existingOrder.indexWhere(knownTurns.contains); + final order = <_TurnKey>{ + if (firstKnown >= 0) ...existingOrder.take(firstKnown), + ...knownTurns, + ...existingOrder, + }.toList(); + final missing = pages.keys.where((id) => !order.contains(id)).toList(); + order.insertAll(olderPage ? 0 : order.length, missing); + return [ + for (final turn in order) + ..._mergeTurn(old[turn] ?? [], pages[turn] ?? [], olderPage), + ]; +} + +List _mergeTurn( + List current, + List incoming, + bool olderPage, +) { + final positions = {for (var i = 0; i < current.length; i++) current[i].id: i}; + final seen = {}; + final out = []; + final pending = []; + var next = 0; + var anchored = false; + for (final item in incoming) { + if (!seen.add(item.id)) continue; + final at = positions[item.id]; + if (at == null) { + pending.add(item); + } else if (at >= next) { + out + ..addAll(current.getRange(next, at)) + ..addAll(pending) + ..add(current[at]); + pending.clear(); + next = at + 1; + anchored = true; + } + } + if (anchored || olderPage) { + out + ..addAll(pending) + ..addAll(current.skip(next)); + } else { + out + ..addAll(current) + ..addAll(pending); + } + return out; +} diff --git a/apps/flutter/lib/src/screens/app_session_screen.dart b/apps/flutter/lib/src/screens/app_session_screen.dart index 0f4157f..a413082 100644 --- a/apps/flutter/lib/src/screens/app_session_screen.dart +++ b/apps/flutter/lib/src/screens/app_session_screen.dart @@ -7,6 +7,7 @@ import 'package:file_selector/file_selector.dart' show openFiles; import 'package:flutter/foundation.dart' show listEquals, defaultTargetPlatform, TargetPlatform, kIsWeb; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:pocket_codex/l10n/gen/app_localizations.dart'; import 'package:pocket_codex/src/app_modes.dart'; import 'package:pocket_codex/src/attachment_refs.dart'; @@ -31,6 +32,7 @@ import 'package:pocket_codex/src/service_key.dart'; import 'package:pocket_codex/src/screens/app_session/activity_cards.dart'; import 'package:pocket_codex/src/screens/app_session/composer_cards.dart'; import 'package:pocket_codex/src/screens/app_session/transcript_model.dart'; +import 'package:pocket_codex/src/screens/app_session/history_merge.dart'; import 'package:pocket_codex/src/screens/app_session/transcript_view.dart'; import 'package:pocket_codex/src/theme.dart'; import 'package:pocket_codex/src/ui_prefs.dart'; @@ -402,6 +404,8 @@ class _AppSessionState extends ConsumerState // True while an older page (or a single turn's items) is in flight, so a // scroll frame can't queue the same fetch twice. bool _loadingOlder = false; + Future? _historyLoad; + int _historyGeneration = 0; // True while `_scrollToEnd(force: true)` is re-jumping to the bottom. Those // jumps fire scroll events from positions that can look like the top of the // list, which would fetch older history nobody asked for. @@ -968,6 +972,8 @@ class _AppSessionState extends ConsumerState _turnSummaries = const []; _hasOlder = false; _loadingOlder = false; + _historyLoad = null; + _historyGeneration++; _approvals.clear(); _ctx = null; _diff = null; @@ -1086,9 +1092,10 @@ class _AppSessionState extends ConsumerState // which is what its ticks are numbered in. if (_listCtl.isAttached) _visibleRows.value = _visibleRowRange(); // Reading back past the top of what's loaded fetches the previous page. - // Suppressed while settling to the bottom, whose repeated jumps generate - // scroll frames that would otherwise read as reaching the top on open. + // Only user scrolling triggers this. An indexed rail jump must not remove + // the older-page header halfway through its animation and shift the target. if (!_settlingToEnd && + _scroll.position.userScrollDirection != ScrollDirection.idle && _hasOlder && !_loadingOlder && _scroll.position.pixels <= _scroll.position.minScrollExtent + 200) { @@ -1227,18 +1234,19 @@ class _AppSessionState extends ConsumerState /// Splice [items] into the transcript by turn order, skipping ids already /// present, and rebuild the id→index map. /// - /// Older history arrives as a block that belongs before what's shown, and a - /// jumped-to turn lands wherever its turn sits — either way every existing - /// index shifts, so [_itemIndex] is rebuilt rather than patched. + /// Both sequential pages and jumped-to turns use the skeleton's order. void _spliceTranscriptItems(List items, {required bool atStart}) { final known = _items.map((i) => i.id).toSet(); final fresh = []; for (final item in items) { - if (item.id.isEmpty || known.contains(item.id)) continue; + if (item.id.isEmpty) continue; if (item.itemType == 'userMessage' && isContextFragment(item.text)) { continue; } - known.add(item.id); + if (known.contains(item.id)) { + fresh.add(_items[_itemIndex[item.id]!]); + continue; + } fresh.add( TranscriptItem( id: item.id, @@ -1254,25 +1262,15 @@ class _AppSessionState extends ConsumerState ); } if (fresh.isEmpty) return; - if (atStart) { - _items.insertAll(0, fresh); - } else { - // Land the block after the last item of the newest earlier turn, so a - // turn fetched out of order still reads in conversation order. - final order = _turnSummaries.map((t) => t.turnId).toList(); - final at = order.indexOf(fresh.first.turnId); - var insertAt = _items.length; - if (at >= 0) { - for (var i = 0; i < _items.length; i++) { - final pos = order.indexOf(_items[i].turnId); - if (pos >= 0 && pos > at) { - insertAt = i; - break; - } - } - } - _items.insertAll(insertAt, fresh); - } + final merged = mergeHistoryItems( + _items, + fresh, + turnOrder: _turnSummaries.map((t) => t.turnId), + olderPage: atStart, + ); + _items + ..clear() + ..addAll(merged); _itemIndex.clear(); for (var i = 0; i < _items.length; i++) { _itemIndex[_items[i].id] = i; @@ -1283,51 +1281,63 @@ class _AppSessionState extends ConsumerState /// position: the list corrects its own offset when content is prepended. Future _loadOlder() async { if (_loadingOlder || !_hasOlder || _threadId == null) return; - final tid = _threadId!; - setState(() => _loadingOlder = true); - try { - final page = await ref - .read(bridgeApiProvider) - .appThreadOlderPage(widget.serviceKey, tid); - if (!mounted || _threadId != tid) return; - setState(() { - _spliceTranscriptItems(page.items, atStart: true); - _hasOlder = page.hasOlder; - _markTurnsLoaded(page.items); - _loadingOlder = false; - }); - } catch (_) { - // Older history is an enhancement — a failure leaves the transcript as - // it is, and scrolling up again retries. - if (mounted) setState(() => _loadingOlder = false); - } + await _startHistoryLoad(); } /// Fetch one turn's items, for jumping to a turn not yet scrolled back to. Future _loadTurn(String turnId) async { - if (_loadingOlder || turnId.isEmpty || _threadId == null) return; - if (_turnSummaries.any((t) => t.turnId == turnId && t.loaded)) return; + final generation = _historyGeneration; + while (_historyLoad != null) { + await _historyLoad; + if (!mounted || _historyGeneration != generation) return; + } + if (turnId.isEmpty || _threadId == null) return; + // A tail-only page may have marked the turn loaded without its user row. + if (_items.any((item) => item.turnId == turnId && item.isUser)) return; + await _startHistoryLoad(turnId: turnId); + } + + Future _startHistoryLoad({String? turnId}) { final tid = _threadId!; setState(() => _loadingOlder = true); + return _historyLoad = _fetchHistory(tid, _historyGeneration, turnId); + } + + Future _fetchHistory(String tid, int generation, String? turnId) async { + bool current() => mounted && _historyGeneration == generation; try { - final items = await ref - .read(bridgeApiProvider) - .appThreadTurnItems(widget.serviceKey, tid, turnId); - if (!mounted || _threadId != tid) return; + final api = ref.read(bridgeApiProvider); + final page = turnId == null + ? await api.appThreadOlderPage(widget.serviceKey, tid) + : OlderPage( + items: await api.appThreadTurnItems( + widget.serviceKey, + tid, + turnId, + ), + hasOlder: _hasOlder, + ); + if (!current()) return; setState(() { - _spliceTranscriptItems(items, atStart: false); - _markTurnsLoaded(items); - _loadingOlder = false; + _spliceTranscriptItems(page.items, atStart: turnId == null); + if (turnId == null) _hasOlder = page.hasOlder; + _markTurnsLoaded(); }); } catch (_) { - if (mounted) setState(() => _loadingOlder = false); + // Keep the existing transcript and let a later navigation retry. + } finally { + if (current()) { + setState(() { + _loadingOlder = false; + _historyLoad = null; + }); + } } } - /// Mark every turn these items belong to as loaded, so the rail stops - /// treating it as a turn that still needs fetching. - void _markTurnsLoaded(List items) { - final arrived = items.map((i) => i.turnId).toSet(); + /// A turn is navigable once its opening user row is present. + void _markTurnsLoaded() { + final arrived = _items.where((i) => i.isUser).map((i) => i.turnId).toSet(); if (arrived.isEmpty) return; _turnSummaries = [ for (final turn in _turnSummaries) @@ -1390,6 +1400,8 @@ class _AppSessionState extends ConsumerState _turnSummaries = history.turns; _hasOlder = history.hasOlder; _loadingOlder = false; + _historyLoad = null; + _historyGeneration++; // Restore the "thinking" state if a turn was still running when we // left: live events (delivered after resume) will finish rendering it. _streaming = history.running; @@ -2977,6 +2989,12 @@ class _AppSessionState extends ConsumerState ), ); } + final snapshotIds = _turnSummaries.map((turn) => turn.turnId).toSet(); + out.addAll( + _turnMinimapItemsFromRows(rows).where( + (turn) => turn.turnId.isEmpty || !snapshotIds.contains(turn.turnId), + ), + ); return out; } @@ -3030,12 +3048,15 @@ class _AppSessionState extends ConsumerState /// Jump to a turn the rail selected, fetching it first when the transcript /// hasn't loaded it yet. Future _selectTurn(TurnMinimapItem item) async { + final generation = _historyGeneration; if (item.rowIndex >= 0) { _scrollToRow(item.rowIndex); return; } await _loadTurn(item.turnId); - if (!mounted) return; + // Let the virtual list lay out inserted rows before navigating by index. + await WidgetsBinding.instance.endOfFrame; + if (!mounted || _historyGeneration != generation) return; // Its row exists now that its items are in; re-derive to find where. final row = _turnMinimapItems( _rows, @@ -3064,11 +3085,16 @@ class _AppSessionState extends ConsumerState // Neither half has anything to show — an empty card that only occludes the // conversation. The tick stays; it just has no preview. if (user.isEmpty && reply == null) { - out.add(TurnMinimapItem(rowIndex: i, userText: '')); + out.add(TurnMinimapItem(rowIndex: i, turnId: row.turnId, userText: '')); continue; } out.add( - TurnMinimapItem(rowIndex: i, userText: user, assistantText: reply), + TurnMinimapItem( + rowIndex: i, + turnId: row.turnId, + userText: user, + assistantText: reply, + ), ); } return out; @@ -3112,9 +3138,12 @@ class _AppSessionState extends ConsumerState /// conversation gets its rail immediately instead of only after enough of it /// has been scrolled back into memory. Falls back to the loaded user messages /// for a thread that arrived whole. - int get _turnCount => _turnSummaries.isNotEmpty - ? _turnSummaries.length - : _items.where((i) => i.isUser).length; + int get _turnCount { + if (_turnSummaries.isEmpty) return _items.where((i) => i.isUser).length; + final snapshotIds = _turnSummaries.map((turn) => turn.turnId).toSet(); + return snapshotIds.length + + _items.where((i) => i.isUser && !snapshotIds.contains(i.turnId)).length; + } /// Whether the gutter rail can take turn navigation over at [available] width, /// so the corner arrows can stand down rather than offer the same thing twice. diff --git a/apps/flutter/test/fake_bridge_api.dart b/apps/flutter/test/fake_bridge_api.dart index 3cb2d59..15200af 100644 --- a/apps/flutter/test/fake_bridge_api.dart +++ b/apps/flutter/test/fake_bridge_api.dart @@ -688,12 +688,16 @@ class FakeBridgeApi implements BridgeApi { /// Turn ids passed to [appThreadOlderPage], in call order. int olderPageCalls = 0; + /// Controlled responses for interleaving requests across threads. + final Map> pendingOlderPages = {}; + @override Future appThreadOlderPage( String serviceKey, String threadId, ) async { olderPageCalls++; + if (pendingOlderPages[threadId] case final response?) return response; if (olderPages.isEmpty) { return const OlderPage(items: [], hasOlder: false); } @@ -707,6 +711,9 @@ class FakeBridgeApi implements BridgeApi { /// Turn ids passed to [appThreadTurnItems], in call order. final List turnItemCalls = []; + /// Controlled responses for hover/select races. + final Map>> pendingTurnItems = {}; + @override Future> appThreadTurnItems( String serviceKey, @@ -714,6 +721,7 @@ class FakeBridgeApi implements BridgeApi { String turnId, ) async { turnItemCalls.add(turnId); + if (pendingTurnItems[turnId] case final response?) return response; return turnItems[turnId] ?? const []; } diff --git a/apps/flutter/test/history_merge_test.dart b/apps/flutter/test/history_merge_test.dart new file mode 100644 index 0000000..2d7f153 --- /dev/null +++ b/apps/flutter/test/history_merge_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:pocket_codex/src/screens/app_session/history_merge.dart'; +import 'package:pocket_codex/src/screens/app_session/transcript_model.dart'; + +TranscriptItem item(String id, String turn) => + TranscriptItem(id: id, type: 'agentMessage', turnId: turn); + +void main() { + test('sequential pages respect previously fetched disjoint turns', () { + final merged = mergeHistoryItems( + [item('a1', 't1'), item('a5', 't5')], + [item('a2', 't2'), item('a3', 't3')], + turnOrder: ['t1', 't2', 't3', 't4', 't5'], + olderPage: true, + ); + expect(merged.map((i) => i.id), ['a1', 'a2', 'a3', 'a5']); + }); + + test('an overlapping turn places its opening before the existing tail', () { + final tail = item('tail', 't1')..text = 'live text'; + final merged = mergeHistoryItems( + [tail], + [item('opening', 't1'), item('tail', 't1'), item('after', 't1')], + turnOrder: ['t1'], + olderPage: false, + ); + expect(merged.map((i) => i.id), ['opening', 'tail', 'after']); + expect(merged[1], same(tail)); + }); + + test('nonoverlapping older items precede the tail of the same turn', () { + final merged = mergeHistoryItems( + [item('tail', 't1')], + [item('older', 't1'), item('older', 't1')], + turnOrder: ['t1'], + olderPage: true, + ); + expect(merged.map((i) => i.id), ['older', 'tail']); + }); + + test('live and optimistic turns stay after the snapshot', () { + final merged = mergeHistoryItems( + [ + item('old', 't1'), + item('optimistic', ''), + item('live', 't2'), + item('next', ''), + ], + [item('older', 't0')], + turnOrder: ['t0', 't1'], + olderPage: true, + ); + expect(merged.map((i) => i.id), [ + 'older', + 'old', + 'optimistic', + 'live', + 'next', + ]); + }); + + test('turns older than the skeleton retain their relative position', () { + final merged = mergeHistoryItems( + [item('outside', 't0'), item('latest', 't5')], + [item('oldest', 'before'), item('middle', 't3')], + turnOrder: ['t2', 't3', 't4', 't5'], + olderPage: true, + ); + expect(merged.map((i) => i.id), ['oldest', 'outside', 'middle', 'latest']); + }); +} diff --git a/apps/flutter/test/screens/app_session_test.dart b/apps/flutter/test/screens/app_session_test.dart index 213c659..0205569 100644 --- a/apps/flutter/test/screens/app_session_test.dart +++ b/apps/flutter/test/screens/app_session_test.dart @@ -27,6 +27,7 @@ import 'package:pocket_codex/src/ui_prefs.dart'; import 'package:pocket_codex/src/widgets/message_images.dart'; import 'package:pocket_codex/src/widgets/status_dots.dart'; import 'package:pocket_codex/src/widgets/turn_minimap.dart'; +import 'package:pocket_codex/src/widgets/middle_click_scroll.dart'; import '../fake_bridge_api.dart'; import '../support/screen_harness.dart'; @@ -5716,15 +5717,34 @@ void main() { ); /// A thread whose newest turn is loaded and whose earlier turns are not. - Future openPaginated(WidgetTester t) async { + Future openPaginated( + WidgetTester t, { + bool tailOnly = false, + bool longTail = false, + bool nextThread = false, + }) async { final api = FakeBridgeApi( config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), ); + if (nextThread) { + api.appThreads.add( + const ThreadMeta( + id: 'thread-next', + preview: 'next history', + cwd: '/work', + updatedAt: 1, + ), + ); + } await api.appConnect('pcx:lb7666:app:default', 28080); api.readResult = ThreadHistory( items: [ - user('u5', 'newest question', 't5'), - agent('a5', 'newest answer', 't5'), + if (!tailOnly) user('u5', 'newest question', 't5'), + agent( + 'a5', + longTail ? 'newest answer\n\n' * 70 : 'newest answer', + 't5', + ), ], running: false, hasOlder: true, @@ -5855,6 +5875,123 @@ void main() { expect(find.byKey(const Key('chat-older-history')), findsNothing); }); + testWidgets('a partially loaded turn can fetch its opening row', (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t, tailOnly: true); + api.turnItems['t5'] = [ + user('u5', 'newest question', 't5'), + agent('a5', 'newest answer', 't5'), + ]; + final rail = t.widget(find.byType(TurnMinimap)); + expect(rail.items.last.rowIndex, -1); + rail.onSelect(rail.items.last); + await t.pumpAndSettle(); + expect(api.turnItemCalls, ['t5']); + expect(find.text('newest question'), findsOneWidget); + expect( + t.widget(find.byType(TurnMinimap)).items.last.rowIndex, + 0, + ); + }); + }); + + testWidgets('older pages keep the order after an arbitrary turn fetch', ( + t, + ) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t); + api.turnItems['t1'] = [user('u1', 'first question', 't1')]; + final rail = t.widget(find.byType(TurnMinimap)); + rail.onPreview!(rail.items.first); + await t.pumpAndSettle(); + api.olderPages = [ + [ + user('u2', 'second question', 't2'), + user('u3', 'third question', 't3'), + ], + ]; + await t.tap(find.byKey(const Key('chat-older-history-load'))); + await t.pumpAndSettle(); + final rows = t.widget(find.byType(TurnMinimap)).items; + expect(rows[0].rowIndex, lessThan(rows[1].rowIndex)); + expect(rows[1].rowIndex, lessThan(rows[2].rowIndex)); + expect(rows[2].rowIndex, lessThan(rows[4].rowIndex)); + }); + }); + + testWidgets( + 'select waits for an in-flight hover and scrolls when it arrives', + (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t, longTail: true); + final response = Completer>(); + api.pendingTurnItems['t1'] = response.future; + final rail = t.widget(find.byType(TurnMinimap)); + final controller = t + .widget(find.byType(MiddleClickScroll)) + .controller; + expect(controller.offset, greaterThan(500)); + rail.onPreview!(rail.items.first); + await t.pump(); + rail.onSelect(rail.items.first); + await t.pump(); + expect(api.turnItemCalls, ['t1']); + response.complete([user('u1', 'first question', 't1')]); + await t.pumpAndSettle(); + expect(api.turnItemCalls, ['t1']); + expect(controller.offset, lessThan(100)); + expect(find.text('first question'), findsOneWidget); + }); + }, + ); + + testWidgets('new live turns join the paginated rail', (t) async { + await onDesktop(() async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await openPaginated(t); + await t.enterText(find.byType(TextField).last, 'a new live question'); + await t.pump(); + await t.tap(find.byKey(const Key('send-btn'))); + await t.pumpAndSettle(); + final rail = t.widget(find.byType(TurnMinimap)); + expect(rail.items, hasLength(6)); + expect(rail.items.last.userText, 'a new live question'); + expect(rail.items.last.rowIndex, greaterThanOrEqualTo(0)); + }); + }); + + testWidgets('a stale failure cannot unlock another thread pagination', ( + t, + ) async { + await t.binding.setSurfaceSize(const Size(1600, 900)); + addTearDown(() => t.binding.setSurfaceSize(null)); + final api = await openPaginated(t, nextThread: true); + final previous = Completer(); + final current = Completer(); + api.pendingOlderPages['thread-long'] = previous.future; + api.pendingOlderPages['thread-next'] = current.future; + await t.tap(find.byKey(const Key('chat-older-history-load'))); + await t.pump(); + await t.tap(find.byKey(const Key('conv-tile-thread-next'))); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('chat-older-history-load'))); + await t.pump(); + previous.completeError(StateError('previous thread failed')); + await t.pump(); + expect(api.olderPageCalls, 2); + expect(find.byKey(const Key('chat-older-history-load')), findsNothing); + current.complete(const OlderPage(items: [], hasOlder: false)); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }); + testWidgets('a thread that arrives whole pages nothing', (t) async { final api = FakeBridgeApi( config: const ConfigInfo(relay: 'lb7666.top:7666', hasKey: true), diff --git a/crates/pocket-codex-bridge/src/api/bridge.rs b/crates/pocket-codex-bridge/src/api/bridge.rs index 8db27e5..d336dcd 100644 --- a/crates/pocket-codex-bridge/src/api/bridge.rs +++ b/crates/pocket-codex-bridge/src/api/bridge.rs @@ -1053,8 +1053,12 @@ pub fn app_compact(service_key: String, thread_id: String) -> Result<()> { /// `thread/list` carries no summary: the only source is a full `thread/read`, /// so the UI fetches these lazily for the rows it actually shows instead of /// paying for every conversation up front. -pub fn app_thread_summary(service_key: String, thread_id: String) -> Result> { - app_session::thread_summary(&service_key, &thread_id) +pub async fn app_thread_summary(service_key: String, thread_id: String) -> Result> { + // Summary RPCs must not occupy the CPU-sized FRB pool, even on one-core + // devices. The UI separately bounds how many background requests run. + runtime::runtime() + .spawn_blocking(move || app_session::thread_summary(&service_key, &thread_id)) + .await? } /// Rename a conversation. The title is persisted by the app-server (so it diff --git a/crates/pocket-codex-bridge/src/engine/app_session.rs b/crates/pocket-codex-bridge/src/engine/app_session.rs index 4997070..65c4f12 100644 --- a/crates/pocket-codex-bridge/src/engine/app_session.rs +++ b/crates/pocket-codex-bridge/src/engine/app_session.rs @@ -175,6 +175,8 @@ struct ThreadPagination { /// Turn ids whose items have been loaded, oldest first. The UI jumps by /// turn, so it needs to know which turns it can already show. loaded_turns: Vec, + /// Timing from every enumerated turn, reused by later item pages. + turn_stamps: HashMap, } /// Bridge calls currently occupying an FRB worker thread. Diagnostic only. @@ -497,11 +499,7 @@ pub fn thread_older_page(service_key: &str, thread_id: &str) -> Result = None; let mut seen = HashSet::new(); - let stamp = TurnStamp { - id: turn_id.to_string(), - completed_at: None, - duration_ms: None, - }; + let stamps = pagination_of(service_key, thread_id) + .map(|state| state.turn_stamps) + .unwrap_or_default(); + let stamp = turn_stamp(&stamps, turn_id); // Bounded: a single turn can hold hundreds of items, and draining all of // them serially is what made opening the longest threads time out. Enough // pages to fill a screen; scrolling covers the rest. @@ -1510,7 +1507,11 @@ fn fetch_item_page( /// Walks `thread/turns/list` backwards with a summary view, which the store /// answers from indexed columns rather than by replaying items — cheap enough /// to do for the whole thread so the rail can show its true length immediately. -fn fetch_all_turn_summaries(client: &Arc, thread_id: &str) -> Result> { +fn fetch_all_turn_summaries( + client: &Arc, + thread_id: &str, + stamps: &mut HashMap, +) -> Result> { let mut newest_first = Vec::new(); let mut cursor: Option = None; let mut seen = HashSet::new(); @@ -1526,6 +1527,8 @@ fn fetch_all_turn_summaries(client: &Arc, thread_id: &str) -> Result< break; } for turn in &turns { + let stamp = TurnStamp::of(turn); + stamps.insert(stamp.id.clone(), stamp); newest_first.push(summarize_turn(turn, false)); } let next = page @@ -1583,7 +1586,7 @@ fn load_paginated_window( .unwrap_or_default(); // Timing lives on the turn shells, so items pick their turn's stamp up here // rather than losing the duration footnote the transcript renders. - let stamps: HashMap = turns + let mut stamps: HashMap = turns .iter() .map(|turn| { let stamp = TurnStamp::of(turn); @@ -1601,13 +1604,9 @@ fn load_paginated_window( .get("turnId") .and_then(Value::as_str) .unwrap_or_default(); - let stamp = stamps.get(turn_id).cloned().unwrap_or(TurnStamp { - id: turn_id.to_string(), - completed_at: None, - duration_ms: None, - }); + let stamp = turn_stamp(&stamps, turn_id); if let Some(parsed) = parse_turn_item(item, &stamp) { - if !loaded_turns.iter().any(|id| id == turn_id) { + if parsed.item_type == "userMessage" && !loaded_turns.iter().any(|id| id == turn_id) { loaded_turns.push(turn_id.to_string()); } items.push(parsed); @@ -1618,7 +1617,8 @@ fn load_paginated_window( // skeleton can't be read still opens — the rail just falls back to the // loaded turns. let phase = std::time::Instant::now(); - let mut skeletons = fetch_all_turn_summaries(client, thread_id).unwrap_or_else(|_| Vec::new()); + let mut skeletons = + fetch_all_turn_summaries(client, thread_id, &mut stamps).unwrap_or_else(|_| Vec::new()); tracing::debug!( target: "pocket_codex_bridge::history", " {} turn summaries in {:?}", skeletons.len(), phase.elapsed() @@ -1626,11 +1626,18 @@ fn load_paginated_window( if skeletons.is_empty() { skeletons = turns .iter() - .map(|turn| summarize_turn(turn, true)) + .map(|turn| summarize_turn(turn, false)) .collect(); - } else { - for skeleton in &mut skeletons { - skeleton.loaded = loaded_turns.contains(&skeleton.turn_id); + } + for skeleton in &mut skeletons { + skeleton.loaded = loaded_turns.contains(&skeleton.turn_id); + } + // The item window can cross more turns than the initial status shells. + // Summary pages carry the same timing metadata without loading their items. + for item in &mut items { + if let Some(stamp) = stamps.get(&item.turn_id) { + item.turn_completed_at = stamp.completed_at; + item.turn_duration_ms = stamp.duration_ms; } } // Where older history continues: this page's own continuation cursor. @@ -1646,6 +1653,7 @@ fn load_paginated_window( next_item_cursor: item_cursor, seen_item_cursors: HashSet::new(), loaded_turns, + turn_stamps: stamps, }); Ok(LoadedHistory { @@ -1822,17 +1830,14 @@ fn thread_read_inner(service_key: &str, thread_id: &str) -> Result Result> { - // Sidebar rows fetch these concurrently, one FRB worker thread each, and the - // pool is only as wide as the CPU count. Counting occupancy here makes a - // saturated pool — which stalls every other bridge call, `thread_read` - // included — visible in the log instead of looking like a hung server. + // The async API runs these on the engine's blocking executor, separate from + // interactive FRB workers. Count them with other history calls for diagnosis. let depth = BRIDGE_BUSY.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; let _release = BusyGuard; if depth > 4 { tracing::warn!( target: "pocket_codex_bridge::history", - "thread_summary thread={thread_id} with {depth} bridge calls in flight (pool holds {})", - std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0) + "thread_summary thread={thread_id} with {depth} history calls in flight" ); } let client = client_for(service_key)?; @@ -2538,6 +2543,13 @@ struct TurnStamp { duration_ms: Option, } +fn turn_stamp(stamps: &HashMap, turn_id: &str) -> TurnStamp { + stamps.get(turn_id).cloned().unwrap_or_else(|| TurnStamp { + id: turn_id.to_string(), + ..TurnStamp::default() + }) +} + impl TurnStamp { /// Read the stamp off a `Turn` object. fn of(turn: &Value) -> Self { diff --git a/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs b/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs index 7c069f2..b03e89c 100644 --- a/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs +++ b/crates/pocket-codex-bridge/src/engine/app_session_pagination_tests.rs @@ -1,6 +1,6 @@ use futures::{SinkExt, StreamExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::{accept_async, tungstenite::Message}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_tungstenite::{accept_async, tungstenite::Message, WebSocketStream}; use super::*; @@ -21,7 +21,19 @@ fn load_window(next_cursor: Value) -> LoadedHistory { ), ("thread/turns/list", json!({"data": [turn], "nextCursor": null})), ]; - let (client, peer) = runtime::runtime().block_on(async { + let (client, peer) = mock_client(replies.into()); + let history = load_paginated_window(&client, "pagination-test", "thread-1") + .expect("pagination test operation"); + runtime::runtime() + .block_on(peer) + .expect("pagination test operation"); + history +} + +fn mock_client( + replies: Vec<(&'static str, Value)>, +) -> (Arc, tokio::task::JoinHandle>) { + runtime::runtime().block_on(async { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("pagination test operation"); @@ -58,13 +70,7 @@ fn load_window(next_cursor: Value) -> LoadedHistory { .await .expect("pagination test operation"); (Arc::new(client), peer) - }); - let history = load_paginated_window(&client, "pagination-test", "thread-1") - .expect("pagination test operation"); - runtime::runtime() - .block_on(peer) - .expect("pagination test operation"); - history + }) } #[test] @@ -85,3 +91,140 @@ fn turn_summary_reads_the_upstream_user_content_array() { assert_eq!(history.skeletons[0].user_text, "First part\nSecond part"); assert_eq!(history.skeletons[0].assistant_text, "Answer"); } + +struct TestSession(String); + +impl TestSession { + fn new(client: Arc) -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let key = + format!("history-test-{}", NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)); + sessions() + .lock() + .expect("test sessions") + .insert(key.clone(), Session { + client, + events: broadcast::channel(16).0, + forwarder: runtime::runtime().spawn(std::future::pending()), + active_turns: Default::default(), + runtime_config: Default::default(), + pending_approvals: Default::default(), + transcript: Default::default(), + pagination: Default::default(), + }); + Self(key) + } +} + +impl Drop for TestSession { + fn drop(&mut self) { + disconnect(&self.0); + } +} + +#[test] +fn paginated_items_keep_timing_beyond_status_shells_and_on_later_reads() { + runtime::init(std::env::temp_dir()).expect("init test runtime"); + let turns: Vec = (1..=8) + .rev() + .map(|id| { + json!({ + "id": format!("t{id}"), "status": "completed", + "completedAt": 1000 + id, "durationMs": id * 100, "items": [] + }) + }) + .collect(); + let entry = |id| { + json!({"turnId": format!("t{id}"), "item": { + "id": format!("a{id}"), "type": "agentMessage", "text": "answer" + }}) + }; + let (client, peer) = mock_client(vec![ + ("thread/turns/list", json!({"data": turns[..5], "nextCursor": "older-shells"})), + ("thread/items/list", json!({"data": [entry(8), entry(3)], "nextCursor": "older-items"})), + ("thread/turns/list", json!({"data": turns, "nextCursor": null})), + ("thread/items/list", json!({"data": [entry(2)], "nextCursor": null})), + ("thread/items/list", json!({"data": [entry(1)], "nextCursor": null})), + ]); + let session = TestSession::new(client.clone()); + let history = load_paginated_window(&client, &session.0, "thread").expect("load window"); + assert_eq!(history.items[0].turn_completed_at, Some(1003)); + assert_eq!(history.items[0].turn_duration_ms, Some(300)); + assert!(!history.skeletons[2].loaded, "an agent tail is not a navigable user row"); + let older = thread_older_page(&session.0, "thread").expect("older page"); + assert_eq!(older.items[0].turn_completed_at, Some(1002)); + assert_eq!(older.items[0].turn_duration_ms, Some(200)); + let turn = thread_turn_items(&session.0, "thread", "t1").expect("turn items"); + assert_eq!(turn[0].turn_completed_at, Some(1001)); + assert_eq!(turn[0].turn_duration_ms, Some(100)); + runtime::runtime().block_on(peer).expect("test peer"); +} + +#[test] +fn pending_summary_yields_on_a_single_async_worker() { + use std::time::{Duration, Instant}; + runtime::init(std::env::temp_dir()).expect("init test runtime"); + let received = Arc::new(tokio::sync::Notify::new()); + let notify = received.clone(); + let (client, peer) = runtime::runtime().block_on(async { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let url = format!("ws://{}", listener.local_addr().expect("address")); + let peer = tokio::spawn(async move { + let mut ws = accept_async(listener.accept().await.expect("accept").0) + .await + .expect("ws"); + let first: Value = serde_json::from_str( + &ws.next() + .await + .expect("summary") + .expect("frame") + .into_text() + .expect("text"), + ) + .expect("json"); + assert_eq!(first["method"], "thread/turns/list"); + notify.notify_one(); + // Bound the peer so a blocking regression fails instead of hanging. + if let Ok(Some(Ok(frame))) = + tokio::time::timeout(Duration::from_secs(3), ws.next()).await + { + let next: Value = + serde_json::from_str(&frame.into_text().expect("text")).expect("json"); + assert_eq!(next["method"], "thread/list"); + ws.send(Message::text( + json!({"id": next["id"], "result": {"data": []}}).to_string(), + )) + .await + .expect("interactive reply"); + } + ws.send(Message::text(json!({"id": first["id"], "result": {"data": []}}).to_string())) + .await + .expect("summary reply"); + ws + }); + (Arc::new(AppClient::connect(&url).await.expect("client").0), peer) + }); + let session = TestSession::new(client.clone()); + let single = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("single worker"); + single.block_on(async { + let summary = tokio::spawn(crate::api::bridge::app_thread_summary( + session.0.clone(), + "thread".into(), + )); + // Wait until the blocking summary is actually in flight. + tokio::time::timeout(Duration::from_secs(1), received.notified()) + .await + .expect("summary started"); + let started = Instant::now(); + client + .request("thread/list", json!({"limit": 1})) + .await + .expect("interactive request"); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(summary.await.expect("summary task").expect("summary"), None); + }); + runtime::runtime().block_on(peer).expect("test peer"); +} diff --git a/crates/pocket-codex-bridge/src/engine/logging.rs b/crates/pocket-codex-bridge/src/engine/logging.rs index 647a736..891be0c 100644 --- a/crates/pocket-codex-bridge/src/engine/logging.rs +++ b/crates/pocket-codex-bridge/src/engine/logging.rs @@ -15,7 +15,7 @@ use std::{ collections::VecDeque, fs::{self, File, OpenOptions}, io::Write, - path::Path, + path::{Path, PathBuf}, sync::Mutex, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -44,14 +44,72 @@ const RING_CAPACITY: usize = 2000; /// reported to Dart as a gap rather than blocking the logger). const CHANNEL_CAPACITY: usize = 1024; -/// How long a log file is kept before it is pruned at startup. +/// How long an inactive log file is kept after its last write. const FILE_RETENTION: Duration = Duration::from_secs(6 * 60 * 60); +const FILE_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(60); static CHANNEL: OnceCell> = OnceCell::new(); static RING: OnceCell>> = OnceCell::new(); -/// Append handle for the on-disk log, `None` when no directory was set (tests) -/// or the file could not be opened. -static FILE: OnceCell>> = OnceCell::new(); +/// Hourly on-disk sink; absent when no support directory was set (tests). +static FILE: OnceCell> = OnceCell::new(); + +struct RotatingFile { + dir: PathBuf, + hour: Option, + file: Option, + last_prune: Option, +} + +impl RotatingFile { + fn new(dir: PathBuf) -> Self { + Self { + dir, + hour: None, + file: None, + last_prune: None, + } + } + + fn maintain(&mut self, now: SystemTime) { + let hour = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() / 3600; + if self.hour != Some(hour) { + // Close before pruning: Windows cannot delete our open file. + self.file = None; + self.hour = Some(hour); + } + let due = self + .last_prune + .and_then(|last| now.duration_since(last).ok()) + .is_none_or(|elapsed| elapsed >= FILE_MAINTENANCE_INTERVAL); + if due { + prune_old_logs(&self.dir, now); + self.last_prune = Some(now); + } + } + + fn write(&mut self, line: &LogLine, now: SystemTime) { + self.maintain(now); + if self.file.is_none() { + let hour = self.hour.unwrap_or(0); + let path = self.dir.join(format!( + "pocket-codex-{}-{:02}.log", + date_stamp(hour * 3600), + hour % 24 + )); + self.file = OpenOptions::new().create(true).append(true).open(path).ok(); + } + if let Some(file) = &mut self.file { + let _ = writeln!( + file, + "{} {:5} {} {}", + clock(line.timestamp_ms), + line.level, + line.target, + line.message + ); + } + } +} /// Install the capture layer as the global subscriber. Idempotent — safe to /// call once at boot; a second call (or codex's own `try_init`) is a no-op. @@ -121,8 +179,9 @@ fn parse_level(raw: &str) -> &'static str { "INFO" } -/// Start writing captured lines to `/logs/pocket-codex-.log`, and -/// drop files older than [`FILE_RETENTION`]. +/// Write hourly logs under `/logs/` and prune inactive files after six +/// hours. Maintenance runs every minute, including while the app is idle in the +/// tray. /// /// Separate from [`init`] because the support directory isn't known that early. /// Failure is silent: the in-memory viewer is the primary sink, and losing the @@ -132,26 +191,29 @@ pub fn init_file(support_dir: &Path) { if fs::create_dir_all(&dir).is_err() { return; } - prune_old_logs(&dir); - let path = dir.join(format!("pocket-codex-{}.log", today_stamp())); - let file = OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .ok(); - let opened = file.is_some(); - FILE.get_or_init(|| Mutex::new(file)); - if opened { - tracing::info!(target: "pocket_codex_bridge::logging", "log file: {}", path.display()); - } + FILE.get_or_init(|| { + let mut log = RotatingFile::new(dir.clone()); + log.maintain(SystemTime::now()); + let _ = std::thread::Builder::new() + .name("pcx-log-retention".into()) + .spawn(|| loop { + std::thread::sleep(FILE_MAINTENANCE_INTERVAL); + if let Some(log) = FILE.get() { + log.lock() + .unwrap_or_else(|e| e.into_inner()) + .maintain(SystemTime::now()); + } + }); + Mutex::new(log) + }); + tracing::info!(target: "pocket_codex_bridge::logging", "log directory: {}", dir.display()); } /// Delete log files last modified longer ago than [`FILE_RETENTION`]. -fn prune_old_logs(dir: &Path) { +fn prune_old_logs(dir: &Path, now: SystemTime) { let Ok(entries) = fs::read_dir(dir) else { return; }; - let now = SystemTime::now(); for entry in entries.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); @@ -171,11 +233,7 @@ fn prune_old_logs(dir: &Path) { } /// `YYYY-MM-DD` in UTC, for the log file name. -fn today_stamp() -> String { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); +fn date_stamp(secs: u64) -> String { let days = secs / 86_400; // Civil-from-days (Howard Hinnant's algorithm), so no date dependency here. let z = days as i64 + 719_468; @@ -208,17 +266,9 @@ fn emit(line: LogLine) { r.push_back(line.clone()); } if let Some(file) = FILE.get() { - let mut guard = file.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(file) = guard.as_mut() { - let _ = writeln!( - file, - "{} {:5} {} {}", - clock(line.timestamp_ms), - line.level, - line.target, - line.message - ); - } + file.lock() + .unwrap_or_else(|e| e.into_inner()) + .write(&line, SystemTime::now()); } if let Some(tx) = CHANNEL.get() { // Err just means no viewers are open — the ring already retained it. @@ -233,6 +283,10 @@ fn now_ms() -> i64 { .unwrap_or(0) } +#[cfg(test)] +#[path = "logging_tests.rs"] +mod tests; + /// A `tracing` layer that funnels every (filtered) event into [`emit`]. struct CaptureLayer; diff --git a/crates/pocket-codex-bridge/src/engine/logging_tests.rs b/crates/pocket-codex-bridge/src/engine/logging_tests.rs new file mode 100644 index 0000000..5257af3 --- /dev/null +++ b/crates/pocket-codex-bridge/src/engine/logging_tests.rs @@ -0,0 +1,95 @@ +use std::fs::FileTimes; + +use super::*; + +struct LogDir(PathBuf); + +impl LogDir { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("pcx-log-test-{}-{id}", std::process::id())); + fs::create_dir_all(&path).expect("create log test directory"); + Self(path) + } + + fn files(&self) -> Vec { + fs::read_dir(&self.0) + .expect("read log directory") + .map(|entry| entry.expect("log entry").path()) + .collect() + } +} + +impl Drop for LogDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn line(text: &str) -> LogLine { + LogLine { + level: "INFO".into(), + target: "test".into(), + message: text.into(), + timestamp_ms: 0, + } +} + +#[test] +fn running_logger_rotates_and_prunes_without_reinitializing() { + let dir = LogDir::new(); + let now = SystemTime::now(); + let mut log = RotatingFile::new(dir.0.clone()); + log.write(&line("first hour"), now); + let first = dir.files().pop().expect("first file"); + log.file + .as_ref() + .expect("first handle") + .set_times(FileTimes::new().set_modified(now)) + .expect("timestamp first file"); + let later = now + Duration::from_secs(3600); + log.write(&line("second hour"), later); + log.file + .as_ref() + .expect("second handle") + .set_times(FileTimes::new().set_modified(later)) + .expect("timestamp second file"); + assert_eq!(dir.files().len(), 2); + assert!(fs::read_to_string(&first) + .expect("read first") + .contains("first hour")); + assert!(!fs::read_to_string(&first) + .expect("read first") + .contains("second hour")); + + // The maintenance tick prunes even when no new event is written. + log.maintain(now + FILE_RETENTION + Duration::from_secs(60)); + assert!(!first.exists()); + let remaining = dir.files(); + assert_eq!(remaining.len(), 1); + assert!(fs::read_to_string(&remaining[0]) + .expect("read second") + .contains("second hour")); +} + +#[test] +fn pruning_keeps_other_files_and_removes_legacy_daily_logs() { + let dir = LogDir::new(); + let old = dir.0.join("pocket-codex-2026-01-01.log"); + let unrelated = dir.0.join("notes.txt"); + fs::write(&old, "legacy").expect("write legacy log"); + fs::write(&unrelated, "keep").expect("write unrelated file"); + let now = SystemTime::now(); + let aged = now - FILE_RETENTION - Duration::from_secs(60); + File::options() + .write(true) + .open(&old) + .expect("open legacy") + .set_times(FileTimes::new().set_modified(aged)) + .expect("age legacy"); + let mut log = RotatingFile::new(dir.0.clone()); + log.maintain(now); + assert!(!old.exists()); + assert!(unrelated.exists()); +} diff --git a/crates/pocket-codex-bridge/src/frb_generated.rs b/crates/pocket-codex-bridge/src/frb_generated.rs index 3f75fa8..7ebef4d 100644 --- a/crates/pocket-codex-bridge/src/frb_generated.rs +++ b/crates/pocket-codex-bridge/src/frb_generated.rs @@ -1591,7 +1591,7 @@ fn wire__crate__api__bridge__app_thread_summary_impl( rust_vec_len_: i32, data_len_: i32, ) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( flutter_rust_bridge::for_generated::TaskInfo { debug_name: "app_thread_summary", port: Some(port_), @@ -1610,13 +1610,15 @@ fn wire__crate__api__bridge__app_thread_summary_impl( let api_service_key = ::sse_decode(&mut deserializer); let api_thread_id = ::sse_decode(&mut deserializer); deserializer.end(); - move |context| { + move |context| async move { transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>( - (move || { + (move || async move { let output_ok = - crate::api::bridge::app_thread_summary(api_service_key, api_thread_id)?; + crate::api::bridge::app_thread_summary(api_service_key, api_thread_id) + .await?; Ok(output_ok) - })(), + })() + .await, ) } }, diff --git a/design/HANDOFF-thread-list-stall.md b/design/HANDOFF-thread-list-stall.md index 617546f..53edbd9 100644 --- a/design/HANDOFF-thread-list-stall.md +++ b/design/HANDOFF-thread-list-stall.md @@ -4,6 +4,33 @@ **状态**:已修复可确定复现的连接状态、复用探测与分页缺陷;原 Mac 进程内部阻塞的根因仍未确认 **分支**:`chore/codex-upstream-sync`,交接时的分页与日志改动已在 `0a52779` 提交 +## PR 审查修复(2026-09-06) + +本轮处理最初收到的 8 条 P2 意见,聚焦正确性与稳定性,不循环追逐后续新增审查。 + +- 分页与按需回合加载共用顺序合并,重叠 item 作为锚点,保留实时内容;只包含尾部 + 的回合仍可获取开头。无 turn ID 的乐观消息保持原顺序。 + `apps/flutter/lib/src/screens/app_session/history_merge.dart:7`。 +- 预加载与点击等待同一次请求;请求代次隔离会话切换及重新加载后的旧结果与异常。 + 跳转等待虚拟列表完成布局,程序跳转不再自动拉旧页、移除表头而错位。 + `apps/flutter/lib/src/screens/app_session_screen.dart:1280`。 +- 回合游标与计数纳入打开会话后新增的实时回合;分页时间缓存保留已枚举回合的完成 + 时间与耗时,并用于初始窗口、旧页和单回合读取。原有最多 500 回合的骨架枚举上限 + 保持不变。`crates/pocket-codex-bridge/src/engine/app_session.rs:1510`。 +- 摘要 API 改为 async,慢 RPC 转到独立的阻塞执行器,不占用交互所用的 FRB 工作线程。 + Dart 仍限制后台请求并发量;单异步工作线程测试验证摘要等待期间交互请求可完成。 + Rust/Dart 绑定已重新生成。`crates/pocket-codex-bridge/src/api/bridge.rs:1056`。 +- 磁盘日志按 UTC 小时轮转,每分钟检查并删除最后写入超过六小时的日志,空闲驻留 + 托盘时也清理。仍兼容清理旧的按日命名日志,轮转先关闭文件以支持 Windows。 + `crates/pocket-codex-bridge/src/engine/logging.rs:58`。 +- 全量 Flutter **435 passed,3 skipped**;WSL 全量 Rust **293 passed,6 ignored**; + 全部第一方 Rust fmt、clippy 与 Dart format、analyze 通过。Windows 本轮原生构建 + 使用桌面 manifest 与独立 Profile 产物,结果记录在 PR 的验证说明中。 + +新增回归覆盖跨回合顺序、同回合重叠分页、尾部补全、预加载后跳转、实时回合、切换 +会话后的迟到失败、跨页时间信息、单线程异步调度与运行期间日志清理。原 Mac 内部 +阻塞原因仍未确认,本轮不扩大该结论。 + ## 本次续接结果(2026-09-05) ### 已确认并修复