diff --git a/CMakeLists.txt b/CMakeLists.txt index 0641fd2..6d7e879 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,22 @@ project(CodexUI VERSION 1.0.0 LANGUAGES CXX) include(GNUInstallDirs) include(CTest) +find_package(Threads REQUIRED) + +option( + CODEXUI_NODEGRAPH_ONLY + "Configure only the standalone codexui-nodegraph library and tests" + OFF +) + +add_subdirectory(src/codex/nodegraph) +if(BUILD_TESTING) + add_subdirectory(tests/codex/nodegraph) +endif() + +if(CODEXUI_NODEGRAPH_ONLY) + return() +endif() set(CMAKE_AUTOMOC ON) @@ -25,7 +41,6 @@ find_package( find_package(Qt6 REQUIRED COMPONENTS Widgets) find_package(PkgConfig REQUIRED) pkg_check_modules(LIBGIT2 REQUIRED IMPORTED_TARGET libgit2) -find_package(Threads REQUIRED) set( CODEXUI_CODEX_COMMON_SOURCES @@ -34,6 +49,7 @@ set( src/codex/ClientRuntime.h src/codex/Configuration.cpp src/codex/Configuration.h + src/codex/CurrentProtocolAdapters.h src/codex/ConnectionDialog.cpp src/codex/ConnectionDialog.h src/codex/DiffViewer.cpp @@ -48,34 +64,21 @@ set( src/codex/MainWindow.h src/codex/NewThreadDialog.cpp src/codex/NewThreadDialog.h + src/codex/NodeGraphJson.cpp + src/codex/NodeGraphJson.h src/codex/PendingRequestPolicy.cpp src/codex/PendingRequestPolicy.h - src/codex/PresentationModel.cpp - src/codex/PresentationModel.h - src/codex/PresentationClient.h - src/codex/PresentationStatus.h - src/codex/PresentationProtocol.cpp - src/codex/PresentationProtocol.h - src/codex/ProtocolNormalizer.cpp - src/codex/ProtocolNormalizer.h - src/codex/UiSession.cpp - src/codex/UiSession.h - src/codex/ipc/QtSocketPairEndpoint.cpp - src/codex/ipc/QtSocketPairEndpoint.h - src/codex/ipc/SNodeSocketPairEndpoint.cpp - src/codex/ipc/SNodeSocketPairEndpoint.h - src/codex/ipc/SocketPair.cpp - src/codex/ipc/SocketPair.h + src/codex/UiStatus.h + src/codex/WorkerMailboxReceiver.cpp + src/codex/WorkerMailboxReceiver.h src/codex/main.cpp src/codex/ui/ExpandingPromptEditor.cpp src/codex/ui/ExpandingPromptEditor.h src/codex/ui/BrandMark.cpp src/codex/ui/BrandMark.h + src/codex/ui/QtNodeAttachment.h src/codex/ui/UiStyle.cpp src/codex/ui/UiStyle.h - src/codex/ui/UiViewProjection.cpp - src/codex/ui/UiViewProjection.h - src/codex/ui/UiViewState.h ) set( @@ -86,8 +89,6 @@ set( src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp src/codex/middle/ConversationCards.h - src/codex/middle/ConversationProjection.cpp - src/codex/middle/ConversationProjection.h src/codex/middle/ConversationView.cpp src/codex/middle/ConversationView.h src/codex/middle/InspectorPane.cpp @@ -96,10 +97,11 @@ set( src/codex/middle/MiddleRegionWidget.h src/codex/middle/MiddleTypes.cpp src/codex/middle/MiddleTypes.h - src/codex/middle/PromptCoordinator.cpp - src/codex/middle/PromptCoordinator.h src/codex/middle/ThreadPane.cpp src/codex/middle/ThreadPane.h + src/codex/ui/NodeGraphUiAdapter.cpp + src/codex/ui/NodeGraphUiAdapter.h + src/codex/ui/UiViewState.h ) qt_add_executable( @@ -118,6 +120,7 @@ function(configure_codexui_target target) ${target} PRIVATE AISuite::OpenAICodex + codexui-nodegraph PkgConfig::LIBGIT2 Qt6::Widgets Threads::Threads @@ -157,131 +160,173 @@ endif() if(BUILD_TESTING) add_executable( - codexui-socketpair-contract-test - tests/codex/SocketPairContractTest.cpp - src/codex/ipc/QtSocketPairEndpoint.cpp - src/codex/ipc/QtSocketPairEndpoint.h - src/codex/ipc/SNodeSocketPairEndpoint.cpp - src/codex/ipc/SNodeSocketPairEndpoint.h - src/codex/ipc/SocketPair.cpp - src/codex/ipc/SocketPair.h + codexui-nodegraph-ui-adapter-test + tests/codex/NodeGraphUiAdapterTest.cpp + src/codex/ui/NodeGraphUiAdapter.cpp + src/codex/ui/NodeGraphUiAdapter.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h ) target_compile_features( - codexui-socketpair-contract-test PRIVATE cxx_std_20 + codexui-nodegraph-ui-adapter-test PRIVATE cxx_std_20 ) target_include_directories( - codexui-socketpair-contract-test PRIVATE src + codexui-nodegraph-ui-adapter-test PRIVATE src ) target_link_libraries( - codexui-socketpair-contract-test - PRIVATE Qt6::Core Threads::Threads snodec::net-un-stream-legacy + codexui-nodegraph-ui-adapter-test PRIVATE codexui-nodegraph ) add_test( - NAME codexui-socketpair-contract - COMMAND codexui-socketpair-contract-test + NAME codexui-nodegraph-ui-adapter + COMMAND codexui-nodegraph-ui-adapter-test ) set_tests_properties( - codexui-socketpair-contract PROPERTIES TIMEOUT 10 + codexui-nodegraph-ui-adapter PROPERTIES TIMEOUT 10 ) - add_executable( - codexui-presentation-pipeline-test - tests/codex/PresentationPipelineTest.cpp - src/codex/PresentationModel.cpp - src/codex/PresentationModel.h - src/codex/PresentationProtocol.cpp - src/codex/PresentationProtocol.h - src/codex/ProtocolNormalizer.cpp - src/codex/ProtocolNormalizer.h + qt_add_executable( + codexui-nodegraph-conversation-ui-test + tests/codex/NodeGraphConversationUiTest.cpp + src/codex/ui/NodeGraphUiAdapter.cpp + src/codex/ui/NodeGraphUiAdapter.h + src/codex/middle/ConversationCards.cpp + src/codex/middle/ConversationCards.h + src/codex/middle/ConversationView.cpp + src/codex/middle/ConversationView.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features( + codexui-nodegraph-conversation-ui-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-nodegraph-conversation-ui-test PRIVATE src + ) + target_link_libraries( + codexui-nodegraph-conversation-ui-test + PRIVATE codexui-nodegraph Qt6::Widgets + ) + add_test( + NAME codexui-nodegraph-conversation-ui + COMMAND codexui-nodegraph-conversation-ui-test + ) + set_tests_properties( + codexui-nodegraph-conversation-ui + PROPERTIES TIMEOUT 20 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + + qt_add_executable( + codexui-nodegraph-thread-pane-ui-test + tests/codex/NodeGraphThreadPaneUiTest.cpp + src/codex/ui/NodeGraphUiAdapter.cpp + src/codex/ui/NodeGraphUiAdapter.h + src/codex/ui/UiViewState.h + src/codex/middle/ThreadPane.cpp + src/codex/middle/ThreadPane.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h ) target_compile_features( - codexui-presentation-pipeline-test PRIVATE cxx_std_20 + codexui-nodegraph-thread-pane-ui-test PRIVATE cxx_std_20 ) target_include_directories( - codexui-presentation-pipeline-test PRIVATE src + codexui-nodegraph-thread-pane-ui-test PRIVATE src ) target_link_libraries( - codexui-presentation-pipeline-test - PRIVATE AISuite::OpenAICodex + codexui-nodegraph-thread-pane-ui-test + PRIVATE codexui-nodegraph Qt6::Widgets ) add_test( - NAME codexui-presentation-pipeline - COMMAND codexui-presentation-pipeline-test + NAME codexui-nodegraph-thread-pane-ui + COMMAND codexui-nodegraph-thread-pane-ui-test ) set_tests_properties( - codexui-presentation-pipeline PROPERTIES TIMEOUT 10 + codexui-nodegraph-thread-pane-ui + PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) add_executable( - codexui-pending-request-policy-test - tests/codex/PendingRequestPolicyTest.cpp - src/codex/PendingRequestPolicy.cpp - src/codex/PendingRequestPolicy.h + codexui-nodegraph-json-test + tests/codex/nodegraph/NodeGraphJsonTest.cpp + src/codex/NodeGraphJson.cpp + src/codex/NodeGraphJson.h + ) + target_compile_features(codexui-nodegraph-json-test PRIVATE cxx_std_20) + target_include_directories(codexui-nodegraph-json-test PRIVATE src) + target_link_libraries( + codexui-nodegraph-json-test PRIVATE AISuite::OpenAICodex codexui-nodegraph + ) + add_test(NAME codexui-nodegraph-json COMMAND codexui-nodegraph-json-test) + set_tests_properties(codexui-nodegraph-json PROPERTIES TIMEOUT 10) + + add_executable( + codexui-current-protocol-adapters-test + tests/codex/nodegraph/CurrentProtocolAdaptersTest.cpp + src/codex/CurrentProtocolAdapters.h ) target_compile_features( - codexui-pending-request-policy-test PRIVATE cxx_std_20 + codexui-current-protocol-adapters-test PRIVATE cxx_std_20 ) target_include_directories( - codexui-pending-request-policy-test PRIVATE src + codexui-current-protocol-adapters-test PRIVATE src + ) + target_link_libraries( + codexui-current-protocol-adapters-test + PRIVATE AISuite::OpenAICodex codexui-nodegraph ) add_test( - NAME codexui-pending-request-policy - COMMAND codexui-pending-request-policy-test + NAME codexui-current-protocol-adapters + COMMAND codexui-current-protocol-adapters-test ) set_tests_properties( - codexui-pending-request-policy PROPERTIES TIMEOUT 10 + codexui-current-protocol-adapters PROPERTIES TIMEOUT 10 ) add_executable( - codexui-ui-session-test - tests/codex/UiSessionTest.cpp - src/codex/UiSession.cpp - src/codex/UiSession.h - src/codex/PendingRequestPolicy.cpp - src/codex/PendingRequestPolicy.h - src/codex/PresentationClient.h - src/codex/PresentationModel.cpp - src/codex/PresentationModel.h - src/codex/PresentationProtocol.cpp - src/codex/PresentationProtocol.h - src/codex/PresentationStatus.h - src/codex/AttachmentDraft.h - src/codex/ui/UiViewProjection.cpp - src/codex/ui/UiViewProjection.h - src/codex/ui/UiViewState.h - src/codex/middle/ConversationProjection.cpp - src/codex/middle/ConversationProjection.h - src/codex/middle/MiddleTypes.cpp - src/codex/middle/MiddleTypes.h - src/codex/middle/PromptCoordinator.cpp - src/codex/middle/PromptCoordinator.h + codexui-client-runtime-dispatch-test + tests/codex/ClientRuntimeDispatchTest.cpp + src/codex/ClientRuntime.cpp + src/codex/ClientRuntime.h + src/codex/Configuration.cpp + src/codex/Configuration.h + src/codex/CurrentProtocolAdapters.h + src/codex/NodeGraphJson.cpp + src/codex/NodeGraphJson.h + src/codex/WorkerMailboxReceiver.cpp + src/codex/WorkerMailboxReceiver.h + ) + configure_codexui_target(codexui-client-runtime-dispatch-test) + add_test( + NAME codexui-client-runtime-dispatch + COMMAND codexui-client-runtime-dispatch-test + ) + set_tests_properties( + codexui-client-runtime-dispatch + PROPERTIES TIMEOUT 20 ) - target_compile_features(codexui-ui-session-test PRIVATE cxx_std_20) - target_include_directories(codexui-ui-session-test PRIVATE src) - add_test(NAME codexui-ui-session COMMAND codexui-ui-session-test) - set_tests_properties(codexui-ui-session PROPERTIES TIMEOUT 10) add_executable( - codexui-conversation-projection-test - tests/codex/ConversationProjectionTest.cpp - src/codex/middle/ConversationProjection.cpp - src/codex/middle/ConversationProjection.h - src/codex/middle/MiddleTypes.cpp - src/codex/middle/MiddleTypes.h - src/codex/middle/PromptCoordinator.cpp - src/codex/middle/PromptCoordinator.h - src/codex/AttachmentDraft.h + codexui-pending-request-policy-test + tests/codex/PendingRequestPolicyTest.cpp + src/codex/PendingRequestPolicy.cpp + src/codex/PendingRequestPolicy.h ) target_compile_features( - codexui-conversation-projection-test PRIVATE cxx_std_20 + codexui-pending-request-policy-test PRIVATE cxx_std_20 + ) + target_include_directories( + codexui-pending-request-policy-test PRIVATE src ) - target_include_directories(codexui-conversation-projection-test PRIVATE src) add_test( - NAME codexui-conversation-projection - COMMAND codexui-conversation-projection-test + NAME codexui-pending-request-policy + COMMAND codexui-pending-request-policy-test ) set_tests_properties( - codexui-conversation-projection PROPERTIES TIMEOUT 10 + codexui-pending-request-policy PROPERTIES TIMEOUT 10 ) qt_add_executable( @@ -301,7 +346,7 @@ if(BUILD_TESTING) ) target_include_directories(codexui-conversation-cards-test PRIVATE src) target_link_libraries( - codexui-conversation-cards-test PRIVATE Qt6::Widgets + codexui-conversation-cards-test PRIVATE codexui-nodegraph Qt6::Widgets ) add_test( NAME codexui-conversation-cards @@ -309,31 +354,24 @@ if(BUILD_TESTING) ) set_tests_properties( codexui-conversation-cards - PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + PROPERTIES TIMEOUT 35 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) qt_add_executable( codexui-application-layout-test - tests/codex/ApplicationLayoutTest.cpp + tests/codex/EstablishedUiUxTest.cpp src/codex/DiffViewer.cpp src/codex/DiffViewer.h src/codex/GitDiffProvider.cpp src/codex/GitDiffProvider.h src/codex/FileSelectionDialog.cpp src/codex/FileSelectionDialog.h - src/codex/PresentationModel.cpp - src/codex/PresentationModel.h - src/codex/PresentationProtocol.cpp - src/codex/PresentationProtocol.h src/codex/TurnSettingsWidget.cpp src/codex/TurnSettingsWidget.h src/codex/ui/ExpandingPromptEditor.cpp src/codex/ui/ExpandingPromptEditor.h src/codex/ui/UiStyle.cpp src/codex/ui/UiStyle.h - src/codex/ui/UiViewProjection.cpp - src/codex/ui/UiViewProjection.h - src/codex/ui/UiViewState.h src/codex/middle/ComposerPane.cpp src/codex/middle/ComposerPane.h src/codex/middle/ConversationCards.cpp @@ -355,7 +393,11 @@ if(BUILD_TESTING) target_include_directories(codexui-application-layout-test PRIVATE src) target_link_libraries( codexui-application-layout-test - PRIVATE AISuite::OpenAICodex PkgConfig::LIBGIT2 Qt6::Widgets + PRIVATE + AISuite::OpenAICodex + codexui-nodegraph + PkgConfig::LIBGIT2 + Qt6::Widgets ) add_test( NAME codexui-application-layout @@ -366,6 +408,37 @@ if(BUILD_TESTING) PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" ) + qt_add_executable( + codexui-inspector-graph-test + tests/codex/NodeGraphInspectorUiTest.cpp + src/codex/DiffViewer.cpp + src/codex/DiffViewer.h + src/codex/GitDiffProvider.cpp + src/codex/GitDiffProvider.h + src/codex/middle/InspectorPane.cpp + src/codex/middle/InspectorPane.h + src/codex/middle/MiddleTypes.cpp + src/codex/middle/MiddleTypes.h + src/codex/ui/NodeGraphUiAdapter.cpp + src/codex/ui/NodeGraphUiAdapter.h + src/codex/ui/UiStyle.cpp + src/codex/ui/UiStyle.h + ) + target_compile_features(codexui-inspector-graph-test PRIVATE cxx_std_20) + target_include_directories(codexui-inspector-graph-test PRIVATE src) + target_link_libraries( + codexui-inspector-graph-test + PRIVATE codexui-nodegraph PkgConfig::LIBGIT2 Qt6::Widgets + ) + add_test( + NAME codexui-inspector-graph + COMMAND codexui-inspector-graph-test + ) + set_tests_properties( + codexui-inspector-graph + PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen" + ) + qt_add_executable( codexui-git-changes-live-test tests/codex/GitChangesLiveTest.cpp diff --git a/README.md b/README.md index 2db31f2..ba2ad04 100644 --- a/README.md +++ b/README.md @@ -5,35 +5,34 @@ CodexUI 1.0 is a native Qt 6 Widgets and browser frontend for the AISuite without introducing another backend, semantic cache, snapshot store, or persistence authority. -The canonical process has two threads: +The native app-server/UI data path has two threads: ```text Qt GUI thread - <-> bounded nonblocking Unix socketpair + <-> typed bounded SPSC queues + one eventfd per direction SNode.C client thread <-> codex-bridge <-> Codex app-server ``` -The Qt thread owns widgets plus a toolkit-neutral `UiSession`, which owns the -`PresentationModel` and UI/UX state machine. Widgets exchange only semantic -intents and value snapshots with that boundary. `FrontendSession` adapts its -generic presentation client to the unchanged socketpair. The SNode.C thread -owns the event loop, selected transport, `AISuite::OpenAICodex` frontend proxy -SDK, native protocol normalization, and connection/controller telemetry. The -threads exchange only bounded `codexui.presentation` JSONL commands and events. +Both threads share one current `NodeGraph`. The SNode.C worker owns +CodexBridge, native app-server decode/encode, protocol-to-graph updates, and +all graph writes. Qt owns every widget and local interaction mechanic, reads +the graph only through non-blocking access, renders visible nodes in bounded +slices, and sends closed typed actions back to the worker. No app-server JSON, +serialized internal state, mirror model, or socketpair crosses this boundary. ## Applications -`codex-ui` is the canonical visual application. Its production shell renders -the neutral `UiSessionView` API and sends semantic intents; it does not consume -`PresentationModel` directly. There is no parallel legacy UI or alternate -application target. +`codex-ui` is the canonical visual application. Its production shell binds the +existing widgets directly to shared nodes and sends typed node/runtime actions. +There is no parallel legacy UI or alternate application target. `CodexWebUI` is the browser presentation. It uses the framework-neutral `@snodec/codex-frontend` SDK from AISuite, connects directly to the bridge over -WebSocket, and follows the same controller, prompt, thread, turn, projection, -and reconnect rules as the native application. Browser-only limitations are +WebSocket, and implements the same visible controller, prompt, thread, turn, +and reconnect behavior in its own TypeScript state path. It does not share the +native in-process graph or its widget binding. Browser-only limitations are listed in the [1.0 contract](docs/web-1.0-contract.md). ## Build @@ -87,10 +86,12 @@ are in [`web/README.md`](web/README.md). ## Architecture -The complete thread model, presentation protocol, authority rules, normalized -event vocabulary, public APIs, shell behavior, implementation report, and test -boundaries are documented in -[`docs/codex-architecture.md`](docs/codex-architecture.md). +The implemented native thread model, node/state authority rules, typed +mailboxes, protocol coverage, widget binding, and qualification boundaries are +documented in +[`docs/two-thread-shared-node-graph.md`](docs/two-thread-shared-node-graph.md). +[`docs/codex-architecture.md`](docs/codex-architecture.md) is a concise product +overview linking the native and browser-specific contracts. Current message routing, pending-prompt acknowledgment, scrolling, composer geometry, shell-output, Inspector, and desktop-integration decisions are diff --git a/design/ux-decisions/thread-turn-model.md b/design/ux-decisions/thread-turn-model.md index 393832c..8bcfa55 100644 --- a/design/ux-decisions/thread-turn-model.md +++ b/design/ux-decisions/thread-turn-model.md @@ -18,8 +18,9 @@ Thread ``` AISuite and the app-server are authoritative for thread, turn, item, and -configuration semantics. CodexUI retains only bounded presentation state and -client-local interaction state. +configuration semantics. CodexUI keeps their current local representation in +one shared `NodeGraph`; Qt retains only bounded widget mechanics, drafts, and +other genuinely local interaction state. ## Thread selection and routing @@ -77,28 +78,34 @@ ensures that a later prompt observes the active-turn state published by the preceding acknowledgment. Queues belonging to different threads are independent. -Only the correlated `turn.start` or `turn.steer` completion callback can -acknowledge a prompt. A successful callback begins a 500-millisecond accepted -transition. Every submission carries a unique `clientUserMessageId`, allowing +Only the correlated `turn/start` or `turn/steer` completion callback can +acknowledge a prompt. A successful callback immediately ends pending feedback. +Every submission carries a unique `clientUserMessageId`, allowing the authoritative user item to inherit the local card's stable visual key even when multiple prompts have identical text. Failure stops the animation and leaves an explicit error card. Prompt dispatch waits for once-per-connection-generation thread hydration. A -provider-marked `notLoaded` thread is resumed first. A transient -thread-not-found submission result triggers one resume-and-retry; a repeated -failure becomes the card's terminal error. Failed hydration leaves the composer -draft intact and requires an explicit reload before admission. Dispatch -rechecks connection and recovery ownership at its queued execution boundary, so -a disconnect cannot send and an in-flight resume cannot overlap a hydration -read or another turn operation. +provider-marked `notLoaded` thread is resumed first. Failed hydration leaves +the composer draft intact and requires an explicit reload before admission. +Dispatch rechecks connection and recovery ownership at its queued execution +boundary, so a disconnect cannot send and an in-flight resume cannot overlap a +hydration read or another turn operation. + +Once admitted, a non-idempotent prompt is never sent again automatically. A +thread-not-found result is terminal for that dispatch. If thread deletion or a +provider-generation reset races queued or in-flight work, the local prompt is +reparented to explicit recovery state with its exact admitted text and +attachment links retained. Its state records whether failure is definite or +the provider outcome is uncertain; reconnect, reload, and hydration never +resend it. A later attempt requires deliberate user action. ## Start, steer, and interrupt -- An idle loaded thread uses `turn.start`. -- An active thread uses `turn.steer` with the stable active turn ID. +- An idle loaded thread uses `turn/start`. +- An active thread uses `turn/steer` with the stable active turn ID. - A not-loaded thread is resumed before starting its turn. -- Stop uses `turn.interrupt` for the stable active turn ID. +- Stop uses `turn/interrupt` for the stable active turn ID. CodexUI does not fabricate turns or infer active identity from row position. @@ -113,13 +120,13 @@ window, while Steer adds input to an existing turn. Operational items remain individual cards inside their turn; there is no second Activity batch or arbitrary visible grouping. Pending prompts remain -thread-local presentation cards until acknowledgment supplies their +thread-local graph nodes until acknowledgment supplies their authoritative turn and item position. -`PresentationModel` is the retained normalized source. A pure projection adds -local prompt admissions and emits stable keyed turn sections and cards. Initial -display and all updates use the same reconcile path; retained card widgets are -mutated in place, and a visually identical projection performs no layout work. +The shared `NodeGraph` is the current native source. Local prompt admissions +are nodes in that same graph. Initial display and all updates use stable keyed +turn sections and cards; retained visible widgets are mutated in place, while +an invisible or visually unchanged node performs no widget layout work. ## Thread lifecycle actions diff --git a/docs/app-server-protocol/master-data-model.md b/docs/app-server-protocol/master-data-model.md index 2cb93c9..d682e01 100644 --- a/docs/app-server-protocol/master-data-model.md +++ b/docs/app-server-protocol/master-data-model.md @@ -1,4 +1,13 @@ -# Codex app-server protocol: complete framework-neutral C++ master data model +# Historical Codex app-server protocol and proposed master data model + +> **Historical, non-normative design research.** The pinned wire-method +> inventory and schema observations in this document remain useful protocol +> references. Its proposed journal, ledger, reducer, outbox, snapshot/cursor, +> persistence, replay, and adapter runtime are rejected and are not CodexUI's +> implemented architecture. The current native contract is +> [`../two-thread-shared-node-graph.md`](../two-thread-shared-node-graph.md): one +> current shared `NodeGraph`, exactly two relevant threads, two typed SPSC +> queues, and two Linux eventfds. ## Scope and reproducible baseline diff --git a/docs/codex-architecture.md b/docs/codex-architecture.md index 191cdbd..c789f7a 100644 --- a/docs/codex-architecture.md +++ b/docs/codex-architecture.md @@ -1,1618 +1,42 @@ -# CodexUI Architecture +# CodexUI architecture -## 1. Purpose +CodexUI presents the Codex app-server through the existing `codex-bridge`. +The app-server remains authoritative for provider data and persistence; CodexUI +keeps only current in-memory state and genuinely local interaction state. -CodexUI is a remote frontend for `codex-bridge`. It uses the AISuite -`ai::openai::codex` frontend proxy SDK and presents Codex app-server behavior -without introducing another backend, protocol authority, or retained semantic -store. +## Native application -The architecture keeps the existing transport boundaries and adds an explicit -in-process renderer boundary: +The native application uses one shared `NodeGraph` across exactly two relevant +app-server/UI threads: ```text -Qt widgets and dialogs - <-> semantic intents, neutral snapshots, notices, and narrow effects -UiSession (toolkit-neutral C++ UI/UX logic; on the Qt thread today) - <-> PresentationClient actions/results and normalized presentation frames -FrontendSession Qt/socketpair adapter - <-> normalized UI command/event protocol -SNode.C client runtime + codex frontend proxy SDK - <-> slim codex-bridge envelope over a selected SNode.C transport -codex-bridge - <-> native Codex app-server JSON-RPC -Codex app-server +Qt main thread + existing widgets, viewport, drafts and local interaction state + non-blocking reads from one NodeGraph + <-> bounded typed SPSC queues and two Linux eventfds +existing SNode.C worker thread + transport, CodexBridge, protocol decode/encode and sole graph writes ``` -The Codex app-server remains authoritative for Codex account, configuration, -model, thread, turn, item, plan, tool, approval, and persistence semantics. -`codex-bridge` adds multi-client routing and telemetry. CodexUI adds only -client-local interaction and presentation state. +There is no internal JSONL, socketpair payload path, presentation model, mirror +graph, snapshot history, or callback framework. The complete implemented +native contract, ownership rules, protocol inventory, backpressure behavior, +widget binding, and qualification are in +[two-thread-shared-node-graph.md](two-thread-shared-node-graph.md). -## 2. Runtime Object Graph +Concrete interaction and rendering behavior is specified in +[ui-behavior.md](ui-behavior.md). -CodexUI has two main operating-system threads. A Codex conversation thread is -a protocol object and is unrelated to these execution threads. +## Browser application -```text - CodexUI process - - Qt GUI thread SNode.C client thread - +------------------------+ +---------------------------+ - | Qt application loop | | SNode.C event loop | - | concrete widgets | | selected client transport | - | UiSession | | ClientConnection | - | PresentationModel | | frontend proxy SDK | - | FrontendSession | | protocol normalizer | - +-----------+------------+ +-------------+-------------+ - | | - | bounded full-duplex Unix socketpair | - +--------------------------------------------+ - | - v - codex-bridge - | - v - Codex app-server -``` - -The SNode.C side has the same principal application shape as -`codex-bridge-client`. The socketpair gateway replaces that application's -interactive stdin parser and terminal presenter: - -```text -Qt command gateway - -> ClientSession-equivalent dispatcher - -> ai::openai::codex::frontend::CodexBridge - -> frontend::client::ClientConnection - -> exactly one enabled SNode.C client transport -``` - -All objects have explicit application ownership. Socket contexts, -subprotocols, and factories borrow the SDK/mediator they need. No singleton is -required. - -## 3. Thread Ownership - -### 3.1 Qt GUI Thread - -The Qt thread exclusively owns: - -- `QApplication`, the Qt event loop, and all GUI objects; -- `UiSession`, including selected-thread intent, new-thread intent, prompt - admission and queues, hydration/recovery state, pending-request eligibility, - and the `PresentationModel`; -- concrete-only selected tab, scroll, expansion, composer-form draft, dialog, - focus, geometry, and paint state; -- `FrontendSession`, the Qt endpoint adapter and normalized operation-result - correlation; -- projection of neutral snapshots into widgets and translation of gestures - into semantic `UiSession` calls. - -Only the Qt thread may mutate Qt objects or `UiSession` today. `UiSession` has -no Qt types and does not require the Qt event loop; keeping it on this thread is -the current threading model, not a toolkit dependency. The Qt side performs no -bridge transport, app-server framing, native JSON-RPC correlation, or typed -app-server decoding. - -### 3.2 Renderer/Logic API - -`UiSession` is the authoritative UI/UX state owner. Its public surface is -deliberately small and protocol-complete: - -- renderer input consists of semantic calls such as select, submit, reload, - interrupt, resolve request, or configure connection; -- normalized `codexui.presentation` frames enter through - `onPresentationFrame`, and transport activity enters through the stable - thread identity only; -- rendering reads one aggregate `UiSessionView` containing toolkit-neutral - thread, conversation, inspector, settings, connection, request, and - optimistic-thread snapshots; -- one-shot notices and narrow effects cover only concrete work such as clearing - or focusing the composer and preserving its local-admission scroll behavior; -- an optional read-only frame observer feeds the bounded Protocol diagnostic - without giving that renderer state or replay authority; -- absolute wakeups let the existing Qt timer drive deferred dispatch and the - pending-feedback threshold without introducing another scheduler. - -Downward communication uses the value-type `PresentationClient`: generic -correlated `execute(action, data, completion)`, fire-and-forget -`send(action, data)`, and `respond(requestId, result, error)`. It contains no Qt, -socket, thread, or inheritance contract. `FrontendSession` supplies those three -functions over the unchanged Qt socketpair endpoint. Consequently another UI -toolkit can consume the same C++ UI/UX logic by rendering the snapshots and -supplying an equivalent presentation-protocol adapter; it does not need to -inherit from or instantiate a Qt widget. - -This separation is not a new live protocol or execution architecture. The -class still runs on the GUI thread, the bounded socketpair remains the sole -cross-thread queue, and protocol version 1 is unchanged. It makes a later move -of the neutral logic possible without making that move part of this refactor. - -### 3.3 SNode.C Client Thread - -The SNode.C thread exclusively owns: - -- the SNode.C event loop; -- the selected frontend transport and its connection lifecycle; -- `ai::openai::codex::frontend::CodexBridge`; -- `frontend::client::ClientConnection` and transport adapters; -- frontend SDK method execution and callbacks; -- bridge-envelope and native app-server message classification; -- app-server JSON-RPC request/response/server-request correlation; -- typed protocol decoding and normalization into bounded UI events; -- bridge connection, role, and diagnostic telemetry. - -The SNode.C thread never accesses widgets or Qt presentation objects. - -Conversation discovery remains latency-sensitive. On connection CodexUI asks -only for the thread list; selecting a thread can therefore issue its -`thread/read` without waiting behind unrelated catalog traffic. The complete -account, configuration, model, permission, skill, hook, plugin, app, and MCP -catalog set is queried lazily when its presentation surface is opened. These -are fresh app-server requests, not a CodexUI or bridge cache. - -The shared provider handshake is owned by `codex-bridge`, not by any frontend. -Its `initialize` request advertises `experimentalApi: true`, making the complete -generated experimental feature types and typed list/enablement operations -available through the frontend proxy SDK. CodexUI does not perform a second -provider initialization. - -Without endpoint configuration, CodexUI selects AISuite's shared per-user -runtime path (`XDG_RUNTIME_DIR` when private, otherwise -`/tmp/codex-bridge-/codex-bridge.sock`), so it discovers a default -`codex-bridge` instance without a configuration file. - -Bridge provider lifecycle is normalized as `connection.provider` with an -independent provider generation. Disconnect or generation change completes all -outstanding UI operations exactly once, clears provider-scoped presentation -state, and rehydrates the selected thread after the new provider reports -`ready`. Late results from a retired generation are ignored. - -## 4. Inter-Thread Socketpair - -One unnamed full-duplex Unix socketpair is the only cross-thread transport: - -```text -Qt endpoint: commands ->, events <- - AF_UNIX SOCK_STREAM socketpair -SNode.C endpoint: commands <-, events -> -``` - -The implementation uses: - -- `AF_UNIX`, `SOCK_STREAM`, `SOCK_NONBLOCK`, and `SOCK_CLOEXEC`; -- one endpoint registered with Qt through `QSocketNotifier`; -- one endpoint registered with the SNode.C descriptor event system; -- bounded JSONL frames in both directions; -- bounded socket and application write queues; -- exclusive endpoint ownership and deterministic close behavior. - -The Qt endpoint retains queued output as independently owned chunks, releases -each consumed chunk immediately, and limits read and write work per notifier -activation. Both endpoints treat framing or dispatch failure as terminal. - -The socket buffers are both the bounded queues and the readiness mechanism. No -parallel in-memory queue, condition variable, eventfd, or other wakeup -descriptor is added. - -The local `SocketPair` follows the ownership shape of SNode.C's -`core::pipe::Pipe`: movable, noncopyable, error-reporting, and responsible for -closing descriptors it still owns. Its endpoint adapters contain no CodexUI -presentation policy so the primitive can move into SNode.C later. - -Named pipes/FIFOs are not used. They add names, filesystem cleanup, directional -composition, and discovery semantics that two threads in one process do not -need. Socketpair overhead is immaterial for the expected control/event volume. - -## 5. CodexUI Presentation Protocol v1 - -### 5.1 Boundary and Reuse - -The Codex app-server protocol terminates in the SNode.C thread. Every normal -socketpair message uses the presentation protocol identified by: - -```json -{"protocol":"codexui.presentation","version":1} -``` - -No bridge envelope, JSON-RPC envelope, native app-server method name, Qt object -name, widget pointer, or widget identifier is part of the normal contract. Qt -does not parse app-server methods or correlate app-server JSON-RPC IDs. - -The protocol is transport-neutral JSON. A stream transport carries one bounded -JSON object per JSONL line. A browser WebSocket carries the same object in one -text message. Browser and Qt consumers therefore share the same reducer and -event semantics without sharing Qt classes or the internal socketpair. - -### 5.2 Frame Grammar - -Exactly three frame kinds cross the socketpair: - -| `kind` | Direction | Purpose | -| --- | --- | --- | -| `command` | UI to SNode.C | Asynchronous user or lifecycle intent | -| `result` | SNode.C to UI | One terminal result for a correlated command | -| `event` | SNode.C to UI | Unsolicited presentation-state or diagnostic update | - -All frames contain `protocol`, `version`, and `kind`. A command contains -`action` and `data`; commands expecting a result also contain -`correlationId`. A result contains `action`, `correlationId`, `ok`, and either -`data` or `error`. An event contains `type` and `data`. - -Every SNode.C-to-UI frame contains: - -- `sequence`: process-local, monotonically increasing output sequence; -- `generation`: bridge connection generation; -- `authority`: `none`, `merge`, `replace`, or `remove`; -- optional `scope`: stable `threadId`, `turnId`, `itemId`, `requestId`, or - `processId` identities represented by the frame. - -`correlationId` identifies one asynchronous command/result exchange. It never -identifies a widget or a presentation entity. Widgets are reached indirectly -through the reducer using stable IDs in `scope` and domain data. - -Sequence zero is reserved for a Qt-local diagnostic that did not cross the -socketpair. Such a diagnostic has no state authority. - -Provider generation is scoped to one frontend connection generation. A new -frontend connection invalidates former provider readiness and accepts the new -bridge's provider counter from its own initial value. Explicit transport or -provider loss remains the authority that clears provider-owned projection. - -### 5.3 Authority - -Authority has one meaning across all domains: - -- `none`: telemetry, notice, or diagnostics; no retained-domain authority; -- `merge`: update only represented fields and preserve omitted fields; -- `replace`: replace exactly the represented scope and collection completeness; -- `remove`: remove exactly the stable scope identified by the frame. - -An omitted field is unchanged. It is never an implicit deletion. Empty data is -authoritative only when accompanied by `replace` or `remove` for an explicit -scope. Unknown event types and diagnostics never mutate retained conversation -state. Authority-free telemetry is retained only in its bounded diagnostic -buffer and never materializes domain or thread state. Removal may delete -existing scoped state, but never creates an absent scoped owner. - -### 5.4 UI-to-SNode.C Commands - -The v1 command catalog used by the application is: - -| Action | Result | Meaning | -| --- | --- | --- | -| `runtime.shutdown` | yes | Acknowledge and drain, then stop the SNode.C runtime | -| `connection.connect` | no | Connect the selected configured frontend transport | -| `connection.disconnect` | no | Explicitly disconnect the selected frontend transport | -| `connection.reconnect` | no | Explicit bridge transport reconnect | -| `connection.configure` | yes | Apply a transient endpoint selection and connect it | -| `controller.claim` | no | Request controller ownership | -| `controller.release` | no | Release controller ownership | -| `threads.list` | yes | Discover threads without deletion authority | -| `thread.read` | yes | Read one thread with full turns where available | -| `thread.create` | yes | Start a thread | -| `thread.resume` | yes | Resume a thread through app-server semantics | -| `thread.fork` | yes | Fork a thread through app-server semantics | -| `thread.rename` | yes | Set a thread name | -| `thread.archive` | yes | Archive a thread | -| `thread.unarchive` | yes | Unarchive a thread | -| `thread.delete` | yes | Delete a thread | -| `models.list` | yes | Read the available model catalog | -| `turn.start` | yes | Start a turn in an idle thread | -| `turn.steer` | yes | Steer the identified active turn | -| `turn.interrupt` | yes | Interrupt the identified active turn | -| `pending-request.resolve` | no | Send typed result/error for a server request | -| `diagnostic.raw.send` | no | Explicit development-only native JSON path | - -The implemented typed action catalog additionally covers: - -- thread goals, metadata, sections, compaction, rollback, shell commands, - guardian decisions, item injection, loaded-thread discovery, and unsubscribe; -- reviews and experimental-feature listing and enablement; -- account read, login, login cancellation, logout, rate limits, token usage, - reset-credit consumption, credit nudges, and workspace messages; -- configuration read, requirements read, single-value write, and batch write; -- model-provider capabilities and permission-profile discovery; -- skills, hooks, marketplaces, plugins, plugin sharing, and apps; -- MCP status, refresh, OAuth login, resource reads, and tool calls; -- filesystem reads, writes, metadata, directory operations, copy/remove, and - watch management; -- one-off command execution, stdin writes, resize, and termination; -- external-agent configuration discovery/import/history, fuzzy file search, - feedback upload, and Windows sandbox setup/readiness. - -Every action is dispatched through its generated AISuite codex operation type. -`UiSession` sends semantic presentation action names and typed `data` through -`PresentationClient` and `FrontendSession`; native app-server method names do -not cross the regular socketpair contract. `initialize` and `initialized` are -deliberately absent because the bridge owns the one shared provider handshake. - -Commands are asynchronous. No Qt call blocks waiting for SNode.C. Unsupported -correlated actions receive one `result` with `ok:false` and a structured error. - -### 5.5 SNode.C-to-UI Results - -Results preserve their originating `action` and `correlationId`. The currently -reduced result payloads are: - -- `threads.list`: `threads`, `nextCursor`, and `backwardsCursor`, with `merge`; -- `thread.read`: returned `thread`, with `replace` when no newer presentation - event arrived after the read began, otherwise `merge` so a late snapshot - cannot erase newer live Plan, Agent, command, or turn-diff domain detail; -- `thread.create`, `thread.resume`, and `thread.fork`: returned `thread`, with - `merge`; -- `thread.rename`, `thread.archive`, `thread.unarchive`, and `thread.delete`: - terminal operation status; their app-server notifications carry state - authority; -- `models.list`: `models` and `nextCursor`, with `replace`; -- `turn.start`: returned `turn`, with `merge` scoped to its thread; -- all other successful actions: typed result data with `none` until a reducer - explicitly declares a presentation scope. - -A failed result contains a structured `error` and has no state authority. - -### 5.6 Event Vocabulary - -The core retained-state events are: - -- `thread.upsert`, `thread.name.changed`, `thread.status.changed`, - `thread.lifecycle`, and `thread.removed`; -- `turn.upsert`, `turn.diff.changed`, `turn.moderation.changed`, and - `plan.replaced`; -- `conversation.item.upsert`, `conversation.item.append`, - `conversation.command.interaction`, `conversation.file-change.output-appended`, - `conversation.file-change.patch-replaced`, `conversation.mcp.progress`, and - `conversation.reasoning.part-added`; -- `agents.activity.upsert`; -- `pending-request.upsert` and `pending-request.removed`. - -Connection and operational events are: - -- `connection.lifecycle`, `connection.bridge`, `connection.controller`, and - `connection.remote-control.changed`; -- `terminal.command.output-appended`, `terminal.process.output-appended`, and - `terminal.process.completed`; -- `activity.hook.started` and `activity.hook.completed`; -- `approval.review.started`, `approval.review.completed`, and - `approval.strict-review.required`. - -Catalog, account, settings, and workspace events are: - -- `account.changed`, `account.rate-limits.changed`, and - `account.login.completed`; -- `catalog.skills.invalidated` and `catalog.apps.changed`; -- `integration.mcp.login-completed`, `integration.mcp.status-changed`, and - `integration.mcp.event`; -- `workspace.project.changed`, `workspace.files.changed`, - `workspace.search.changed`, and `workspace.search.completed`; -- `settings.external-agent-import.progress` and - `settings.external-agent-import.completed`; -- thread goal, queue, project, environment, settings, token-usage, compacted, - and reverted events under the `thread.*` namespace; -- model reroute, verification, and safety-buffering events under `model.*`. - -Realtime and platform events are normalized under `realtime.*` and `system.*`. -Warnings and errors use `notice.added`. Unknown or malformed input uses -`system.diagnostic`. Every generated app-server notification is either mapped -to one of these semantic event types or produces a diagnostic-only event; it is -never forwarded as generic presentation state. - -### 5.7 Pending Requests - -All app-server server-request families normalize to -`pending-request.upsert`. Its data contains the native stable request ID, a -presentation category, and typed request data. Categories are: - -- `command-approval`, `file-change-approval`, `user-input`, - `mcp-elicitation`, and `permissions-approval`; -- `dynamic-tool-call`, `authentication-refresh`, and `attestation`. - -Resolution uses `pending-request.resolve` in the other direction and -`pending-request.removed` when authoritative resolution is observed. Secret -request content is not copied into diagnostics. The web Protocol history keeps -only request identity, category, and a redaction marker; the transient typed -request remains available exclusively through the Requests decision surface. - -### 5.8 Raw JSON and Compatibility - -The codex SDK preserves complete native app-server JSON and unknown fields in -its generated C++ values on the SNode.C side. The regular socketpair boundary -carries bounded normalized presentation data, including only the native fields -needed to render and answer a pending request. The request object is retained -transiently until that request is resolved and is never rendered as a raw dump. -Arbitrary raw JSON crosses the boundary only through the explicit bounded -`diagnostic.raw.send` development action. Raw data is not normal UI state, -deletion authority, or an escape from typed normalization. - -Consumers reject an unsupported protocol name or major version. They ignore -unknown semantic event types without deleting state. New optional fields, -actions, and event types are backward-compatible within version 1 when old -consumers can safely ignore them. Any change to frame meaning, authority, or -identity requires a new major version. - -## 6. Presentation Authority and Reduction - -`UiSession` owns `PresentationModel`, the sole retained authoritative store for -normalized presentation state. Only neutral projection code inside the logic -boundary reads it. Qt widgets consume value snapshots and do not retain -competing copies of thread, turn, item, plan, agent, request, or global-domain -state. Widget-local scroll, expansion, sorting, focus, and paint caches are -presentation mechanics, not another semantic store. The app-server remains the -semantic and persistence authority, so the model is not a persistence layer or -substitute for app-server history. - -Presentation reduction follows these rules: - -1. Stable `threadId`, `turnId`, `itemId`, agent-thread ID, and request ID define - identity; row position never defines identity. -2. Incremental events merge only fields they represent. -3. Deltas append to the identified field of the identified item. -4. A richer completed item is not degraded by a later partial item view. -5. Authoritative replacement is honored only when the normalized event marks - the represented scope and completeness explicitly. -6. Explicit removals remove exactly their identified scope. -7. Unknown, malformed, stale-generation, or diagnostic-only events do not - mutate retained presentation content. -8. Thread/turn completion does not itself remove completed activity. - -This prevents an incomplete publication from acquiring accidental deletion -authority while preserving the app-server's explicit authority. - -## 7. Thread Selection and Interaction - -The selected Codex thread is user-owned UI state. - -- A thread created or updated by another frontend does not change selection. -- Incoming activity in a parallel thread does not change selection. -- Controller changes, reconnects, list refreshes, and read completions do not - change selection merely because another thread is newer. -- User selection changes the selected thread. -- A user-initiated local new-thread action may select its returned thread as - part of that same explicit intent. -- An explicitly removed selected thread may clear selection. - -There is no automatic switch to the newest, active, or newly created thread. - -For an idle selected thread, submitting a prompt starts a new turn. For an -active selected turn, a steering action uses the app-server steering operation -rather than fabricating another local turn. Interrupt targets the stable active -turn ID. - -Switching threads or inspector tabs while turns, plans, commands, agents, or -requests are changing must not stop, reset, or reorder those lifecycles. - -Selecting a thread hydrates it once per bridge connection, including when the -thread-list projection already reports materialized or active turns. The -`thread.read` result is merge-authoritative: it fills reconstruction data but -does not erase retained live-only Plan, Agent, or turn-diff domain details that -the provider omits. This explicit hydration state prevents a partial discovery -projection from being mistaken for an operation-ready thread. Reload is the -explicit forced fresh-read operation. - -### 7.1 Upcoming-Turn Settings - -The real shell has a codex-native upcoming-turn settings surface populated from -the neutral `UiSession` settings snapshot. Its primary controls are: - -- model and model-constrained reasoning effort; -- sandbox access and the sandbox-native network choice; -- workspace; -- approval policy; -- personality/style. - -The compact More menu contains the named permission profile, approval -reviewer, service tier, reasoning summary, and collaboration mode. Model, -effort, service-tier, and permission-profile choices are populated from fresh -app-server catalogs. A named permission profile and a sandbox policy are -mutually exclusive, matching the native app-server contract. An explicit Access -or Network choice therefore returns the permission-profile control to Thread -default and submits the selected sandbox policy. Other individual controls keep -the active permission profile and submit their supported app-server overrides. - -The settings object is a transient draft bound to the stable selected thread -identity. User changes are serialized into native `thread/start` and -`turn/start` fields; untouched fields remain omitted so UI defaults cannot -replace provider state. Collaboration mode is the deliberate exception: -app-server may retain Plan mode without returning it from a later -`thread/read`, so every `turn/start` explicitly sends the Code or Plan mode -currently displayed by CodexUI. The new-thread workspace always has an -explicit local fallback. Settings are disabled while steering because -`turn/steer` does not accept upcoming-turn configuration. No setting is -persisted by CodexUI or treated as canonical before the app-server publishes -it. - -### 7.2 Thread Creation and Per-Thread Actions - -New thread creation starts with a canonical custom dialog. It captures the -workspace, optional thread name, optional base and developer instructions, and -the native ephemeral flag. The dialog creates only a transient draft. CodexUI -does not create an empty provider thread until the user submits the first -prompt, so canceling or switching away cannot leave a phantom app-server -thread. Model, reasoning, access, permission, style, service-tier, reviewer, -and collaboration choices remain in the shared upcoming-turn controls rather -than being duplicated in the dialog. - -Accepting the dialog immediately creates one optimistic thread-list row without -inserting a synthetic thread into `PresentationModel`. The row uses a stable -visual identity and an orange pending sweep. A successful `thread/start` -rekeys that same row to the authoritative thread ID, but it remains pending -until the matching first `turn/start` callback succeeds. That callback switches -the existing row to canonical presentation; failures stop animation and retain -the row with an explicit failure state. Native and web follow the same -lifecycle. - -Workspace selection uses the shared custom file browser in directory-only -mode. It validates that the selected directory exists and returns an absolute -local path. The accepted workspace is encoded as the native `thread/start` -`cwd`; CodexUI does not persist it as an application preference. - -The visual shell's thread sidebar has no global More menu. A right-click -context menu is created for the stable thread ID under the pointer and exposes -Reload, Rename, Fork, Archive/Unarchive, and Delete. Read-only Reload remains -available to an observer while the app-server provider is ready; mutations -require a provider-ready connected controller. Provider loss keeps the selected -stable ID only as a rehydration hint, disables admission, and cannot route a -prompt to a thread that is no longer present in provider authority. -Opening or invoking the menu does not select the row or disturb the thread -currently being reviewed. - -### 7.3 Message Attachments - -The composer opens the same custom file browser in multi-file mode. It supports -up to sixteen unique files and reports detected MIME type and size. Local -admission moves the prompt and attachments into a per-thread pending card and -immediately clears the composer so another prompt can be entered. Images become -native `localImage` input and audio becomes `localAudio`. Other files are -appended to the admitted prompt as Markdown links to their local paths, so the -temporary and authoritative cards carry the same durable representation. - -These are app-server local-path references, not bytes uploaded through -`codex-bridge`. The provider must be able to access the selected path. This is -correct for a local CodexUI/app-server workspace and remains explicit for a -remote bridge topology; adding remote file transfer would require a separate -bounded protocol and security design. - -Image paths are retained in pending and authoritative user-message -presentation. The conversation shows bounded thumbnails below the Markdown -prompt in one source-ordered horizontal ribbon; overflow scrolls horizontally -without wrapping or widening the card. The ribbon uses the canonical dark -surface and vertically centers each preview with equal top and bottom clearance. -Selecting a thumbnail opens a non-modal, -fit-to-window viewer. CodexUI never fetches remote image URLs implicitly, and -missing local images remain visible as unavailable placeholders. - -Authoritative `imageGeneration` items use their app-server `savedPath` and the -same thumbnail/viewer. Their Base64 `result` is transport data and is never -rendered as text. Authoritative `imageView` items use their local `path` and the -same presentation with the neutral title `Image`. Unknown item types retain a -generic diagnostic card, but its visible JSON is bounded before Qt performs -text layout. - -Conversation-card folding is presentation state, not protocol state. Each -stable visual card key retains its user-selected collapsed state in the -`ConversationView` for the UI session. New message cards default expanded and -new activity cards default collapsed. The card owns one header and one content -container, so streamed payload updates remain live while folded without -changing visible height. `ConversationView` owns the fold geometry transaction, -including title anchoring within the natural scroll range, alongside its -existing single-owner scrolling calculations. Expansion scrolls only as needed -to reveal the complete card when it fits in the unobscured viewport above any -grown composer overlay. At the lower limit, normal range clamping may move the -selected title rather than creating artificial blank space. - -### 7.4 Changes and Diff Presentation - -The Changes inspector is authoritative over the local Git worktrees associated -with the selected thread. It does not use app-server `turn.diff.changed` or -`fileChange` messages as review content. `ThreadPresentation` retains bounded, -deduplicated command working directories and changed-path hints from the -thread's authoritative items. The provider resolves each directory upward with -libgit2, deduplicates repository roots, validates ambiguous paths against the -worktree, index, and HEAD, and persists the resolved roots per thread. A path -that is currently changed ranks above the same clean tracked path; equal-rank -matches remain available together. It never performs a recursive downward -workspace search. - -Resolved roots are synchronously persisted in QSettings and loaded from either -the native string-list representation or the scalar representation used by the -INI backend for a single root. Consequently, restart hydration does not depend -on historical command items being present in `thread.read`. - -The repository selector defaults to All repositories when several candidates -match. Candidate paths containing a dot-prefixed directory are excluded by -default; the persistent Hidden option explicitly includes them. The provider -exposes Unstaged, Staged, and Since HEAD scopes. Untracked -content—including files created outside CodexUI—renames, copies, deletions, -type changes, conflicts, and binary metadata come from libgit2. A folder -outside Git remains a valid Codex workspace, but its Changes tab reports that -review requires a repository. - -The Inspector contains a compact unified preview with stable file selection, -addition/deletion counts, Copy, Open review, and file-double-click review. The -modeless Change Review window remains usable beside the conversation and offers -Unified or Side by side layout plus Compact or Expanded context. Preferences -persist across threads. Repository collection runs outside the UI thread, -superseded results are discarded, and a thread/workspace context change -synchronously cancels the prior generation before adopting the new identity. -An old result therefore cannot be rendered or persisted under the newly -selected thread. Rendered diff content is bounded to 16 MiB with an explicit -truncation state. Every returned file carries its resolved -absolute pathname. CodexUI watches existing changed files and their parent -directories, then debounces filesystem events into a fresh libgit2 snapshot. -Parent-directory watches keep deletion, recreation, rename, and atomic file -replacement consistent. A visible-only two-second refresh remains the safety -net for newly created files in previously unwatched nested directories and for -index-only changes. Files disappear from selection as soon as libgit2 reports -that they are clean again. - -### 7.5 Conversation Projection and Prompt Admission - -The selected conversation snapshot is a pure projection inside `UiSession` of -`PresentationModel` plus client-local prompt admissions. Its one structural -grouping level is the app-server turn: each retained turn contributes one -transparent section, and its items remain in exact server order. A turn is -identified only by its stable turn ID; CodexUI does not infer a turn boundary -from a user-message card. - -Authoritative cards use the stable `(threadId, turnId, itemId)` identity. -Locally admitted cards use a process-wide submission identity that remains -stable when a new-thread draft receives its app-server thread ID. Initial -render and later updates use the same keyed reconcile path. Existing widgets -are updated in place, absent keys are removed, new keys are inserted at their -projected positions, and an identical typed projection is a true visual no-op. - -Prompt admission and app-server acknowledgment are separate states. On Send or -Steer, CodexUI immediately appends a calm client-local user card with an -emphasized blue or teal border to the destination thread. If the correlated -app-server result has not arrived after one second, a Qt-painted highlight -begins sweeping left and right. Only the matching `turn.start` or `turn.steer` -completion callback acknowledges the prompt; conversation events cannot infer -acknowledgment. Each request carries a unique `clientUserMessageId`, allowing -the resulting user item to bind exactly even when prompts have identical text. -The matching success or definitive failure stops the sweep immediately; the -one-second wakeup changes presentation only and cannot acknowledge a request. -Pending cards survive thread switching and -become normal authoritative user messages when the corresponding app-server -item materializes. The pending and authoritative forms share one visual key, -anchor, and active-turn border during that transition. Once materialization and -acknowledgment are complete, the local submission is removed and the retained item -uses its authoritative identity. Failure produces a retained error card. - -The composer remains enabled while acknowledgments are outstanding. Multiple -prompts may be admitted, but CodexUI dispatches them sequentially per thread so -each operation observes the turn state established by the preceding result. -Queues for different threads are independent. New-thread prompts remain bound -to the explicit creation draft until `thread.create` returns its stable ID. -Dispatch waits for explicit connection-generation thread hydration. A -provider-marked `notLoaded` thread is resumed first, and a transient -thread-not-found submission result permits exactly one resume-and-retry before -becoming a terminal error. Failed hydration rejects local admission without -clearing the composer draft; an explicit reload is required before sending. -Transport eligibility is rechecked at the queued dispatch boundary. Internal -session cancellations caused by provider or bridge generation loss are marked -as transient: an in-flight prompt returns to its queue, fresh hydration runs, -and bridge-open then re-drives dispatch. Ordinary app-server error results are -not marked and remain terminal. An in-flight resume gates both hydration reads -and turn operations. - -The conversation smoothly follows new content only while its vertical scrollbar -is at the bottom. Geometry bursts retarget a short monotonic animation to the -latest maximum. Manual upward scrolling interrupts that animation immediately -and pauses following until the user returns to the bottom. Programmatic Qt -range clamps from card reflow do not change this user-owned state. While paused, -the first visible stable card and its viewport offset anchor the reading position -across appends, card reflow, and reconstruction. Wheel and touchpad events over -non-scrollable center-pane chrome and splitter handles are forwarded to the -conversation. Command text and output retain a gesture that began while they -could move in its direction, including later updates at the reached boundary. -A fresh outward gesture begun at an existing boundary is routed to the -conversation. -Follow/pause mode and the stable anchor are stored per thread and restored when -the user returns to that thread. - -The update pipeline compares each card's typed visible projection. -Protocol-only changes cannot mutate widgets or scroll state. All visible item -changes in one reconcile are measured and applied inside one paint-suppressed -layout transaction, followed by one scroll settlement. This is especially -important for Command execution cards, whose streaming output and bounded -nested viewer alter geometry. New authoritative items are inserted at their -server-ordered layout position without rebuilding retained cards. While -following is paused, the effective history window expands with appends so its -stable visual anchor cannot be evicted; the requested bound is restored when -following resumes. - -Streamed scalar text and indexed reasoning/content parts share a 256 KiB -retained budget per item field in both reducers. Crossing that threshold drops -the oldest complete UTF-8 prefix and retains a 192 KiB tail, leaving amortized -space for further deltas. Full item hydration and completion payloads pass -through the same bound. The item retains the discarded-byte count separately, -and projection visibly places that count before the retained tail; truncation -is therefore bounded, explicit, and never mistaken for complete output. - -The bottom composer overlay has a canonical in-layout reservation. As multiline -input, attachments, settings, or attention controls grow beyond that height, -the conversation viewport keeps its geometry and the composer overlays its -lower portion. A content-owned logical trailing extent grows by the same extra -height, extending the natural `QScrollArea` range so the final card can be -scrolled to the overlay boundary. Permanent scroll-owned bottom padding is not -used; the moving composer owns the standard divider with the canonical 8 px -vertical spacing on both sides and 10 px horizontal outset beyond its adjacent -content. The scrollbar maximum is never assigned manually. - -Trailing-extent growth temporarily suppresses range-driven bottom following -and restores the previous scrollbar value, so existing messages do not move. -Reaching the new maximum re-enables following. Composer contraction removes the -extent; Qt may clamp the value to the reduced range, and being at that maximum -re-enables following for later content. - -### 7.6 Command Execution Output and Info Viewers - -Command execution output controls exist only for printable, non-whitespace -output after terminal control sequences are ignored. Empty, whitespace-only, and -ANSI/control-only results create no black output surface. A shown control grows -from zero content height to a 220-pixel maximum. Its width-dependent content -height is measured synchronously inside the conversation update transaction. -Streaming output and command completion mutate the retained outer card in -place; a protocol update with an unchanged visible fingerprint touches neither -the widget nor scroll state. Beyond the maximum the output control uses the -shared styled vertical scrollbar. It follows appended output only while already -at its bottom; manual upward scrolling pauses following, and the state is -retained across in-place output updates. The bounded command-text control uses -the same gesture-boundary ownership as the output control. - -The Info tab's State and Protocol viewers use the same scrollbar styling and -show vertical scrollbars only when required. The Protocol log owns the tab's -expanding region and its statistics summary is placed below the log. Inspector -Plan, Agents, and Requests content is read from retained per-thread -presentation snapshots. Changes is instead refreshed from the selected -thread's local Git worktree and is independent of protocol-frame retention. - -## 8. Plans and Agents - -### 8.1 Plans - -`turn/plan/updated` is the canonical structured plan update. A normalized plan -replacement carries the thread ID, turn ID, optional explanation, and ordered -steps with `pending`, `inProgress`, or `completed` status. - -Plan presentation is retained across tab and thread switching. It changes only -for the identified turn and is cleared only by an explicit authoritative empty -or replacement event for that turn. The Inspector is the production owner of -structured plans, so they are not duplicated in the conversation. The typed -turn-level conversation key, conversion, placement, and renderer are retained -behind a disabled projection switch for narrow reactivation. Textual `plan` -items remain supported conversation content and use the same card renderer. -When no structured plan survives a fresh `thread/read`, the Plan inspector -renders the newest retained textual plan item as a read-only compatibility -view; it never overrides a newer authoritative structured turn plan. - -### 8.2 Agents - -Agent presentation is derived from typed collaboration data, especially -`collabAgentToolCall` and `subAgentActivity` items. It retains, when supplied: - -- tool operation and stable item ID; -- sender thread ID; -- receiver/agent thread IDs; -- prompt; -- requested model and reasoning effort; -- current tool-call status; -- last known per-agent state and path/activity details. - -Completion must not collapse this information into only a generic -"Subagent activity completed" row. Completed and failed agent activity remains -inspectable as part of its owning turn. Later partial events may update status -without erasing richer agent identity or prompt data. - -Only spawn operations create agent rows. Provisional spawn starts without a -child identity are not independently presented, and `wait`, `sendInput`, and -other collaboration operations update an already identified child rather than -being counted as additional agents. Once supplied, the child thread ID is the -stable presentation identity across spawn completion, child activity, wait, -and result events. - -App-server may publish a parent `subAgentActivity(kind=started)` and later -complete the child thread without replacing the parent item with a completed -variant. CodexUI correlates those authoritative records by `agentThreadId` and -projects child turn status and retained child result into the original parent -activity. This is transient presentation correlation, not backend state. -Identified subagent implementation threads remain addressable for correlation -but are omitted from the ordinary top-level thread list. When one is already -user-selected, the sidebar retains that visible row across subsequent -navigation for the session. An authoritative thread removal still drops it. - -The Agents view follows the currently selected thread; it never selects an -agent thread or parent thread automatically. - -## 9. Pending Requests and Attention State - -App-server-initiated requests are normalized into explicit pending-request -events using the native stable JSON-RPC request ID and associated thread ID. -Supported request families include approvals, user input, MCP elicitation, -permission approval, dynamic tool calls, and other generated server-request -types. - -The Requests view presents each pending request independently. Command and -file-change approvals use native decision enums, user-input answers preserve -question IDs and support options/free text/secret input, MCP form responses -return structured JSON, and permission approvals preserve the requested -permission object and selected turn/session scope. Every requested permission -field, including unknown future fields, is disclosed as literal structured -detail before approval. Dynamic tools unavailable -in CodexUI return a typed failed-tool response. Authentication, attestation, -and unknown capabilities receive an explicit JSON-RPC error rather than -remaining pending indefinitely. Canceling the dialog itself does not resolve -the request. Provider-supplied request text is always rendered literally; the -explicit MCP URL link is the only rich-text label and its URL is HTML-escaped. - -The UI attention/brown state is derived only from currently unresolved pending -requests associated with that thread. It is not inferred from historical item -status or retained across process restart without fresh provider evidence. -Response actions require a ready provider, current controller ownership, and -an exact match of request identity, connection generation, provider generation, -kind, thread, and content. After one response is sent, the request remains -authoritative but visibly disabled until its removal arrives; repeated clicks -cannot emit duplicate responses. - -A pending request is retired exactly once when: - -- its typed response/error is accepted and the corresponding resolution is - observed; or -- `serverRequest/resolved` identifies that same request; or -- the owning connection/generation terminates and the request can no longer be - answered by this frontend. - -Resolution matching uses stable request identity plus available thread and -connection generation context. The presentation request record retains that -generation. A mismatch is diagnostic and must not retire an -unrelated request. Resolved request content is removed from actionable UI while -non-secret lifecycle diagnostics may remain observable. - -## 10. Controller and Observer Roles - -The bridge permits one controller and multiple observers. - -- The controller may mutate Codex state, steer turns, and answer server - requests. -- Observers receive fanout events and may use bridge-approved read operations. -- Mutating observer operations fail visibly rather than appearing accepted. -- Controller claim and release are explicit. -- No frontend silently steals control. -- A disconnected controller is not replaced by automatic promotion. -- Thread selection is independent of controller ownership. - -CodexUI displays connection identity and role. Controls requiring authority are -disabled or produce a precise role error while CodexUI is an observer. A local -policy may request initial control explicitly, but role assignment remains a -bridge decision reported through telemetry. - -Connection controls sit immediately to the left of Claim/Release control -because transport lifecycle and controller ownership are distinct operations. -The menu exposes Configure, Connect, Disconnect, and Reconnect. It never claims -control as a side effect. - -## 11. Recovery, History, and No-Cache Policy - -CodexUI does not request or reconstruct an AISuite-owned snapshot because -codex has no snapshot authority, replay store, frontend `State`, or backend -semantic cache. - -Connection and process recovery uses fresh app-server queries through the -bridge: - -1. establish the frontend transport and observe bridge readiness/role; -2. issue `thread/list` for discovery; -3. issue `thread/read(includeTurns=true)` for the selected materialized thread; -4. continue applying normalized live events. - -A refresh result applies its declared authority. In particular, `thread.read` -merges represented content and has no deletion authority because the current -provider projection is incomplete. Explicit scoped remove events remain -authoritative. Temporary disconnect, incomplete discovery, request failure, or -an unknown message does not authorize clearing the existing presentation. - -Current app-server behavior may return `itemsView: "notLoaded"`, reject -`includeTurns` for an unmaterialized thread, or reconstruct less live detail -than was previously emitted under its active history mode. CodexUI reports -that provider limitation; it does not invent missing items or add an implicit -long-term history cache. Adding caching later requires a separate explicit -architecture decision covering authority, bounds, persistence, and eviction. - -## 12. External Transport and Configuration - -The socketpair is internal only. The SNode.C thread connects to `codex-bridge` -through exactly one configured frontend transport supported by codex and -SNode.C: - -- Unix stream; -- IPv4 or IPv6 stream; -- IPv4 or IPv6 TLS stream; -- IPv4 or IPv6 WebSocket; -- IPv4 or IPv6 WSS; -- RFCOMM or RFCOMM TLS where available. - -Transport and encryption do not change normalized UI semantics. WebSocket -changes framing; TLS changes transport protection. Neither creates state, -authority, or authentication semantics. - -There is no bearer-token or other codex-bridge authentication layer. Native -Codex account/login operations remain app-server protocol features and are -handled through typed SDK operations when exposed by the UI. - -Command-line configuration uses the SNode.C configuration subsystem. Any -CodexUI-specific configuration class is a `utils::SubCommand`. Existing SNode.C -instance options remain authoritative for addresses, Unix paths, IPv4/IPv6, -TLS certificates, WebSocket setup, reconnect behavior, timeouts, and queue -limits; CodexUI must not duplicate those semantics. - -The connection dialog reads the effective SNode.C client configurations to -enumerate compiled transports and provide current endpoint defaults. A user may -override the selected Unix path, IP host/port, WebSocket path, or RFCOMM -address/channel for the running CodexUI session. TLS certificate and -verification configuration remains in the corresponding SNode.C config -object. Runtime overrides are intentionally transient and are not written to a -CodexUI data file. - -Changing transport uses one asynchronous lifecycle: disconnect the attached -frontend SDK, terminate the selected SNode.C flow, wait for both to detach, -apply the new selection, then connect once. Repeated logical connect requests -cannot create parallel flows or reuse an attached SDK. Local disconnect, -reconnect, and transport-switch reasons remain distinguishable from remote -closure in normalized diagnostics. - -Quiet Codex sessions are normal, so transport inactivity read/write timeouts -default to zero (unlimited). Frame bounds, write-queue bounds, connect errors, -and explicit lifecycle controls remain enforced. - -## 13. Startup and Shutdown - -The process lifecycle is: - -```text -QApplication construction and Qt argument handling - -> core::SNodeC::init(argc, argv) - -> construct socketpair and both ownership graphs - -> start the SNode.C client thread - -> core::SNodeC::start() inside that thread - -> run the Qt event loop on the main thread - -> request inner transport shutdown - -> core::SNodeC::stop() - -> close socketpair endpoints and join the client thread -``` - -There is no `core::SNodeC::free()` call. Shutdown is asynchronous and -idempotent. Qt does not destroy objects still used by the client thread, and -the process does not exit while the SNode.C thread is still running. - -EOF or terminal failure on either socketpair endpoint initiates orderly -shutdown. External bridge disconnect does not terminate CodexUI; it produces a -normalized disconnected state and follows configured reconnect policy. - -## 14. Boundedness and Failure Semantics - -Every boundary is bounded: - -- bridge transport frame size; -- socketpair frame size; -- socketpair and transport write queues; -- bytes processed per readiness callback; -- retained diagnostics; -- UI presentation work scheduled per event-loop pass. - -No operation may block the Qt event loop or wait synchronously across threads. -Backpressure, oversized frames, malformed JSON, queue rejection, transport -closure, and callback failure produce classified diagnostics. They are not -silently converted into generic disconnects or state deletion. - -Outstanding normalized UI operations complete once with success or a concrete -failure. A disconnect clears ephemeral request correlation and role telemetry, -not Codex presentation content. Reconnect starts a new connection generation so -late results from an old generation cannot resolve new operations or pending -requests. - -## 15. Protocol Compatibility - -AISuite codex generates concrete C++ datatypes for the complete exported Codex -app-server protocol, including client requests, client notifications, server -requests, server notifications, responses, errors, nested objects, enums, and -unions. Every generated value preserves its native JSON through `getRaw()` and -preserves unknown fields. - -CodexUI uses those generated types and typed callbacks in the SNode.C thread. -Every generated server notification and request is classified into a v1 -presentation event or a diagnostic-only event. No known message silently falls -through as generic state. Unknown future messages remain observable through -bounded diagnostics and cannot mutate presentation state. The app-server -source/schema checkout is read-only and is never modified by CodexUI. - -The app-server wire is JSON-RPC-shaped but may omit the optional -`"jsonrpc": "2.0"` member. The frontend SDK owns that compatibility; Qt never -depends on the member's presence. - -## 16. Application Presentation - -The production `ShellWidget` is a concrete renderer of `UiSessionView`; it does -not consume `PresentationModel` or branch on protocol operations. Conversation, -Plan, Agents, Changes, Requests, retained State, and bounded Protocol -diagnostics are integrated into that shell. The raw Protocol log is a bounded -renderer-local diagnostic view of frames also delivered to `UiSession`; it has -no reduction, replay, hydration, or deletion authority. The shell retains no -parallel semantic state authority. - -## 17. Implemented Components and APIs - -The canonical CodexUI implementation contains one complete visual shell. -`ExpandingPromptEditor` and the visual style helpers live under -`src/codex/ui` because they contain no protocol authority. - -The implementation is divided into the following concrete components: - -| Component | Responsibility | -| --- | --- | -| `Configuration` | CodexUI `utils::SubCommand`; adds only CodexUI-specific frame-size and WebSocket-path options | -| `SocketPair` | Movable RAII owner for the unnamed nonblocking `AF_UNIX` socketpair | -| `QtSocketPairEndpoint` | Qt-thread descriptor adapter using `QSocketNotifier`, bounded reads, and bounded writes | -| `SNodeSocketPairEndpoint` | SNode.C-thread descriptor adapter using `ReadEventReceiver` and `WriteEventReceiver`, bounded reads, and bounded writes | -| `PresentationClient` | Toolkit-neutral value API for generic execute, send, and server-request response operations | -| `FrontendSession` | Qt-side `PresentationClient` adapter, correlation registry, lifecycle owner, and socketpair JSONL endpoint | -| `ClientRuntime` | SNode.C-thread application graph, selected transport, frontend proxy SDK dispatch, reconnect, and shutdown | -| `ProtocolNormalizer` | Native app-server/bridge input to `codexui.presentation` result/event conversion | -| `PresentationProtocol` | Frame construction, validation, authority, sequence, generation, and scope utilities | -| `PresentationModel` | Toolkit-neutral stable-ID reducer for threads, turns, items, plans, agents, requests, global domains, and telemetry; owned by `UiSession` | -| `UiSession` | Toolkit-neutral UI/UX owner for semantic intents, selection, hydration, recovery, prompt queues, pending eligibility, projections, notices, and effects | -| `UiViewState` / `UiViewProjection` | Renderer-facing neutral snapshot DTOs and pure model projection | -| `ShellWidget` | Thin Qt product-shell adapter for dialogs, gestures, snapshot rendering, focus, and pane composition | -| `MiddleRegionWidget` | Three-pane visual composition and center-region wheel routing | -| `ThreadPane` | Stable-ID thread-list projection and thread actions | -| `ConversationProjection` | Pure thread-to-turn-to-card projection over `PresentationModel` and local prompts | -| `ConversationView` | Stable-key reconciliation, card geometry, per-thread follow/pause state, and anchor-preserving scrolling | -| `ConversationCard` implementations | In-place typed card presentation, including pending prompts and bounded Command execution output | -| `PromptCoordinator` | Per-thread prompt admission queues, callback-only acknowledgment, and authoritative-item correlation | -| `ComposerPane` | Bottom-anchored upcoming-turn controls, attachments, prompt editor, and overlay-height reporting | -| `InspectorPane` | Retained Plan, Agents, Requests, State, and Protocol presentation plus selected-workspace Git review | -| `TurnSettingsWidget` | Codex-native transient settings draft and native thread/turn option encoder | -| `NewThreadDialog` | Transient native thread-start draft with workspace selection and instructions | -| `FileSelectionDialog` | Canonical directory or bounded multi-file browser shared by workspace and attachments | -| `ConnectionDialog` | Session-only selector over effective compiled SNode.C client configurations | -| `GitDiffProvider` | Asynchronous in-process libgit2 repository discovery and scoped diff snapshots | -| `DiffViewer` | Compact repository summary/preview and modeless unified or side-by-side review | -| `PendingRequestDialog` | Typed, generation-preserving UI for app-server server-request families | -| `MainWindow` | Top-level Qt window ownership only | -| `BrandMark` and desktop resources | Shared visual mark and the consistent `codex-ui` executable/application/window/icon identity | - -### 17.1 Presentation Client and FrontendSession APIs - -`PresentationClient` is the normal UI-logic entry point. Its three generic -functions cover correlated operations, uncorrelated commands, and typed -server-request responses. `FrontendSession::presentationClient()` binds them to -the existing Qt endpoint. Every correlated operation returns a presentation -correlation ID and optionally invokes a GUI-thread response callback. Neither -API blocks the GUI thread or exposes a transport socket. - -The generic operation method: - -```cpp -request(std::string operation, - nlohmann::json parameters, - ResponseHandler handler = {}) -``` - -supports the complete generated AISuite operation catalog. Existing narrow -`FrontendSession` convenience methods remain adapters, but `UiSession` depends -only on `PresentationClient` and therefore does not mirror the catalog as a Qt -facade. - -Lifecycle is explicit: `start()` creates the endpoint/runtime graph, -`shutdown()` requests orderly asynchronous termination, and `wait()` joins the -SNode.C thread. `setEventHandler()` receives normalized frames and -`setRuntimeStoppedHandler()` reports terminal worker shutdown. - -### 17.2 UiSession API - -`UiSession` accepts normalized frames and semantic renderer intents. It owns -the presentation reducer and prompt coordinator and publishes one aggregate -`UiSessionView`. Thread and Inspector panes accept their neutral snapshot DTOs; -conversation cards accept neutral middle-layer values. The concrete shell has -no `PresentationModel` include. - -The change callback requests a coalesced render on the current GUI loop. The -absolute wakeup callback maps deferred prompt dispatch and acknowledgment -deadlines onto `QTimer` without giving `UiSession` a Qt dependency. This is an -adapter seam, not another queue or event loop. - -### 17.3 Normalizer and reducer APIs - -`ProtocolNormalizer` accepts transport lifecycle, bridge telemetry, typed -server notifications, server requests, raw inbound observation, operation -success, and operation rejection. Its only output is a validated bounded -presentation frame through its sink. `knownServerMethod()` makes coverage gaps -observable rather than silently treating an unknown method as state. - -`PresentationModel::applyEvent()` is the single public reduction entry point. -The model exposes stable thread ordering and lookup, active-turn lookup, -generation-aware pending-request queries, retained global domains, bounded -telemetry, and pending-request presentation records. Internal upsert helpers -preserve complete fields across partial events, correlate child-agent threads, -and apply explicit merge/replace/remove authority. - -### 17.4 Transport availability - -The executable always builds Unix, IPv4, and IPv6 JSONL clients. TLS, RFCOMM, -WebSocket, and WSS clients are compiled when their SNode.C targets are -available. Exactly one configured client instance may be enabled. Address, -certificate, timeout, queue, reconnect, and instance-enable options come from -the corresponding SNode.C client configuration; CodexUI adds no duplicate -transport configuration. - -The build produces one application, `codex-ui`. It integrates the production -shell with `FrontendSession`, `ClientRuntime`, the socketpair, normalizer, -presentation protocol, and presentation model; no alternate UI target has a -privileged transport or state path. - -The current build links the codex AISuite frontend library as -`AISuite::OpenAICodex`, Qt Widgets, Threads, libgit2 through pkg-config, and the -selected SNode.C client modules. Git review is performed through libgit2; the -application never launches a Git process. CodexUI CI consumes AISuite from -`master`/HEAD and does not pin a particular AISuite revision. The canonical -AISuite change must therefore be merged before the dependent CodexUI change. -The AISuite dependency build is limited to two compiler jobs because its -generated protocol translation units can otherwise exceed the hosted runner's -aggregate memory. - -### 17.5 Shell settings and pending-request APIs - -`TurnSettingsWidget` owns only an upcoming-turn draft. The shell supplies fresh -provider context and catalogs through: - -```cpp -setContext(std::string identity, - const nlohmann::json &canonical, - const nlohmann::json &models, - const nlohmann::json &permissionProfiles); -setControlsEnabled(bool enabled); -``` - -`workspace()` resolves the visible workspace against the caller's local -fallback. `threadStartOptions()` emits only native `thread/start` fields, while -`turnStartOptions()` emits only native `turn/start` fields. Untouched fields are -omitted except that collaboration mode is always explicit because app-server -does not reliably reconstruct its retained value. Explicitly selecting a -provider default emits `null`; named permissions and sandbox policy remain -mutually exclusive. The three app-server -`thread/start` sandbox strings are encoded directly. The richer -`externalSandbox` object is emitted only as a `turn/start.sandboxPolicy`, where -the native protocol defines it. Reasoning efforts, service tiers, default tier, -and personality availability follow the selected model catalog. - -The native collaboration object is not a partial mask: its nested `model` is -mandatory, while `reasoning_effort` and `developer_instructions` use the -app-server schema's snake-case names. When the UI shows `Codex default`, the -encoder resolves the catalog entry marked `isDefault` and sends its concrete -model ID. Until that fresh catalog is available, CodexUI omits the otherwise -explicit collaboration object rather than constructing an invalid one. - -`PendingRequestDialog::present()` accepts one neutral, generation-preserving -`PendingRequestDescriptor` and returns either no value when the user closes the -dialog or a `PendingRequestResponse` containing exactly one native result or -JSON-RPC error. `PendingRequestPolicy` owns family-specific positive, negative, -and form-submission response shaping. `UiSession::resolvePending()` revalidates -the descriptor against current generation, identity, kind, thread, and raw -request before responding through `PresentationClient`; the dialog never -mutates presentation state itself. - -`ShellWidget` is the sole native visual adapter. It translates selection, -composer, settings, controller, thread-management, and request-review gestures -into semantic `UiSession` calls and renders snapshots/effects. Agent messages, -plan text, reasoning summaries, and agent results pass through -`QTextDocument::setMarkdown()` with -`MarkdownNoHTML`; user prompts, commands, and command output remain literal. -Its custom dialogs return transient value objects and never mutate the -presentation model directly. The composer owns its editable attachment draft; -the neutral logic owns admitted attachment values. The connection dialog edits -only the SNode.C runtime selection, and `DiffViewer` -is a read-only consumer of normalized model domains and retained provider -items. - -### 17.6 Essential Automated Architecture Tests - -The permanent automated-test policy protects architectural boundaries rather -than individual fixes, widget details, or lines of implementation. A defect -correction does not automatically justify another test. A test belongs in the -codex suite only when it validates a boundary whose failure would undermine -the application architecture independently of the particular symptom that -revealed it. - -Nine focused CTest executables form the essential suite. They use production -classes directly and are built when standard CMake `BUILD_TESTING` is enabled. -CTest enables that option by default; disabling it remains the conventional -packaging choice and does not select a different runtime implementation. - -#### Socketpair Contract - -`codexui-socketpair-contract-test` exercises the actual two-thread IPC -mechanism: - -```text -QCoreApplication / Qt event loop - -> QtSocketPairEndpoint - -> nonblocking AF_UNIX SOCK_STREAM socketpair - -> SNodeSocketPairEndpoint - -> SNode.C event loop on its worker thread -``` - -The test constructs the production `SocketPair`, gives one descriptor to the -production Qt endpoint and the other to the production SNode.C endpoint, and -runs both framework event loops. Multiple newline-delimited records travel in -both directions as separately queued writes. The test establishes that byte -ordering is preserved across partial/coalesced stream delivery, both endpoint -queue bounds reject an oversized write without replacing the bound with an -unbounded buffer, and closing the Qt endpoint produces orderly closure on the -SNode.C side without a transport error. It also requires the SNode.C event loop -to terminate cleanly. The test does not introduce another IPC implementation, -polling loop, mock event loop, or synchronous cross-thread method call. - -This test deliberately treats the socketpair as an ordered byte stream. JSONL -framing and semantic interpretation remain above this boundary; duplicating -the AISuite `JsonLineFramer` tests here would test another project rather than -CodexUI's thread boundary. - -#### Presentation Pipeline - -`codexui-presentation-pipeline-test` exercises the production semantic -path without a bridge substitute: - -```text -representative native app-server and bridge records - -> ProtocolNormalizer - -> codexui.presentation v1 frames - -> PresentationModel::applyEvent() - -> coherent toolkit-neutral presentation state -``` - -The representative lifecycle includes connection and controller publication, -effective transport-settings publication, thread discovery, an authoritative -full thread read, a later live turn, command start, command output, command -completion, authoritative turn-diff publication, and turn completion. The -test verifies the contract at architectural granularity: every emitted frame -has the expected protocol version, monotonic sequence, and connection -generation; connection settings reduce coherently; list/read/live updates -converge on stable thread, turn, and item identities; and the completed model -contains one coherent command result and scoped diff with no active turn left -behind. It does not enumerate every generated app-server method, every -presentation field, or every historical correction. - -The normalizer sink is connected directly to the reducer because the -socketpair itself is independently covered by the first test. This keeps a -failure attributable to either inter-thread transport or semantic reduction -instead of repeating both mechanisms in every case. - -#### UI Session Boundary - -`codexui-ui-session-test` supplies a fake value-type `PresentationClient` to -the production, Qt-free `UiSession`. It verifies provider hydration, selection -hydration, settings resume, aggregate snapshots, deferred exact prompt -dispatch, pending-request eligibility and stale-response rejection, new-thread -effects, and change/wakeup callbacks. `codexui-pending-request-policy-test` -separately verifies every typed native response shape. Neither executable links -Qt. - -#### Conversation Projection - -`codexui-conversation-projection-test` verifies the pure typed projection and -prompt coordinator: one section per app-server turn, stable card identity and -server ordering, per-thread prompt queues, dispatch-time Start/Steer choice, -callback-only acknowledgment, exact `clientUserMessageId` correlation, -duplicate-prompt ordering, history bounds, resolved-submission removal, and -Command execution output visibility. - -#### Middle-Region Behavior - -`codexui-conversation-cards-test` exercises the actual conversation widgets -programmatically. It verifies smooth follow, user-owned pause, stable -card-and-pixel anchoring across every card type and width-dependent reflow, -per-thread restoration, composer trailing space, pending-prompt animation, -and independent Command execution output sizing and scroll ownership. - -`codexui-application-layout-test` verifies the three-pane constraints, composer -overlay geometry, complete center-region wheel routing, thread-list selection -projection, nested-scroll handoff, and retained Inspector/Info behavior. These -are state and geometry assertions over Qt widgets, not golden-screenshot or -pixel-perfect visual baselines. The pending-animation check compares two -transient card rasters only to prove that motion exists. - -#### Shell Integration - -`codexui-shell-integration-test` drives the production `ShellWidget` and -`FrontendSession` across their real socketpair presentation boundary. It -verifies exact visible-thread routing, independent prompt queues, real result -acknowledgment, background completion, retained Plan/Agents state, monotonic -hydration across reconnect, queued and in-flight prompt retention across a -provider restart, terminal current-provider callbacks, failed-hydration draft -retention, bounded child-thread reads, and one-shot thread-not-found recovery. - -#### Git Changes Integration - -`codexui-git-changes-live-test` uses production `DiffViewer`, -`GitDiffProvider`, QFileSystemWatcher, and libgit2 against a temporary real Git -repository. It performs filesystem writes rather than UI interaction. The test -verifies polling discovery of a manually created nested untracked file and -native watcher refresh after removal, content reversion, deletion restoration, -atomic replacement, and suppression of an in-flight result across a context -switch. `codexui-application-layout-test` complements it with -in-process repository-resolution coverage for all scopes, duplicate candidates, -ambiguous and absolute paths, All and individual repository selection, hidden -repository exclusion/inclusion, stale hints/selections, and preference for an -actually changed path over an identical clean tracked path. - -#### Explicit Exclusions - -The permanent automated suite does not include: - -- a fake or scripted codex-bridge; -- a fake app-server or synthetic network server; -- external GUI-driving automation, golden screenshots, or pixel-perfect - styling baselines; -- one test per fixed issue, setting, request family, widget, or source branch; -- the Unix/IPv4/IPv6/TLS/WebSocket/RFCOMM transport matrix already owned by - AISuite and SNode.C; -- authenticated model execution, approval interaction, or assumptions about - nondeterministic model output. - -A real app-server-to-bridge-to-CodexUI turn remains a manual live acceptance -procedure. It depends on external authentication, service availability, -credits, approval policy, and model behavior, so presenting it as a -deterministic CI test would be misleading. The persistent live topology and -independent bridge observer provide that evidence without introducing a fake -bridge into the CodexUI repository. - -The seven focused tests can be built and run directly: - -```sh -cmake --build "${BUILD_DIR}" --parallel 8 \ - --target codexui-socketpair-contract-test \ - codexui-presentation-pipeline-test \ - codexui-conversation-projection-test \ - codexui-conversation-cards-test \ - codexui-application-layout-test \ - codexui-git-changes-live-test \ - codexui-shell-integration-test -ctest --test-dir "${BUILD_DIR}" --output-on-failure \ - -R '^codexui-(socketpair-contract|presentation-pipeline|conversation-projection|conversation-cards|application-layout|git-changes-live|shell-integration)$' -``` - -Each test has a 10-to-30-second CTest ceiling. Normal successful execution is -substantially shorter and requires no network listener, credentials, isolated -Codex home, or user interaction. - -## 18. Live Application Validation - -The application was exercised against one persistent real topology: - -```text -Codex app-server over IPv4 WebSocket - <-> codex-bridge over IPv4 WebSocket - <-> CodexUI over IPv4 WebSocket -``` - -An independent `codex-bridge-client` observer remained connected to the same -bridge while CodexUI held controller ownership. The run used an authenticated -isolated Codex home and an existing persistent bridge process rather than a -simulated provider. - -Validated behavior includes: - -- initial connection, explicit controller claim/release, and observer fanout; -- fresh thread discovery followed by selected `thread/read(includeTurns=true)`; -- no automatic thread selection when another client or subagent creates a - thread; -- multiple turns, steering, structured plan updates, command execution, - command output/completion, and final answers; -- pending-request presentation and resolution without stale brown attention; -- parent/child agent correlation, child history hydration, and retained child - result presentation in the parent Agents view; -- switching among Conversation, Plan, Agents, Requests, State, and Protocol - surfaces while turns and agents were active; -- retention of an early completed marker command while later commands, plan - transitions, subagent activity, and final output arrived; -- stable presentation after turn completion, with no observed disconnect, - sequence gap, stale pending request, or retained-item disappearance. - -The final validation turn lasted about 36 seconds and included a completed -marker command, a three-step completed plan, one subagent thread, later Command -execution activity, and a final answer. At the final checkpoint the normalized -model held one top-level selected thread, three turns, seventeen items, zero pending -requests, and the retained marker and later activity simultaneously. - -A new CodexUI process was then validated against the same persistent bridge. -Selecting the completed parent thread retained all top-level rows and -hydrated the Conversation. The Plan inspector reconstructed the retained -textual plan with Markdown formatting; Changes displayed the explicit -read-only empty state; Requests remained at zero; and opening Info lazily -populated the environment State without clearing or blocking Conversation. -Controller and connection status remained stable throughout these tab -transitions. The fresh Agents view correctly remained empty because the -authoritative `thread/read` omitted all prior collaboration items, as documented -below. - -A final focused live turn requested exactly one subagent. Raw observer events -contained one completed `spawnAgent` item with child thread ID, one `wait` -operation, the child command/result, and the parent final answer. During the -turn the shell reported `1 agent | 1 active`; after completion it reported -`1 agent | 0 active` and retained one completed agent card with model, effort, -prompt, child thread ID, and result. No provisional spawn or wait row appeared. -The settings controls also displayed explicit chevrons. This run exposed one -additional compatibility defect: Code was displayed after a fresh read while -`turn/start` omitted collaboration mode and app-server silently continued its -retained Plan mode. The encoder now sends the displayed collaboration mode on -every new turn once the mandatory model has been resolved from the fresh -catalog. - -The post-fix live acceptance used frontend connection `frontend-27` and a fresh -thread. Its raw `turn/start` contained `mode: "default"`, catalog-resolved model -`gpt-5.6-sol`, and native `reasoning_effort: null` and -`developer_instructions: null` fields. App-server accepted the request, -published matching Default collaboration settings, completed the turn without -tools, and returned the requested `CODE_MODE_OK` response. - -Startup latency was traced to eager account/configuration/plugin/app catalogs -queued before the selected thread read. Startup now requests thread discovery -plus the small model and permission-profile catalogs required by the composer. -The larger environment catalog is fetched lazily when Info is first opened, -allowing the selected conversation to hydrate promptly without introducing a -cache. - -This live run proves the implemented paths exercised by the scenario; it is -not a claim that every generated operation or every optional transport has -received equivalent live coverage. The canonical build and `git diff --check` -completed successfully. Automated coverage is intentionally limited to the -socketpair contract and presentation pipeline described in Section 17.5; the -real authenticated topology remains the manual live acceptance boundary. - -## 19. Provider Limitations Observed Live - -### 19.1 Reconstruction Shortcomings - -Three app-server reconstruction shortcomings were observed live: - -1. Live parent events include `collabAgentToolCall` and child-agent activity, - but a later `thread/read(includeTurns=true)` returned both parent turns while - omitting every collaboration and subagent item. A fresh no-cache CodexUI - therefore cannot reconstruct historical Agents content. During a continuous - connection CodexUI correlates the authoritative live records by child thread - ID and keeps implementation threads out of the ordinary top-level list. -2. `turn/plan/updated` notifications produced and updated the Plan view - correctly during the live session, but a later - `thread/read(includeTurns=true)` did not return those completed plan updates - or an equivalent current-plan field. During the current session, the merge - authority of `thread.read` preserves the live structured plan. On a fresh - process, a completed textual plan item is used as the Plan inspector's - read-only fallback when available. -3. Under the configured app-server history representation, a later - `thread/read` can reconstruct generic - item IDs and omit a live command-execution item even though the live event - stream contained the richer item. - -CodexUI preserves already observed live presentation when an incomplete read -omits it, but it does not synthesize content that the process has never -observed. A fresh process therefore remains limited to the provider's -reconstruction. This merge policy is bounded in-memory presentation retention, -not a semantic cache or persistence authority. - -### 19.2 Capability Limitations - -The current app-server does not support `historyMode: "paginated"` and returns -`paginated_threads is not supported yet`. A newly started thread is also not -materialized for `thread/read(includeTurns=true)` until it receives its first -user message. - -These are provider-boundary discrepancies. CodexUI reports and renders the -authoritative result it receives; it does not hide them with a bridge snapshot, -AISuite cache, or CodexUI persistence layer. A future caching design requires a -separate explicit authority and retention decision. - -## 20. Visual Shell Integration Boundary - -The CodexUI shell is implemented in codex-owned Qt widgets. Those widgets -consume only neutral view values and call only semantic `UiSession` intents. -`UiSession` alone consumes normalized frames, owns `PresentationModel`, and -talks downward through `PresentationClient`. - -The implemented shell contains the 64-pixel top bar, hideable work sidebar, -thread list, conversation timeline and composer, hideable inspector, Plan, -Agents, Changes, Requests, and Info surfaces, explicit controller control, -connection lifecycle/configuration, canonical new-thread/workspace/attachment -dialogs, per-thread context actions, complete upcoming-turn settings, a -first-class diff viewer, pending-prompt cards, request status, and the 40-pixel -status bar. Agent -messages, plans, reasoning summaries, agent results, and authoritative user -messages are rendered with Qt Markdown parsing while embedded HTML is disabled. -The transitional local prompt, commands, and command output remain literal. -State and Protocol diagnostics remain nested under Info rather than dominating -normal use. - -Pending-request presentation exposes category, stable request ID, connection -generation, owning thread, and a bounded set of safe typed details. The native -request object remains transiently available to the typed response dialog but -is never dumped to the shell, Info/State view, notice banner, or protocol log. -Secret answers are held only by password editors until the dialog is -destroyed. - -Operation errors, provider notices, protocol diagnostics, and connection -failures produce a dismissible latest-notice banner. Its text is extracted -only from bounded message/detail fields. The complete bounded frame chronology -remains in Info/Protocol. Neither surface has state authority. - -Further shell work remains presentation-only. It must not change the socketpair -protocol, app-server normalization, model authority, bridge role semantics, -recovery policy, or AISuite codex implementation unless a proven missing -contract requires a separately reviewed change. - -## 21. Architectural Invariants - -1. The app-server is Codex semantic and persistence authority. -2. `codex-bridge` is a thin multi-client router with telemetry, not a cache. -3. The codex frontend SDK is a typed proxy, not a frontend state store. -4. SNode.C owns transport, SDK execution, protocol decoding, and normalization. -5. Qt owns concrete widgets and renderer mechanics; toolkit-neutral - `UiSession` owns semantic UI/UX state on the Qt thread today. -6. Only normalized commands/events form the regular inter-thread contract. -7. Cross-thread work is asynchronous and bounded. -8. Partial omission is not deletion authority. -9. Stable protocol IDs, never row order, define identity. -10. Controller transfer and thread selection are explicit; neither auto-switches. -11. Plans, completed commands, and completed agent activity remain visible until - an authoritative scoped update says otherwise. -12. Pending attention exists only while a matching server request is unresolved. -13. Recovery queries app-server; no snapshot, replay store, or semantic cache is - introduced. -14. Generic Qt and SNode.C socket classes remain free of Codex-specific methods. -15. Native app-server and bridge transport failures remain distinguishable. -16. Prompt routing always uses the stable visibly selected thread; thread - creation requires an explicit new-thread intent. -17. Pending prompts are presentation state until acknowledged and are - dispatched sequentially per thread without disabling the composer. -18. Conversation and nested-output following is enabled exactly while the - corresponding scrollbar is at its bottom. Conversation following is smooth - and user-interruptible; its paused state preserves a stable visual anchor. -19. Composer growth overlays the unchanged message viewport and adds equal - trailing content space without automatically moving existing messages. -20. Thread selection hydrates once per bridge connection; prompt dispatch waits - for readiness and permits at most one resume-and-retry after a transient - thread-not-found result. -21. Nonvisual item updates do not reconstruct cards; one coalesced refresh uses - one hidden layout transaction and one scroll settlement. -22. Conversation hierarchy has exactly one semantic grouping level: stable - app-server turns containing stable server-ordered items. -23. `UiSession` exclusively owns `PresentationModel`, the only retained - normalized presentation store; conversation and inspector views are value - projections, not parallel state authorities. - -## 22. Resolved Presentation Decisions - -The remaining presentation-level choices are implemented as follows: +The browser frontend connects to `codex-bridge` over WebSocket and has a +separate TypeScript implementation appropriate to that process boundary. Its +architecture, parity boundary, and limitations are documented in +[web-1.0-contract.md](web-1.0-contract.md). The native node graph is not a new +service or public protocol and is not shared with the browser. -- every incoming frame is reduced immediately; the selected conversation then - takes one typed projection snapshot and one stable-key reconcile, with - identical visible projections producing no widget or geometry work; -- the Info/Protocol view retains at most 2,000 text blocks and the presentation - model retains at most 256 authority-free telemetry records; the protocol - statistics summary is below the expanding log; -- overdue prompt acknowledgment uses delayed per-thread card feedback rather - than an application-wide busy state or composer lock; -- reaching the conversation bottom re-enables automatic following, including - after scrolling through composer-added trailing space or a contraction clamp; -- paused conversation updates preserve the first visible stable card and its - pixel offset through appends, reflow, and reconstruction; -- typed operation errors and provider notices use a dismissible latest-notice - banner, while unknown/malformed protocol input remains visible in bounded - diagnostics and never mutates retained presentation state. +## Local Git Changes -No architectural decision remains open in the canonical CodexUI implementation. -Interactive visual validation covered settings, Markdown, plans, pending -requests, and live agent lifecycle. Provider-omitted history remains visible as -an explicit reconstruction boundary rather than being hidden by client state. -The current CodexUI acceptance boundary requires focused build/tests and live -visual acceptance of thread routing, prompt acknowledgment, scrolling, -composer geometry, attachments, connection, and diff surfaces. No semantic -cache is part of this boundary. +The existing `GitDiffProvider` performs scoped local libgit2 work separately +from the app-server/UI data path. It does not access the shared graph or add an +authority for protocol state. diff --git a/docs/native-ui-ux-qualification-inventory.md b/docs/native-ui-ux-qualification-inventory.md new file mode 100644 index 0000000..15a3006 --- /dev/null +++ b/docs/native-ui-ux-qualification-inventory.md @@ -0,0 +1,298 @@ +# Native UI/UX qualification inventory + +This is the acceptance inventory for the native two-thread shared-node-graph +cutover. It supplements `ui-behavior.md`; it does not define a second UI model. +Every row must be checked with current `NodeGraph` state, existing widgets, and +the real application where timing or event propagation matters. + +## State matrix applied to every relevant surface + +Each scenario below is exercised in every applicable user state: + +- no thread selected, empty selected thread, hydrating thread, ready idle + thread, active turn, interrupted/failed/completed turn, and disconnected or + non-controller runtime; +- following the conversation bottom, paused one card above the bottom, paused + near the middle, paused at the oldest loaded item, scrollbar thumb held, and + keyboard focus inside a card, nested output, composer, menu, or dialog; +- short content, one very tall card, many turns, the initial 80-item window, + one or more explicit Load 80 pages, and history containing unknown items; +- long mixed histories containing normal and steering You cards, interim and + final agent messages, reasoning, file changes, agent activity, generated + images, image attachments, unknown fallbacks, approvals, review requests, + user-input and MCP decisions, and command cards with both short and very long + output; +- selected/root thread, selected child thread, activity in an unselected + thread, and a selected thread whose parent or child changes; +- one notification, several coalesced notifications, a sustained streaming + burst, graph-read contention, removal, reconnect, and replayed state; +- normal width, narrow split panes, resize in progress, hidden/reopened pane, + and the saved pane/sort/tab/fold state after navigation. + +For a paused viewport, every mutation records the first visible stable card, +its pixel offset, scrollbar value and maximum, horizontal positions of visible +content, focused widget, selection, expanded states, and live widget IDs. A +change below the viewport must preserve the visible card and pixel exactly. A +height change above it may change the scrollbar value only by the compensating +height delta. A change in the visible card may alter only that card's required +geometry. No case may expose a placeholder, reserved empty extent, parentless +child, partial order, or intermediate lifecycle style. + +## Shell, window, and cross-pane routing + +| Area | Situations and expected result | Qualification | +| --- | --- | --- | +| Main window | Initial show, maximize/restore, resize, splitter drag, pane hide/show, minimum useful size | No clipped controls, oscillating layout requests, stale overlay geometry, or whole-window repaint from a descendant update | +| Top chrome | Connection label/dot/menu, controller button, request indicator, restore-pane buttons | Setters/style polish/paint occur only when the effective displayed value changes; item streaming does no work here | +| Bottom status | Ready/connecting/disconnected/error and attribution | Stable geometry; exact state/tone; unrelated thread changes do not repaint it | +| Conversation chrome | Selected-thread title, workspace, last activity, lifecycle state and visibility/filter buttons | Only changed effective fields update; item text streaming does not rewrite thread chrome; final status is immediate | +| Transient notice | Success/error text, replacement, timeout, resize, navigation | Non-layout-shifting overlay, latest notice wins, no input obstruction after dismissal | +| GraphChanged routing | Item field, item structure, thread field, thread topology, interaction, catalog, connection, removal | Only dependent visible panes receive work; hidden panes become dirty only; removals detach synchronously | +| Frame coalescing | Many reduced notifications inside one GUI frame | One bounded commit per affected pane; final completed content is exact; no raw-frame merging before graph reduction | +| Identical state | Repeated same node state/effective shell state | Zero row/card construction, geometry, layout, style polish, or repaint | +| Idle application | Fresh launch with no selection and ready/disconnected states | Near-zero idle CPU; no zero-delay timer, scan, repaint, or layout loop | + +Full-app evidence: Xvfb movie plus pane-level paint/layout/style/scan counters, +with fixed screen geometry and pixel-difference regions. + +## Thread pane + +| Area | Situations and expected result | +| --- | --- | +| Discovery | Empty list, first page, additional pages, refresh, reconnect and provider reset show only admitted roots and never flash placeholders as roots | +| Rows | Name/preview/ID fallback, status dot, hover, selected state, badges/tooltips and exact canonical state vocabulary match the legacy appearance | +| Selection | Left click changes selection once; background activity never steals it; selecting a child keeps its root visible; removal clears selection safely | +| Hierarchy | Root/child ownership, expansion/collapse, deep indentation, late parent, fork reassignment and child removal retain complete reachable topology | +| Sorting | Recent, Created, Last changed and natural Alphanumeric orders; missing timestamps; local prompt ticks; non-sort field changes never scan/reorder | +| Local draft row | New Thread insertion, animation, promotion to provider ID, failure, abandon-empty, second-create guard and later-navigation preservation use one stable row | +| Context menu | Right-click targets the pointed row without selection, holds hover while open, dismisses without click-through, and exposes correct reload/rename/fork/archive/delete state | +| Incremental update | Name/status/tooltips patch only the affected row and repaint only its rectangle; no whole-list update suppression | +| Atomic topology | Insert/remove/reparent/reorder computes a complete target before commit and never exposes partial ordering | +| Large list | Visible rows plus two-row overscan only; bounded scans complete under unrelated revisions; scroll position and expansion remain stable | +| Pane visibility | Hidden pane performs zero QWidget work and resolves the latest graph once when shown | + +Existing coverage includes the ThreadPane tests in `ApplicationLayoutTest`; +required additions are paint/style/row-construction region counters, selected +child/root persistence in the full shell, and an idle-CPU/full-app check. + +## Conversation initialization, history, and ownership + +| Area | Situations and expected result | +| --- | --- | +| No selection | Empty instruction and disabled settings/composer are stable and idle | +| Initial hydration | Cold ready thread, `notLoaded` resume, delayed `thread/read`, active thread and failed hydration preserve authored input and show no partial history | +| Atomic selection | All cards in the selected loaded window are constructed and laid out invisibly; one final frame appears with correct bottom/remembered anchor and no prior reserved blank space | +| Mixed long selection | A retained history containing every supported card family, expanded command output, pending decisions, failures and completed work is exposed in one final frame with canonical order, ownership, expansion and heights | +| Thread switch | Outgoing thread remains visually stable until the incoming final layout is ready; each thread restores its own anchor, follow mode, folds and nested output scroll | +| Load 80 | Existing viewport remains unchanged while all newly requested cards materialize; one final anchored frame appears; repeated pages do not make later live updates history-sized | +| Turn ownership | Every represented child card has its canonical opening You card as QWidget ancestor from its first visible frame; steering You remains nested and never becomes the turn owner | +| Root handoff | Local prompt to authoritative user item retains the same widget, parent, key, fold, height and anchor; item/result arrival in either order has no parentless frame | +| Authoritative replacement | Removed/rolled-back nodes disappear atomically; retained optimistic tails stay attached; stale IDs have no widget | +| Final geometry | Initial wrapping uses final viewport width; after reveal, card/turn/content heights and scroll maximum remain unchanged without new data | + +Automated checks capture every paint-time representation count and parent +chain, not only the state after `spinUntil`. The full-app movie must show no +intermediate empty extent or later layout correction at 30 fps. + +## Conversation updates in every scroll mode + +| Mutation | Following bottom | User scrolled up / holding scrollbar | +| --- | --- | --- | +| New normal prompt | Pending outer You card appears atomically and bottom remains visible | Card is fully constructed below the viewport; exact visible card/pixel and horizontal position do not move | +| New steering prompt | Pending teal You card appears inside the active owner atomically | Same owner and anchor remain visible; no temporary parentless or outer card | +| Prompt acknowledgement | Same widget morphs to authoritative identity and stops delayed feedback | No movement, replacement or temporary neutral styling | +| New agent/process/review card | Complete card and correct owner appear in one frame | No reserved blank extent, viewport movement or neighboring-card repaint | +| Streaming text/output | Affected card updates once per GUI frame; following remains at bottom | Offscreen updates perform zero QWidget work; visible updates preserve the anchored pixel except for required local growth | +| Status-only change | Header/status/border paint only unless text width truly changes | No global geometry or viewport movement; terminal state cannot be overwritten by stale active state | +| Card height change above viewport | Bottom/follow behavior remains natural | Scroll value compensates by the exact height delta so the painted anchor is stationary | +| Card height change in viewport | Only card and owning Turn section recompute | Same, with minimal unavoidable local displacement and no unrelated repaint | +| Auto-approval/request card | Attention state and card arrive together | No jump even when adjacent incoming events arrive before prompt acknowledgement | +| Removal/revert | Removed card/section vanishes and bottom settles once | Anchor restored around the removed extent; no detached live QWidget | +| Unrelated thread change | No conversation work | No conversation work | + +The tests cover first-visible stable identity, pixel offset, vertical and +horizontal coordinates, scrollbar range/value, live QWidget identity, focus, +and per-pane paint/layout counters before, during, and after each transition. + +## Card types and card-local interaction + +Every supported card is instantiated in pending/running/completed/failed/ +interrupted/not-loaded states where meaningful: outer and steering You, +interim and final Codex message, command execution, reasoning, file changes, +agent activity, generated image, image view, plan, MCP/tool activity, approval +and user-input activity, generic known activity, and unknown fallback. + +For each type verify: + +- canonical surface, title, specialization, status text/tone, metadata, active + emphasized border while awaiting delayed results, and terminal border; +- exact stable widget identity across field streaming and compatible type + hydration, with replacement only when the existing widget cannot represent + the authoritative type; +- canonical parent Turn/You card, 8 px nested gap, no duplicated or unmanaged + nested card, and correct collapse inheritance; +- default expansion, user fold retention across updates/navigation, chevron + direction, title anchoring, keyboard focus and no geometry change for + paint-only state; +- Copy availability, exact plain/Markdown content, 0.5-second check feedback, + reduced-motion behavior, overlay placement and no header movement; +- retained text truncation notice and copied disclosure at the byte bound; +- attachment order, encoded filenames, bounded image ribbon, horizontal + scrolling without vertical growth, missing-image accessibility and modeless + viewer geometry; +- command output upward growth while following, independent inner scroll + pause/follow state, metadata, cwd, exit/duration, auto-open for live output, + user-controlled expansion after completion, and state retention; +- no animation for ordinary active work; delayed local prompt feedback starts + after one second only while visible and stops exactly on acknowledgement or + failure. Pending normal and steering headers show their lifecycle state. + +## Composer, settings, and prompt interaction + +| Area | Situations and expected result | +| --- | --- | +| Submit keyboard | Enter/keypad Enter/Ctrl+Enter/Meta+Enter submit; Shift combinations insert newline; Alt+Enter does not; auto-repeat and IME-confirm never submit | +| Send/Steer enablement | Requires non-whitespace draft, ready controller/provider, valid selected hydrated target; active turn chooses steer; clicking an enabled button admits exactly once | +| Exact target | Navigation between typing and clicking uses the visibly selected stable NodeRef; no fallback thread and no dual send | +| Draft ownership | Navigation retains text/attachments; successful admission clears once; rejection/backpressure/wake failure/hydration failure retains it exactly | +| Multiple prompts | Per-thread queue preserves order and only one unacknowledged mutation is in flight; other threads remain independent | +| Composer geometry | Growth overlays upward, canonical reserve stays stable, bottom-follow pause semantics are preserved, and shrinking restores without a jump | +| Attachments | Add/remove, duplicate names, long names, ordinary file Markdown and image paths retain order/content and update height atomically | +| Turn settings | Model/reasoning/access/network/workspace/approval/style/additional values, defaults, enabling and persistence patch only changed controls | +| Attention bar | Request title/detail and Reject/Accept/Review actions, enablement and composer displacement are atomic and scoped | +| Stop | Visible only for an active turn, targets exact turn, remains safe through completion races | +| Recovery | Definite/uncertain failure card restores only by user action and never overwrites a non-empty draft or attachments | + +Submission live tests cover result-before-item, item-before-result, other events +between admission and acknowledgement, delayed acknowledgement, error, +interrupt, disconnect, deletion, provider reset, reconnect, and navigation +away/back while pending. + +Normal Send and steering submission are each repeated while following the +bottom and while paused at the top, middle and near-bottom. The local prompt is +materialized invisibly and exposed complete, remains under its canonical Turn/ +You owner while unrelated cards and decisions arrive before acknowledgement, +keeps keyboard focus and the painted anchor stable, targets the selected node +exactly once, and transitions from pending to accepted without replacement. + +Every decision workflow is exercised from the user's point of view: approval +accept/reject/review, command permission, file-change review, structured +user-input answers (including validation failure), MCP elicitation, request +cancellation, and completion arriving while the dialog or card has focus. +Admission must target the displayed request exactly, a rejected admission must +retain authored input, and resolution must update or remove only the affected +surface without changing the conversation anchor. + +## Inspector and Info + +| Tab | Situations and expected result | +| --- | --- | +| All tabs | Only the visible tab scans/materializes/paints; hidden tabs keep one dirty bit and render latest state once on activation; tab and scroll positions persist | +| Plan | Explanation and ordered steps, canonical/terminal status reconciliation, stable row identity, bounded visible window, no jump under revisions | +| Agents | One stable row per logical child thread; spawn-item fallback only without child ID; spawn tools only; replay/progress/completion/interruption deduplicate; first-spawn order and terminal status win | +| Changes | Async repository resolution, hidden-path option, repo selector, scopes, file list, totals, watches, manual/untracked/staged changes, preview and unavailable state do not block other tabs | +| Requests | Pending/recoverable requests, exact target/context/generation, bounded rows, authored input retention, enabled Review/Accept/Reject and removal after exact response | +| State | Current graph summary, selected hierarchy totals, bounded scan under churn, redaction, scroll preservation and no raw authority/history | +| Protocol | Bounded chronological append with direction/time/sequence/authority/scope/correlation/error, redaction, tail-follow vs paused scroll, one-row append and no rebuild | +| Agent row interaction | Existing visual design, default collapse, expansion retention, copy/status/disclosure order, lazy construction and row-local geometry | +| Info switch | State/Protocol toggle patches only the selected page and never resets unrelated Inspector tabs | + +Instrumentation records scans, materialized rows, row constructions, +patches, layouts, paints and style polishes per tab. A conversation-only stream +must leave every unrelated Inspector counter unchanged. Inspector tests are +also repeated while the user is scrolled within Plan, Agents, Requests, State, +Protocol and Changes. + +## Dialogs, menus, viewers, focus, and accessibility + +- Connection, New Thread, file selection, pending request, permission approval, + user questions, MCP elicitation, DiffViewer review, image viewer, thread + context menu, transport menu and sort menu retain their modality/modeless + behavior, geometry, focus, keyboard activation and canonical styling. +- Invalid required answers or JSON keep the modal open with all authored input; + untrusted labels/links are escaped; complete permission facts are disclosed; + secrets and raw payloads are not exposed. +- Popup dismissal has no click-through. Tab/Backtab traversal, Enter/Space + buttons, accessible names/tooltips, Markdown links and image targets work. +- Keyboard focus is visibly identifiable on every reachable editor, button, + card, disclosure, tab, menu action, link, image target, list row and nested + scroll view. The focus indicator is never clipped by a card, viewport, + overlay or splitter; it does not change geometry; focused content is scrolled + into view; and disabled/non-interactive content is skipped. Focus remains on + the same logical control through paint-only graph updates and stable-identity + materialization, returns sensibly after a modal closes, and never jumps due + to activity in another card, pane or thread. +- Clipboard success/failure and reduced motion are tested. Focus never jumps + from the composer or a card because an unrelated node changes or a hidden + pane becomes dirty. +- Unified/side-by-side and compact/expanded diff preferences persist across + threads; overview marks, scroll positions, selection and modeless lifetime + remain correct during refresh. + +## Failure, contention, lifetime, and performance UX + +- Queue full, eventfd wake failure, disconnect, lost controller, stale + generation, graph contention, malformed/unknown protocol, app-server error, + removal and shutdown are visible and never consume authored input silently. +- No non-idempotent mutation is retried. Shutdown cannot hang. Retired nodes + detach QWidget state before acknowledgement and no queued pass touches a + destroyed pane or stale generation. +- Continuous unrelated revisions cannot starve conversation, thread or + Inspector scans. Contention uses a nonzero bounded retry and does not create + idle CPU churn. +- Large thread selection and Load 80 may take a bounded, measurable delay, but + expose only the final layout. Ordinary live passes remain viewport/frame + bounded. Long streaming, large topology, Inspector bursts and bulk removal + keep Qt heartbeat and input responsive. + +## Full-application Xvfb movie suite + +The final application—not a small test window—is run with workspace-local +Codex home and config, a real `codex-bridge`, and the app-server it launches. +Movies are recorded at 30 fps and paired with logs/counters for these scripts: + +1. Fresh start idle for 30 seconds, connect, discover threads, and verify no + background visual churn or CPU loop. +2. Select a long multi-turn thread from no selection and from another populated + thread; use a mixed-card fixture with expanded running command output, + pending user decisions and all retained card families, and verify the first + changed conversation frame is already final. +3. Pause at top/middle/near-bottom, click Load 80, and verify exact anchor and + one final reveal. +4. While paused, admit a normal prompt; receive user item, progress, command, + approval/review, agent activity and completion in varied order; verify only + affected regions change. +5. Repeat with steering, including events before acknowledgement and + interruption; verify permanent parent, stable widget and anchor. +6. Stream a long visible response/command at the bottom and while paused; + compare dirty regions for Conversation, ThreadPane, Inspector, chrome and + settings. +7. Keep each Inspector tab visible and then hidden during bursts; verify + row-local updates, stable scrolling and latest-once activation. +8. Expand/collapse cards, agents and thread hierarchy; focus/copy/scroll nested + outputs; switch threads away/back and verify every local state. +9. Exercise request dialogs and attention actions, connection/controller + changes, every accept/reject/review/user-input/MCP decision, validation and + cancellation path, failure/recovery, thread removal and reconnect. +10. Resize and drag splitters during/after activity, then hold idle again to + prove geometry settles and CPU returns to idle. + +Acceptance uses frame-by-frame region differences. Expected animation regions +(pending prompt sweep and optimistic thread row) are whitelisted narrowly; +any changed pixels in unrelated panes, any partial card/row/layout, or any +anchor displacement fails the scenario. + +## Test selection and execution rule + +For each particular fix, first identify every checked-in native test executable +and every individual scenario that exercises the changed surface, its parent +layout, routing boundary, focus/scroll owner, and protocol admission path. Run +that complete affected set interactively on a dedicated Xvfb display with +`QT_QPA_PLATFORM=xcb`, not only with Qt's minimal `offscreen` plugin. Then run +the corresponding full CodexUI script above against real `codex-bridge` and +its app-server and inspect the movie frame by frame. A focused test gate is +followed by the full native suite and WebUI parity suite at the qualification +boundary. + +The inventory and test selection use only files checked into this local branch. +No GitHub or other remote operation is part of testing or documentation. diff --git a/docs/two-thread-shared-node-graph.md b/docs/two-thread-shared-node-graph.md new file mode 100644 index 0000000..3e9ecee --- /dev/null +++ b/docs/two-thread-shared-node-graph.md @@ -0,0 +1,411 @@ +# Two-thread shared node graph + +## Scope and baseline + +This document is the implementation contract for CodexUI's deliberately narrow +replacement of its native app-server-to-widget data path. It is not a reusable +event-sourcing system, presentation framework, protocol runtime, or callback +architecture. + +Work began on `codex/two-thread-shared-node-graph` with a clean worktree. `HEAD`, +that branch, `master`, and `origin/master` all resolved to +`dbcbb1b4d30dc24d96ed06647198a1abc6fa4c3c`. No repository-specific agent +instruction file was present; `README.md`, `docs/codex-architecture.md`, +`docs/ui-behavior.md`, the checked-in tests, and CI are the compatibility +oracles. + +The unchanged baseline built successfully. All nine native CTest tests passed +with an isolated writable `XDG_CONFIG_HOME`, and all 83 WebUI tests passed. +Without that isolated settings directory, `codexui-git-changes-live` failed its +persisted-resolution recreation case because the test shared ambient QSettings; +the isolated run records the product baseline without weakening a test. + +## One graph and two threads + +The native application has exactly two relevant execution threads: + +```text +Qt main thread existing SNode.C worker thread +------------------------------ ---------------------------------- +all QWidget ownership SNode.C event loop and transport +viewport and renderer mechanics CodexBridge decode/encode +short non-blocking graph reads <--> sole graph writer +typed user actions protocol updates and correlations +QSocketNotifier native descriptor receiver + | | + +-- bounded SPSC + eventfd each way ------+ +``` + +There is one current in-memory `NodeGraph`, shared by those threads. The +app-server remains authoritative for provider facts. This data path has no +mirror, snapshot history, journal, presentation model, view-state model, +projector, serialized internal protocol, or third execution context. The +pre-existing `GitDiffProvider` may use Qt's global thread pool for local +libgit2 work; it neither consumes app-server traffic nor reads or writes the +node graph and is outside this deliberately narrow data path. + +The framework-neutral implementation lives only in `src/codex/nodegraph/`, is +named `codexui-nodegraph`, and uses namespace `codexui::nodegraph`. Its +standalone headless tests live in `tests/codex/nodegraph/`. It has no dependency +on Qt, SNode.C, sockets, a running app-server, wall-clock timing, or any legacy +CodexUI presentation class. + +The independent gate is available without configuring the application: + +```sh +cmake -S . -B build-nodegraph -DCODEXUI_NODEGRAPH_ONLY=ON +cmake --build build-nodegraph +ctest --test-dir build-nodegraph --output-on-failure +``` + +## Nodes and current state + +Every node has: + +- a unique graph `NodeId` and concrete `NodeKind`; +- one immutable current `NodeState` storage object; +- ordered parent/child and directly derived cross-entity relations; +- a changed revision and removed marker; +- one opaque, non-owning UI attachment slot. + +Globally unique protocol entities, such as threads, use their canonical wire +IDs directly. Protocol turn IDs are scoped by thread and item IDs are scoped +by turn in the graph index; each scoped node retains its raw canonical wire ID +as `protocolId`. Process and watch identities also include the current +connection generation. Outbound calls recover the raw IDs at the protocol +boundary, while internal relations use stable `NodeRef`s. State and ordering +live on the nodes; no render commit or second domain model is introduced. +Unknown methods and tagged-union alternatives are retained in unknown +nodes/current fields without changing known state. + +Catalog responses remain current `Catalog` envelopes for response-level paging +and invalidation facts. Their addressable entities are also ordered child nodes +of the natural declared kind: `CatalogEntry`, `PermissionProfile`, `Skill`, +`Hook`, `Plugin`, `App`, or `McpServer`. Authoritative refreshes preserve the +`NodeRef` of retained entities, apply provider order, and retire omitted +entities. The remaining declared kinds likewise have concrete protocol +lifecycles; none exists only as an opaque catalog blob. + +Nodes are held by `std::shared_ptr`. The graph, queued notifications, +materialized widgets, and active reads therefore pin lifetime. Relations may +be non-owning while protected by graph synchronization. Removal unlinks a node +and erases its indexes under the write lock, marks it removed, and includes a +stable `NodeRef` in the direct Qt notification. If that notification coalesces, +the graph's retired-node set remains the lifetime source and Qt collects it in +bounded 64-node rescan slices. Qt clears the attachment and destroys the +QWidget on Qt-main, then acknowledges detachment through the typed action +queue; final node destruction waits for the graph retirement pin and every +other `NodeRef` to be released. + +Only Qt-main sets, clears, or dereferences the opaque attachment. The native Qt +adapter may place a `QPointer`, last rendered revision, and viewport +materialization state behind it. The worker never inspects it, and no permanent +NodeId-to-widget registry is allowed. + +## Synchronization and atomic updates + +One graph reader/writer lock protects state, indexes, relations, order, +revisions, and lifetime transitions. The SNode.C worker is the sole writer. +For each decoded message it prepares values before locking, takes one write +access, and applies every related node/index/relation change. A transaction +that changes graph state increments the graph revision exactly once, unlocks, +and only then queues a notification. An idempotent update or an explicitly +state-neutral message is a no-op: it does not advance either graph or node +revisions and does not queue `GraphChanged`. + +Qt uses try-read acquisition only. Failure schedules another Qt event-loop +pass; Qt never waits for the writer. A successful read either briefly pins a +node's immutable current storage or extracts only values needed for the visible +render. The access is released before any QWidget call. No callback executes +while a graph or queue lock is held. + +This gives Qt either the state before a decoded message or the complete state +after it, never an intermediate graph. + +Mutations validate membership and cycles and reserve or construct replacement +containers before changing topology. State storage and relation maps are +published with no-throw swaps after transaction bookkeeping is prepared. Batch +removal validates every `NodeRef` and builds all replacement maps, order, +retirement, and affected-node collections before unlinking anything. Rejected +validation is tested to leave lookup, order, relations, lifetime, and revision +unchanged. + +## Protocol contract + +The implementation's checked, closed inventory is `ProtocolCatalog.cpp`. Its +method names were verified against the app-server registry at Codex +`305eed102d6ab5fc1228fec0737ba240eb29826b`, including compatibility and +internal methods omitted by public schema generation. The accompanying +`docs/app-server-protocol/master-data-model.md` is historical protocol research, +not the design of this runtime. The complete surface is: + +| Direction | Methods | +| --- | ---: | +| client requests | 157 | +| server requests | 11 | +| server notifications | 83 | +| client notifications | 1 (`initialized`) | + +Those 252 methods have these exact dispositions: + +| Disposition | Methods | +| --- | ---: | +| worker operation/result | 158 | +| reverse interaction | 11 | +| graph update | 75 | +| typed UI effect | 6 | +| intentionally state-neutral | 2 | + +The six UI-effect notifications are `error`, `warning`, `guardianWarning`, +`deprecationNotice`, `configWarning`, and `windows/worldWritableWarning`. The +two deliberate no-ops are `rawResponseItem/completed` and +`rawResponse/completed`; their presence is recognized without creating a +second raw-response authority. + +Tests assert direction and disposition counts, uniqueness, lookup, and semantic +handling of every entry. Every one of the 157 requests retains its input fields +in one pending operation and consumes the exact response correlation. Every one +of the 11 server requests retains its fields and target relation and resolves +through its exact interaction `NodeRef`. Every server notification either +publishes concrete current state or is one of the two named neutral messages. +Focused tests additionally verify natural catalog entities, review targets, +reasoning-summary parts, environment state, compaction, provider-auth recovery, +and operation relations. The installed AISuite generated macros contribute 95 +client requests, 10 server requests, 76 server notifications, and the one +client notification. The verified newer set contributes 62 more client +requests (one supplied through a local typed compatibility adapter); local +typed adapters also supply one server request and seven server notifications. +Those named unions equal 157/11/83/1 without double-counting the adapted client +request. A changed generated binding set must be reconciled explicitly with the +verified catalog. + +CodexBridge performs native app-server JSON decode and encode. Its typed +frontend callbacks provide a decoded message containing method, direction, +request/correlation identity, and owned current payload values to a plain +dispatcher. The dispatcher finds or creates addressed nodes, updates fields +and directly affected relations/statuses, removes nodes when required, and +commits at most one revision. It is explicit application logic, not a generic +reducer, rule engine, dependency graph, callback registry, or second raw-JSON +parser. + +One CodexBridge raw-message hook has a deliberately narrow completeness role. +It observes the outbound `initialized` notification and retains inbound +methods absent from the closed catalog as unknown protocol nodes. It returns +immediately for every known inbound method, whose registered typed callback is +the sole update path. Raw app-server JSON never reaches Qt-main. + +Responses are correlated on the worker because response envelopes do not carry +their method. Server requests become pending interaction nodes and are removed +only on an accepted response or `serverRequest/resolved`. Known state-neutral +messages still pass through the exhaustive dispatcher and are tested. Unknown +messages are retained separately and cannot mutate an addressed known node. +Targeted operations also retain the expected stable `NodeRef` and connection +and provider generations. Late or mismatched results cannot update a +replacement node. `thread/read` records per-node and per-field revision stamps +at dispatch. A stale result merges field by field: independently changed facts +win, while absent or untouched authoritative identity/type fields are filled. +Hydration becomes ready only after usable identity/type data exists. An +authoritative replacement retires omitted provider-owned turns/items and stale +lookup IDs, while preserving only explicitly protected local optimistic tails. +Derived agent-child ownership is reference-counted across source items, and +fork changes remove the old source-to-child relation before assigning the new +one. + +## Typed mailboxes and wake-up + +Communication consists of exactly two bounded SPSC queues and two Linux +eventfds created with `EFD_NONBLOCK | EFD_CLOEXEC`: + +- worker to Qt: `GraphChanged`, `UiEffect`, or `WorkerStopped`; +- Qt to worker: `NodeAction`, `RuntimeAction`, or `ShutdownRequest`. + +`GraphChanged` carries the committed graph revision, stable references to +affected and removed nodes, and an explicit rescan-required flag. It never +copies `NodeState`. Eventfds carry only wake counts. A sender pushes one typed +message and writes `uint64_t{1}`; the receiving event loop reads the accumulated +counter and drains its queue. + +`NodeAction` carries a stable target `NodeRef`, a concrete action kind, and only +newly authored owned data. The worker re-reads all existing target state before +acting. `RuntimeAction` covers connection, controller, catalog, refresh, and +explicit new-thread work that has no existing target node. Pure widget gestures +such as fold, copy, focus, and scroll remain on Qt-main and never enter either +mailbox. + +The worker-to-Qt queue has 512 slots. Ordinary graph changes and notices stop +at 510; one slot remains available for a critical selection effect and the +final slot for `WorkerStopped`. A graph transaction containing more than 64 +direct node references also uses rescan instead of creating an unbounded +notification. Worker-to-Qt saturation occurs only after graph state is +committed. It records the newest revision in an explicit rescan-required +condition and wakes Qt, so current state remains discoverable. Sequenced +selection and notice effects also retain their latest value in graph state if +direct delivery is full; Qt ignores any older queued effect after reconstructing +that value. + +The Qt-to-worker queue has 256 slots. Ordinary actions stop at 255 so shutdown +retains the final slot. A user action is admitted exactly once or rejected +visibly without moving its authored payload out of Qt. Non-idempotent actions +are never retried automatically. Large prompt text and attachments move into +an admitted command rather than being copied. A wake failure after admission +is reported as admitted and non-retryable, preventing a duplicate send. + +Thread deletion and provider-generation reset do not discard an admitted +prompt or turn it into an automatic resend. The worker reparents its stable +local prompt node to explicit recovery state in the same graph transaction, +retaining the authored text and attachment links while marking a definite or +uncertain outcome. A late result cannot revive that operation. Qt presents the +retained recovery input, and only a new deliberate user action may submit it. + +Qt observes the worker eventfd with `QSocketNotifier`. SNode.C observes the Qt +eventfd with its native descriptor mechanism. The prior socketpair/JSONL path +is removed after cutover and is not retained as a fallback. + +## Widget and UX compatibility contract + +The complete class, method, DTO, ordering, failure, and thread-affinity +contract is [`ui-ux-internal-api.md`](ui-ux-internal-api.md). The adapter and +Shell integration are accepted only when they satisfy that contract as well as +the visible behavior in `ui-behavior.md`. + +The normative internal boundary is `docs/ui-ux-internal-api.md`. In +particular, a complete DTO is extracted under one short graph read, the guard +is released, and only then is the established widget API called. The adapter +owns no projected state. Widget-local focus, scroll, fold, draft, expansion, +and menu state remain authoritative for UX mechanics. + +Existing native widgets and styling remain the renderer. Conversation history +remains in NodeGraph, while the adapter supplies the established view with one +bounded 80-activity DTO plus any pinned owning prompts. Selection and Load 80 +materialize the complete supplied window during the old view's shortest +update-suppressed reconciliation and expose only its final parented layout. +Cards are retained when scrolling offscreen; scrolling performs no destruction +or late rematerialization. New selected-thread cards are materialized in the +same atomic reconciliation even while the user is paused above them. Stable +keys, retained widget-local state, and anchor restoration preserve scroll and +horizontal position. A strict append to the selected history's last Turn (or +one new last Turn root) settles only the new card and commits exact cached +height deltas through its Turn, section, and content extent. It does not ask Qt +to traverse retained card layouts; all non-tail or otherwise structural cases +remain on the complete validated reconciliation path. Thread rows follow the +expanded hierarchy, and Inspector +constructs rows only for the active tab when its effective snapshot changes. +There is no permanent parallel NodeId-to-widget registry. Focus, animation, +folding, filters, drafts, editor mechanics, and scroll-following remain +genuinely local QWidget state. + +The Inspector keeps its useful State view and a bounded chronological Protocol +view. Protocol diagnostics retain direction, sequence/time, semantic +authority, scope, correlation, and safe errors as metadata only; credentials +and sensitive IDs are redacted, raw request/response payloads are not retained, +and only the newest 2,000 lines remain. This diagnostic tail is explicitly +non-authoritative. The Agents projection groups current protocol items by +canonical child thread ID (falling back to the spawn item only when necessary), +so replay, progress, completion, and interruption update one logical row in +stable first-spawn order without removing canonical items from `NodeGraph`. + +The complete visible behavior in `docs/ui-behavior.md` remains required, +including: + +- root/child thread hierarchy, ordering, selection, hydration, history paging, + create, rename, fork, archive, unarchive, delete, and reload; +- stable turn/item/card identity, unknown-item fallback, stream deltas, plans, + agents, generated images, attachments, Markdown, process/file/MCP details, + copy, folding, filters, focus, scroll anchoring, and follow-latest behavior; +- exact prompt keyboard rules, per-thread admission queues, drafts, + attachments, optimistic cards, acknowledgement/error feedback, steering, + recovery, and no duplicate or dual-send transition; +- command/file approvals, permissions, user-input requests, MCP elicitation and + tool calls, validation, attention state, and exact reverse responses; +- models, settings, permission profiles, account/rate limits/usage, skills, + hooks, plugins, apps, MCP catalogs, connection/provider/controller state, + and notices; +- local Git Changes repository resolution, live refresh, diff presentation, + Inspector behavior, dialogs, menus, desktop identity, accessibility, and + progress presentation. + +Native tests in `tests/codex/` and the WebUI parity suite are behavioral +oracles. They may be extended but not weakened. + +## Implemented cutover and qualification + +`ClientRuntime` now hosts `WorkerLogic` and `ProtocolUpdater` beside +CodexBridge on the existing SNode.C worker. Every known inbound callback +applies a decoded typed payload to the graph. Provider-facing widget actions +enter the one typed Qt-to-worker queue. Each concrete outbound operation is +revalidated against current connection, controller, generation, target, and +active-turn state before its one direct CodexBridge call; compound workflows +such as creation followed by the first prompt remain ordered distinct +operations. The same mailbox carries the two concrete local lifecycle actions: +prompt-materialization acknowledgement and removed-widget detachment; they +update graph lifetime state without a bridge call. The old presentation +authority, JSONL framing, socketpair endpoints, and temporary comparison path +have been deleted; no outbound operation is dual-sent. + +Thread hydration readiness is current graph state keyed to provider +generation. Failed or unresolved hydration rejects admission before moving the +user's draft. Local prompts retain exact admitted text plus safe ordinary-file +Markdown, use request-result acknowledgement independent of item correlation, +and preserve stable card/thread-row identity through canonical promotion. +An empty new-thread draft is discarded when the user selects a real thread; +once its first creation action is admitted, a second New Thread command is +visibly rejected until that exact correlation resolves. Conversation history +reveals retained older nodes before requesting another provider page, and a +paused viewport grows only its effective tail window until following resumes. +Streaming response, reasoning, plan, and command-output fields retain a +UTF-8-aligned 192 KiB newest tail after crossing the 256 KiB threshold and +carry exact omitted-byte metadata that both rendering and copy disclose. +Deleted threads and provider resets reparent affected local prompts to explicit +recovery state; reconnection never resends a non-idempotent operation. +Conversation widgets are materialized for the bounded selected history window +in one invisible old-view transaction and remain retained while scrolling. + +Qualification covers the standalone target/tests, exact source-derived +inventory, graph atomicity, non-blocking read contention, removal lifetime, +queue saturation and stale-effect ordering, eventfd coalescing, moved large +payloads, worker ownership, scoped identity collisions, realtime append/final +semantics, and bounded visible-only rendering. It also includes a Qt heartbeat +while 4,096 distinct inbound items plus 4,096 streaming deltas saturate and +drain the notification queue. + +The direct CodexBridge integration test exercises every supported UI wire +family rather than only counting method names: hydrate/reload, history paging, +rename, fork, archive/unarchive/delete, new-thread creation, turn start and +steering, interruption, thread/catalog refresh, and all 11 reverse-request +families. It verifies encoded addressing and authored fields, exact operation +targets and correlations, decoded success/error results, and absence of a +second wire send. + +Focused performance qualification on the final Debug build measured the +1,024/2,048-delta long stream at 40.1/80.2 ms, 1,500/3,000-item thread deletion +at 4.5/9.1 ms, and 3,000/6,000-node graph batch removal at 4.4/9.2 ms. The +large-render tests keep full history in NodeGraph while exposing only the +requested 80-item (or explicitly expanded) conversation window. Continuous +unrelated graph revisions do not restart selected-pane work, identical DTOs are +presentation no-ops, and contention retries use a bounded nonzero delay. + +Qt smoothness qualification uses the established widgets as one retained, +virtualized selected-thread surface. Multi-card selection and Load 80 create +rich cards one at a time under a hidden staging parent, then reparent the +already-current widgets and commit final geometry once. The final commit does +not reapply presentation to staged card subtrees. Ordinary graph changes route +to the exact card, thread row, visible Inspector dependency, or effective +chrome value; they do not treat a graph or Thread revision as repaint authority. +The 81-card final commit measured 81--82 ms in three normal Debug runs. A +28-second, 60-fps full-application capture of a 1,800-line command showed +sustained in-place conversation motion while the interiors of ThreadPane, +Inspector, and shell chrome remained visually unchanged. A separate steering +capture preserved the paused viewport across optimistic admission and +authoritative completion. Artifact names and the complete protocol for those +movies are recorded in `ui-ux-internal-api.md`. + +The final clean qualification ran the independently configured nodegraph-only +suite in Debug, AddressSanitizer, and ThreadSanitizer builds (5/5 tests in each, +with no sanitizer findings and no Qt libraries linked), the integrated native +suite (17/17), three consecutive passes of the mailbox, graph-concurrency, +runtime-dispatch, and shell-integration tests, and the WebUI compatibility suite +(83/83). The newest complete ASan/UBSan integrated run produced no sanitizer +diagnostic; 16/17 suites passed, and only the shell suite's unchanged 100 ms +wall-clock performance assertion exceeded its unsanitized budget under +instrumentation (163 ms offscreen, 189 ms on Xvfb). The normal Debug suite and +full-application Xvfb evidence satisfy that product timing boundary. diff --git a/docs/ui-behavior.md b/docs/ui-behavior.md index 7cad721..bb6f4a7 100644 --- a/docs/ui-behavior.md +++ b/docs/ui-behavior.md @@ -1,5 +1,10 @@ # CodexUI Interaction and Presentation Decisions +The method-level contract used to supply these behaviors is +[`ui-ux-internal-api.md`](ui-ux-internal-api.md). That document is the +canonical logic/UI boundary for the native shared-node-graph integration; +this file remains the visible-product behavior oracle. + This document defines the current CodexUI interaction contract. AISuite and the Codex app-server own protocol and domain semantics; CodexUI owns only local presentation, input, selection, and scroll state. @@ -11,26 +16,37 @@ blue-tinted checked actions, muted disabled actions, and inset separators. ## Conversation source and structure -`UiSession` owns the sole retained `PresentationModel` for normalized UI state -and projects toolkit-neutral snapshots. The concrete message view consumes its -selected-thread snapshot plus client-local prompt admissions; cards and -inspectors do not access the model or maintain a second domain store. Qt keeps -only renderer mechanics such as scroll anchors, folding, expansion, focus, -geometry, and the editable composer form. +One shared `NodeGraph` is the sole current native representation of app-server +state. The SNode.C worker writes it and Qt performs short non-blocking reads of +the selected thread's nodes. Cards and inspectors pin only current immutable +node storage needed for a render pass; they do not retain another domain or +view model. Qt keeps only renderer mechanics such as scroll anchors, folding, +expansion, focus, geometry, and the editable composer form. + +Conversation history is retained canonically in NodeGraph. The established +view receives only the current 80-activity window (plus pinned owning prompts) +and materializes that complete window invisibly inside one reconciliation. +Clicking Load 80 expands the complete retained window in the same way. Cards +remain materialized when merely scrolled offscreen; scrolling never replaces +them with placeholders or exposes late construction. Thread rows materialize +for the currently expanded hierarchy. Inspector row widgets are constructed +only for the active tab when that tab's effective snapshot changes. Every graph +read ends before a QWidget is called, and no permanent parallel domain model or +NodeId-to-widget registry exists. The conversation has one semantic grouping level: an app-server turn contains its items in server order. When a turn has a prompt, its first You card is the visible turn container and owns all later cards from that turn. Steering You cards are nested with the activity they steer rather than starting a second visual turn. For every turn represented in the retained activity window, the -projection identifies that opening prompt from the complete authoritative turn +graph-backed renderer identifies that opening prompt from the complete turn and pins it outside the activity budget. History paging therefore never promotes a later steering You card to turn ownership; loading earlier activity retains the same root identity without duplication. Authoritative cards are keyed by stable thread, turn, and item IDs; local prompt cards are keyed by their submission IDs. The same keyed reconcile path handles initial display and updates, mutating a card in place when its visible data changes. An -identical visible projection does not rebuild widgets or change geometry. +identical visible node state does not rebuild widgets or change geometry. The complete prompt content, including attachments, adds the canonical 8 px structural section gap before its first nested turn card. This spacing is layout geometry and never becomes part of authored Markdown. @@ -47,14 +63,15 @@ bottom or is owned by the user. - The selected thread is identified by its stable app-server thread ID. - The Conversation heading reports `Last activity` from one monotonic - presentation timestamp. After hydration it starts at the greater of the + effective activity timestamp. After hydration it starts at the greater of the app-server's `updatedAt` and optional `recencyAt`. During the live session, meaningful thread-scoped protocol traffic in either direction advances it immediately. Selection-driven `thread/read` and `thread/resume` hydration, global connection traffic, and catalog traffic do not count as activity. Live traffic does not alter thread ordering. Only local prompt admission - advances the presentation model's effective `updatedAt` and `recencyAt`; - these local values are not persisted by CodexUI. + advances the thread node's local effective activity fields used by the + `Recent` and `Last changed` comparators; these values are not persisted by + CodexUI. - The visible sidebar order contains confirmed root threads only. Minimal thread placeholders created by scoped protocol traffic remain retained but invisible until an explicit list, read, resume, create, or fork admits them @@ -104,7 +121,7 @@ bottom or is owned by the user. preserved while the draft's queued prompts continue independently. - Selecting a thread hydrates it once per bridge connection even when the discovery result already contains an active turn. The full read is merged - into the retained per-thread presentation, so live Plan and Agents state + into the thread's current graph nodes, so live Plan and Agents state cannot be erased by an incomplete reconstruction. Reload remains the explicit forced fresh-read action. @@ -118,9 +135,9 @@ events and Enter used to confirm an active input-method composition never submit a prompt. Auto-repeat is consumed instead of inserting an accidental newline. Send and Steer are enabled only when admission is available and the draft contains non-whitespace text. Focus uses the canonical blue composer -border without changing its geometry. Whitespace is used only for admission -validation: the exact authored text, including intentional leading and trailing -space and blank lines, is passed to the submission path unchanged. +border without changing its geometry. The legacy submission contract trims +leading and trailing whitespace once; whitespace and blank lines inside the +trimmed prompt remain unchanged. Submitting a prompt creates a client-local pending prompt card at the bottom of the destination thread immediately. The card begins with the calm blue @@ -141,14 +158,14 @@ whether pending feedback is visible; it never acknowledges or promotes the prompt. If the authoritative app-server item arrives before or after the result, it inherits the pending card's stable visual anchor and replaces it as soon as both correlation and acknowledgment are complete. Only the correlated -`turn.start` or `turn.steer` completion callback acknowledges a prompt; +`turn/start` or `turn/steer` completion callback acknowledges a prompt; conversation events never infer acknowledgment. Each operation carries a unique `clientUserMessageId`, which binds the authoritative user item without confusing identical prompt text. A failed submission remains visible with an explicit error state. A prompt that starts a turn is the outer soft-blue turn card. A prompt admitted -through `turn.steer` appears immediately inside the active turn as a calm teal +through `turn/steer` appears immediately inside the active turn as a calm teal `You` card with a right-aligned `steering` specialization. It uses the same one-second delayed-feedback rule as the outer card. After acknowledgment, the same widget becomes a soft-teal inset steering card with @@ -159,7 +176,7 @@ it without changing existing nested card identity. At acknowledgment, the retained outer You card immediately uses the stronger static blue running border. That border belongs to the card across its local- prompt-to-authoritative-message morph while pending feedback stops; it -has no animation, glow, shading, or geometry change. A successful `turn.start` +has no animation, glow, shading, or geometry change. A successful `turn/start` result retains active ownership until the separate authoritative lifecycle catches up, so the optimistic-to-running handoff has no neutral-border frame. Completion restores the canonical border in place. @@ -172,12 +189,16 @@ visibly selected at that moment. Explicit new-thread creation still starts with a deliberately cleared composer. Accepting New Thread immediately inserts one selected orange animated row in -the thread list. It is a presentation-only draft, not a synthetic app-server +the thread list. It represents a client-local draft row, not an app-server thread. Sending the first prompt promotes the same row to the ID returned by `thread/start`; animation continues until that prompt's `turn/start` callback succeeds, then the same row adopts canonical styling. Creation or first-prompt failure stops animation and leaves the row visibly failed. No duplicate row or replacement transition is permitted. +Selecting an existing provider thread abandons an empty, unsubmitted local +draft and removes its optimistic row. After the first prompt has been admitted, +New Thread is visibly refused until that exact creation resolves; the existing +row, correlation, and authored input are never replaced by a second creation. CodexUI queues submissions per thread and dispatches them in order: only one unacknowledged prompt operation is in flight for a thread. After each result, the next queued prompt is sent using the app-server state produced by the @@ -185,17 +206,25 @@ preceding acknowledgment. Different threads remain independent. Submission waits until the destination thread has completed its connection- generation hydration. A provider-marked `notLoaded` thread is resumed before -the turn operation. If a submission still receives a transient thread-not-found -result, CodexUI performs one bounded resume-and-retry; a repeated failure is -shown on the pending card rather than retried indefinitely. If hydration has -failed, submission is rejected without clearing the composer draft; Reload -must succeed before that prompt can be admitted. A disconnect after admission -leaves the pending card in place; a dispatched prompt is returned to its queue, -and bridge-open re-drives queued work only after fresh thread hydration. Real -app-server operation failures remain terminal. An active resume prevents a -concurrent hydration read or turn operation for the same thread. - -For an explicit new-thread draft, prompts entered while `thread.create` is in +the turn operation. If hydration fails before admission, submission is rejected +without clearing the composer draft; Reload must succeed before that prompt can +be admitted. + +An admitted prompt is never submitted again automatically. In particular, a +thread-not-found result, disconnect, thread deletion, or provider-generation +reset cannot trigger a resume-and-resend path for `turn/start` or `turn/steer`. +If deletion or a provider reset invalidates the destination while work is +queued or in flight, the local prompt is detached from the invalid thread and +retained in explicit recovery state with its exact admitted text and attachment +links. The recovery card distinguishes definite failure from an outcome that +may already have reached the provider. Reconnection and hydration do not send +it; recovery requires a deliberate user action. Restoring a recovery card +never overwrites non-empty composer text or attachments: the current draft +remains intact until the user sends or clears it. Other app-server operation +failures remain terminal. An active resume prevents a concurrent hydration +read or turn operation for the same thread. + +For an explicit new-thread draft, prompts entered while `thread/start` is in flight remain attached to that draft. When creation succeeds, all pending prompts move to the returned stable thread ID and are dispatched in order. @@ -256,7 +285,7 @@ retained source as both plain clipboard text and `text/markdown`, never reconstructed rendered text. Structured cards copy a deterministic plain-text representation of their primary content. After a successful write, only the copy glyph quickly morphs into the canonical green check, remains a check for -1.5 seconds, and morphs back without moving the header. A rounded, +0.5 seconds, and morphs back without moving the header. A rounded, non-layout-shifting `Copied` overlay appears at the action. Web clipboard failure keeps the copy glyph and uses the same local overlay with canonical error styling; reduced-motion mode makes the icon transitions immediate @@ -269,12 +298,16 @@ title remains at the left; no separator glyph is rendered. Pending-request dialogs validate required answers and structured MCP content before accepting the modal. Invalid input keeps the dialog and all entered content open for correction. +If controller or provider state changes after a response enters the typed +mailbox but before the worker can send it, no automatic retry occurs. The +interaction node retains the authored response and its error so reopening +Review restores the entered decision, answers, or structured content. The native and web Conversation headers expose persistent, matching icon-only controls for Reasoning visibility, interim Codex-update visibility, and the initial folding state of newly appearing Command execution and Image cards. Final Codex answers are never filtered. Visibility is a presentation choice only: filtered -cards remain in the retained projection, continue accepting updates, and reappear +cards remain as retained graph nodes, continue accepting updates, and reappear with their latest content and user-owned folding state. Changing the Command preference never refolds an existing card. Browser persistence is an optional convenience: unavailable or denied local @@ -308,8 +341,8 @@ sender, and receivers are shown only when app-server supplied them. Textual `plan` items remain conversation content. Structured `turn/plan/updated` state is shown only in the Inspector Plan tab, avoiding a duplicate representation in the conversation. Its typed conversation key, -conversion, placement, and renderer remain implemented behind a disabled -projection switch so this policy can be reactivated narrowly if required. +conversion and renderer remain available to textual plan items; structured +turn state is deliberately not materialized as a conversation card. ## Conversation scrolling @@ -335,7 +368,7 @@ offset. Appends below the viewport keep the scrollbar value unchanged; card reflow or reconstruction restores that visual anchor after Qt completes layout. Incoming data therefore cannot move the user's reading position merely because content above or below it changed size. Protocol updates that do not change a -card's visible projection do not rebuild that card. Multiple visible card +card's visible node state do not rebuild that card. Multiple visible card changes from one refresh are applied as one paint-suppressed layout transaction with one anchor restoration, including streaming Command execution updates. Incoming deltas are coalesced to at most one reconcile per display interval; @@ -345,6 +378,9 @@ New authoritative cards are inserted at their server-ordered position without reconstructing retained cards. While following is paused, the effective history window expands with incoming cards so its visible anchor is not evicted; the requested bound is restored after following resumes. +Load more expands the retained in-memory window first. It requests an older +provider page exactly once only when that click reaches the retained-history +boundary, so revealing already loaded cards never creates duplicate wire work. User scrolling to the current bottom re-enables following. A generic Qt range clamp caused by card reflow does not count as user intent and cannot silently @@ -422,13 +458,17 @@ as the complete command output, response, reasoning, or plan text. ## Inspector and Info presentation The State and Protocol viewers use the common CodexUI scrollbar styling and -show vertical scrollbars only when needed. The Protocol log occupies the -expanding area of its tab; protocol statistics are displayed below the log. -Protocol and State data are diagnostic presentation only and do not create -domain authority. Plan, Agents, and Requests use retained per-thread -presentation snapshots. Agent cards start collapsed and expose status, copy, -and fold actions in that order; folding changes presentation only and never -discards agent content. Changes instead resolves local Git repositories upward +show vertical scrollbars only when needed. State summarizes the current shared +graph. Protocol lists bounded current operation and unknown-protocol nodes; +revision, node, operation, pending, and unknown counts remain below it. These +diagnostics are current graph views, not a raw frame log or a domain authority. +Plan, Agents, and Requests read the selected thread's current nodes in bounded +passes. Each uses at most 48 materialized rows plus a two-row overscan and +fixed-height leading/trailing spacers; moving its viewport schedules a fresh +non-blocking graph scan for the newly visible window. Agent cards start +collapsed and expose status, copy, and fold actions in that order; folding +changes presentation only and never discards agent content. Changes instead +resolves local Git repositories upward from the selected thread's retained command working directories and refreshes them asynchronously through libgit2. When several repositories match, All repositories is the default and a selector can narrow the view. Resolution diff --git a/docs/ui-ux-internal-api.md b/docs/ui-ux-internal-api.md new file mode 100644 index 0000000..fb0e0de --- /dev/null +++ b/docs/ui-ux-internal-api.md @@ -0,0 +1,738 @@ +# Native UI/UX internal API contract + +This document is the canonical contract for the boundary between CodexUI's +application logic and the established native Qt UI/UX. It describes behavior, +call ordering, identity, ownership, and failure semantics in addition to C++ +method signatures. `docs/ui-behavior.md` remains the visible-product contract, +and `docs/native-ui-ux-qualification-inventory.md` remains the test inventory. + +The reference API is the native UI at +`dbcbb1b4d30dc24d96ed06647198a1abc6fa4c3c`. The shared-node-graph cutover may +add narrow adapter hooks, but it must not make widgets parse protocol data, +read the graph, own application state, or change the meaning of an existing +entry point. + +## Boundary and call discipline + +The UI accepts complete, toolkit-neutral values and emits user intentions. +The graph adapter owns no nodes and retains no projected state. A projection +call follows this sequence: + +1. Qt tries one short graph read. +2. The adapter validates the supplied stable `NodeRef` and extracts only the + values required by the established UI DTO. +3. The graph read guard is released. +4. Qt calls the existing widget API with the complete DTO. +5. The widget compares stable identity and visible values, mutates its own + QWidget tree, and retains genuinely local state such as focus, scroll, + folds, draft text, settings edits, or menu state. + +No QWidget method, signal callback, modal dialog, notification, or typed +mailbox send may occur while a graph read is held. A failed nonblocking read +schedules a nonzero-delay Qt retry and changes no visible state. A graph +revision alone is not a UI instruction. + +User actions travel in the opposite direction. The widget callback identifies +the exact visible object. Shell code resolves or receives its stable +`NodeRef`, releases any graph read, moves only new authored payload into one +typed action, and attempts admission once. Rejection keeps authored input in +the widget. Admission may be followed by a wake-failure notice, but never by +an automatic retry of a non-idempotent action. + +## Compatibility matrix + +The status column describes the current shared-graph branch. "Compatible" +means both shape and temporal semantics match. "Narrow extension" means the +old entry point still has its old meaning and an additional method carries a +concrete graph-cutover requirement. + +| Surface | Established behavior | Current status | +| --- | --- | --- | +| `ThreadPane::Actions` | Emits New, Refresh, Hide, Select, Reload, Rename, Fork, Archive toggle, and Remove exactly once using the row's canonical string ID. | Compatible. Shell resolves the visible ID to the exact current `NodeRef` before admission. | +| `ThreadPane::refresh` | Consumes one complete hierarchy snapshot; retains expansion, selection, sort choice, optimistic rows, hover/context state; identical effective rows do no work. | Compatible after restoring provider/controller gating, effective activity time, unreachable-root retention, and ordered child relations. | +| optimistic thread methods | Begin one draft row, promote it without replacing its visual identity, mark failure, and remove only on confirmation/abandonment. | Compatible. Promotion is correlated to the admitted creation prompt rather than guessed from later payload fields. | +| `ConversationView::reconcile` | Consumes one complete `ConversationSnapshot`; keys mutate compatible cards in place; one Turn section owns one opening You card and all nested cards; identical snapshots are a no-op. | Compatible after restoring encoded section keys, canonical root pinning, stable prompt aliasing, and the identical-snapshot early return. | +| initial conversation selection | Never exposes part of an authoritative replacement. The first content frame has complete cards, final parentage, final width/height, and final anchor. When switching populated threads, the outgoing surface stays stable until the incoming final snapshot is ready. | Compatible. Provider fragments are blocked, the outgoing conversation/heading/Inspector remain staged, and readiness replaces them once with the complete bounded window. | +| conversation history window | Starts at 80 authoritative items. Pinned opening prompts do not consume the budget. Load More adds 80. While paused, new authoritative tail items expand the effective window; following resets it to the requested window. | Compatible after replacing the unbounded adapter request with per-thread requested/effective counters and excluding local prompts from the authoritative count. | +| card DTO and rendering | Typed payloads preserve the old card kinds, text, metadata, status, images, truncation disclosure, plan, diff counts, and unknown fallback. Presentation options are applied by `ConversationView`, not by protocol logic. | Compatible. Generic detail is a safe bounded rendering string because the graph deliberately does not retain raw payloads for UI convenience. | +| prompt materialization | An admitted local card keeps its `LocalPromptKey` while the authoritative user item arrives; the same widget changes type in place and preserves owner, anchor, focus, and local fold state. | Compatible through a narrow additive callback carrying the exact prompt `NodeRef`; widgets do not inspect graph state. | +| prompt recovery | A definite/uncertain failed prompt remains visible and restores text/attachments only by explicit user action, without overwriting an existing draft. | Compatible through a narrow additive recovery callback carrying the exact prompt `NodeRef`. | +| `setEmptyMessage` | Changes only the empty-state text and preserves the current anchor/follow behavior. It does not authorize clearing an existing conversation. | Compatible. Hydration staging decides whether an empty snapshot may be reconciled. | +| presentation options | Reasoning/Codex-update visibility and initial command/image folding remain local UI preferences; changing them reuses current card widgets and state. | Compatible. The adapter does not reinterpret these preferences. | +| scroll/follow API | `modeForThread`, `isAtBottom`, wheel forwarding, trailing composer space, and local-prompt preparation remain owned by `ConversationView`; each thread retains mode and anchor. | Compatible; old widget implementation is retained. Full mixed-history movie qualification is pending. | +| `ComposerPane::Actions` | Submit returns admission success; only success clears the draft. Stop, Attach, Accept, Review, and Deny are exact one-shot intentions. | Compatible. Submit/Steer selects the operation from canonical active-turn state and targets the visibly selected thread. | +| composer state setters | Attention, active turn, submit eligibility, settings eligibility, attachments, and overlay height are effective visible state; repeated values must not rebuild the composer. | Compatible. `setAttentionEnabled(bool)` keeps its original meaning; `setAttentionActionEnabled` is an additive split needed for recoverable review versus provider-actionable buttons. | +| `TurnSettingsWidget::setContext` | Reconciles identity, canonical settings, model catalog, permission profiles, revision, and update while preserving locally touched fields. | Compatible. Additive `setCanonicalContext`, `setModelCatalog`, and `setPermissionProfileCatalog` allow unrelated graph revisions not to rebuild catalog controls. | +| `MiddleRegionWidget` | Sole owner of three-pane geometry, heading, notice overlay, pane visibility, splitter state, option buttons, and cross-pane wheel routing. | Compatible. Existing signatures and geometry behavior remain; heading setters now return early for identical effective values. | +| `InspectorPane::refresh` | Accepts one complete Inspector DTO but refreshes only the visible tab; tab/scroll/expansion state is local and unrelated tab data creates no QWidget work. | Compatible. The adapter may calculate current values, but hidden tabs retain no secondary authority and perform no QWidget work. | +| Inspector Agents | One row per logical spawned child; canonical child-thread ID wins, spawn item ID is fallback; later progress/result/status updates the same row; terminal status wins; first-spawn order is stable. | Compatible after correcting activity-item projection and restricting creators to real spawn tools or started/empty sub-agent activity. | +| Inspector State | Shows useful bounded current state, counts, selected context, current domains, and pending interactions without secrets or raw protocol authority. | Compatible. Values are derived on demand from the current graph. | +| Inspector Protocol | Appends bounded chronological diagnostic metadata with time, sequence, generations, direction, authority, scope, correlation, outcome, and safe error. It is explicitly non-authoritative and preserves paused/tail scroll. | Compatible through additive `appendProtocolDiagnostic`; the legacy `appendProtocolFrame` entry point remains and converts safe metadata only. | +| shell chrome/status | Heading, connection, controller, requests, workspace, settings, status, and composer eligibility change only when their effective visible values change. | Compatible after provider readiness and hydration gating; memoized effective values prevent item streaming from rewriting chrome. | +| removal/lifetime | Removed nodes detach their QWidget references synchronously before retirement acknowledgement; queued stable references keep nodes alive but cannot resurrect them. | Compatible. | +| graph contention | No blocking, no visible half-update, no zero-delay retry loop, and no loss of the newest reduced state. | Compatible in focused tests; full sustained-contention qualification remains. | + +## Class and method reference + +This section is exhaustive for the logic/UI boundary. Private layout helpers +and ordinary Qt overrides are implementation details unless their behavior is +called out below. + +For every class below, “Qt-main only” is a hard thread-affinity requirement. +No method throws as part of its normal contract. Allocation failure may still +propagate according to ordinary C++/Qt rules, but must not leave a published +partial graph transaction or an exposed partial QWidget structure. Parameters +described as values may be moved by the callee after admission; const-reference +DTO parameters remain owned by the caller for the call. Callbacks are replaced, +not accumulated, by their setter. + +### `ui::NodeGraphUiAdapter` + +This final, non-QObject class is the only graph-to-established-DTO translator. +It stores only a non-owning pointer to the one `NodeGraph`. It must not cache a +projection, register callbacks, create widgets, or own a `NodeRef` beyond the +duration/value returned by a call. + +Thread/lock contract: called on Qt-main. Each query performs one nonblocking +`tryRead`; successful DTO construction occurs under that read and returns only +after the guard is destroyed. It never calls a callback or QWidget. `nullopt` +always means “no coherent value was available now”, never “render empty”. + +- `NodeGraphUiAdapter(const NodeGraph&)` binds the one canonical graph. The + referenced graph must outlive the adapter. +- `threads(selectedThread)` tries one read and returns the complete + `ThreadListSnapshot`. `nullopt` means contention and must cause a delayed + retry, not an empty list render. The optional target is accepted only when + it is the current non-removed graph instance. +- `conversationInfo(thread)` returns the lightweight hydration/history facts + needed before a potentially larger projection: authoritative item count, + display readiness, hydration failure, and provider continuation. Local + prompts never contribute to the authoritative count. +- `conversation(thread, itemLimit, options)` returns one complete retained + `ConversationSnapshot` for a validated thread. `itemLimit` is the effective + per-thread history window, never an instruction to mutate graph state. + `options` mirrors the existing presentation preferences; visibility remains + the widget's responsibility so toggling it can reuse widgets and local fold + state. +- `card(thread, item, options)` projects one validated item only when the item + is still parented by a Turn owned by the supplied thread. It is reserved for + a targeted visible-card update and must never reconstruct identity from + payload fields. A stale/detached item returns `nullopt`. +- `ConversationOptions` carries only `showReasoning` and + `showCodexUpdates`; it owns no filter state. +- `ConversationInfo` is adapter control metadata, not a presentation model or + widget snapshot. + +| Method | Parameters and return | Preconditions, postconditions, failure | +| --- | --- | --- | +| constructor | `graph`: long-lived canonical graph; no return | Pre: graph outlives adapter. Post: no read and no allocation is performed. | +| `threads` | `selectedThread`: optional stable target; returns optional complete DTO | Stale/removed selection is represented as no selected ID, while valid roots still project. Contention returns `nullopt` without side effects. | +| `conversationInfo` | `thread`: required stable Thread; returns optional control facts | Wrong kind, stale generation, removal, or contention returns `nullopt`. Success does not construct card DTOs. | +| `conversation` | `thread`, positive effective `itemLimit`, presentation `options`; returns optional complete snapshot | Pre: the caller has observed `conversationInfo.readyForDisplay`; this primitive projects the graph's current content and does not itself infer temporal hydration completeness. Limit is clamped to at least one. Success preserves canonical order and root ownership. Invalid target/contention returns `nullopt`. | +| `card` | exact `thread` and `item`, presentation `options`; returns optional card DTO | Success requires the item still be a child of a Turn owned by the exact thread. It never searches by payload IDs. | + +### `middle::ThreadPane` + +`ThreadPane` owns the sidebar's QWidgets, selected-row rendering, expanded +thread IDs, current sort criterion, optimistic row animation, context-menu +state, and row comparison values. + +Thread/ownership contract: Qt-main only; QObject parenting owns every row and +popup. The pane owns no graph references or provider state. Callbacks may enter +shell code synchronously, so all caller graph guards must already be released. + +- `ThreadPane(parent)` constructs the established sidebar and restores its + persisted local sort/expansion behavior. +- `setActions(Actions)` replaces the callback bundle. Missing callbacks make + the corresponding gesture a no-op; callbacks execute without graph locks. +- `refresh(snapshot)` compares a complete DTO with the last effective rendered + list. It patches/reorders only as required, preserves local expansion and + context state, and does nothing for an identical effective list. It never + initiates hydration or provider operations itself. +- `beginOptimisticThread(id, title, cwd)` inserts one locally animated draft + row using the supplied stable provisional ID without changing canonical + graph authority. +- `promoteOptimisticThread(draftId, authoritativeId)` changes the row's action + identity in place and preserves its selection/animation/position. +- `confirmOptimisticThread(threadId)` removes only the matching optimistic + overlay once a canonical row represents it. +- `failOptimisticThread(threadId)` retains the row and changes its local + failure presentation so recovery/navigation remains possible. +- `isOptimisticThread(threadId)` is a side-effect-free membership query used + only by shell correlation logic. +- `setSortCriterion(criterion)` changes the local ordering rule, persists it, + and reconciles the current snapshot once. +- `currentSortCriterion()` returns that local rule without triggering work. +- `visiblySelectedThreadId()` returns the ID of the row the user currently + sees as selected. Outbound prompt routing must use this value, not a stale + shell selection. +- `Actions::select/reload/rename/fork/toggleArchive/remove` carry exactly the + pointed row ID. `Actions::newThread/refresh/hide` carry no inferred target. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | optional QWidget `parent` | Constructs one empty pane; restores only local settings. Performs no callback. | +| `setActions` | replacement `Actions` value | Post: later gestures use only this bundle. Does not replay a gesture. | +| `refresh` | complete `ThreadListSnapshot` by const reference | Snapshot remains valid for the call only. Post: rendered hierarchy/selection equals its effective value plus local expansion/sort/optimistic rows. Identical effective input performs no row work. | +| `beginOptimisticThread` | provisional `id`, display `title`, `cwd` | `id` must be nonempty and process-locally unique. Duplicate begin updates no canonical graph state. | +| `promoteOptimisticThread` | exact `draftId`, exact `authoritativeId` | If draft is absent, no-op. Post: callbacks and visible selection use authoritative ID without replacing unrelated rows. | +| `confirmOptimisticThread` | current provisional/promoted `threadId` | Removes only the matching overlay; canonical row remains. | +| `failOptimisticThread` | exact optimistic ID | Marks only that overlay failed and keeps it recoverable/selectable as defined by UI behavior. | +| `isOptimisticThread` | ID; returns bool | Pure local query. | +| `setSortCriterion` | enum value | Reorders roots atomically using local snapshot and persists choice. Child order/hierarchy is retained. | +| `currentSortCriterion` | no parameters; returns enum | Pure local query. | +| `visiblySelectedThreadId` | no parameters; returns canonical/provisional string | Empty when no visible row is selected. This is the outbound routing source of truth. | + +### `middle::ConversationView` + +`ConversationView` is the sole owner of conversation QWidgets and geometry. +It retains per-thread follow/pause anchors, collapsed-card state, nested +command-output scroll state, stable card widgets for the current retained +window, and presentation options. + +Thread/ownership contract: Qt-main only. The view owns all cards and Turn +sections through QObject parentage. Snapshot `NodeRef` action tokens may pin +node lifetime but are opaque; the view never dereferences them. Reconciliation +may synchronously emit only local Qt signals; graph/action callbacks run after +the widget transaction and after every graph guard has been released. + +- `ConversationView(parent)` creates the established scroll surface, Load + More control, empty label, and content layout. +- `setLoadMoreAction(callback)` installs the one user gesture for expanding + history. The callback decides retained-graph versus provider loading. +- `setPromptMaterializedAction(callback)` is a narrow additive integration + hook. After a local card has successfully morphed to its authoritative user + card and after all QWidget work, it returns the exact prompt `NodeRef` for + worker acknowledgement. `false` stops further acknowledgements in that + pass; the widget never retries automatically. +- `setPromptRecoveryAction(callback)` is a narrow additive hook fired only by + explicit recovery on the exact failed local-prompt token. +- `setEmptyMessage(message)` changes empty text only, preserving anchor and + follow behavior. It must not clear cards. +- `setPresentationOptions(options)` updates reasoning/update visibility and + initial folding preferences using the already retained snapshot. Existing + card-local fold choices remain authoritative. +- `presentationOptions()` returns the current local preferences without work. +- `reconcile(snapshot)` is the single structural/render entry point. It + returns `false` and performs zero presentation work for an identical + snapshot. Otherwise it validates the full target order, suppresses exposure + during the existing synchronous commit, reuses compatible keyed widgets, + establishes every Turn/You parent, restores the anchor, and exposes one + final state before returning `true`. +- `reconcileStaged(snapshot)` preserves that same observable contract while + allowing multi-card selection and Load 80 construction to yield under the + hidden staging owner. A strict one-card tail append bypasses staging: the + new card is settled off-hierarchy, then its cached card, nested-Turn, + section, and content height deltas are committed without traversing or + remeasuring retained cards. Reorder, removal, non-tail insertion, and any + coalesced retained-card geometry change continue through full validated + reconciliation. +- `setTrailingSpaceHeight(height)` represents only the composer's overlay + growth below conversation content and preserves current scroll semantics. +- `prepareForLocalPromptAdmission()` resumes following only when pause was + caused solely by composer growth. Explicit user scrolling stays paused. +- `forwardWheelEvent(event)` lets the middle region route a wheel/touchpad + gesture to the canonical conversation scroll owner after nested controls + decline it. +- `mode()`, `modeForThread(id)`, and `isAtBottom()` are side-effect-free + scroll-state queries used to calculate the history window and action UX. +- `dispatchingNativeWheel()` prevents recursive event-filter forwarding. +- `trailingSpaceHeight()` reports the current overlay compensation. +- `PresentationOptions` has four independent local values: + `showReasoning`, `showCodexUpdates`, `commandsInitiallyExpanded`, and + `imagesInitiallyExpanded`. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | optional QWidget `parent` | Produces an empty following-mode view with one content owner. No cards exist. | +| `setLoadMoreAction` | replacement `void()` callback | Called once per accepted button gesture; view neither changes history count nor calls provider itself. | +| `setPromptMaterializedAction` | replacement `bool(NodeRef)` callback | Called after a successful local-to-authoritative visual transition. Exact token is moved to callback. False aborts only the remaining callbacks in this reconcile. | +| `setPromptRecoveryAction` | replacement `void(NodeRef)` callback | Called only from explicit recovery gesture on the current matching card. | +| `setEmptyMessage` | display `QString` value | Changes only empty-label text; current cards and `snapshot_` remain. Anchor is preserved. | +| `setPresentationOptions` | complete local options | Reconciles retained `snapshot_` with force=true; no graph query. Existing user fold choices win over initial-fold defaults. | +| `presentationOptions` | returns value copy | Pure query. | +| `reconcile` | complete snapshot const reference; returns changed bool | Pre: unique section/card stable keys and correct root keys. Post: complete target exposed atomically, cards parented, scroll policy applied, snapshot retained. False guarantees no presentation pass for identical input. | +| `reconcileStaged` | owned complete snapshot | Same final-state contract as `reconcile`; multi-card construction remains hidden and sliced. A single append may commit from cached geometry only when it is the last card of the last retained Turn, or the one root of a new last Turn, and no retained card also changed geometry. | +| `setTrailingSpaceHeight` | nonnegative effective pixels | Post: content extent/anchor reflects composer overlay without changing viewport ownership. Repeated value is a no-op. | +| `prepareForLocalPromptAdmission` | no parameters | May change pause caused only by composer growth; never overrides explicit user pause. | +| `forwardWheelEvent` | live `QWheelEvent*`; returns consumed bool | Event is not owned. Nested eligible control must have declined it. | +| `mode`, `modeForThread`, `isAtBottom`, `dispatchingNativeWheel`, `trailingSpaceHeight` | pure queries | No layout, paint, callback, or scroll mutation. | + +### `middle::ConversationCard` + +Cards remain the established specialized renderers. They do not read the +graph. Their `VisibleCardData` is the entire canonical presentation input. + +- `data()` returns the last applied DTO for identity/action comparison. +- `canApply(data)` reports whether the existing concrete card can represent a + new DTO. It permits the intentional local-prompt to user-message handoff. +- `apply(data)` preserves the old boolean contract: `true` means visible + presentation changed. +- `applyPresentation(data)` additionally classifies the local effect as + `None`, `PaintOnly`, or `GeometryChanged`; it does not propagate a global + invalidation. +- `isCollapsed()` and `setCollapsed(value)` read/write user-owned fold state. +- `setAuthoritativeTurnActive(value)` changes only the owner card's canonical + active emphasis and returns whether paint state changed. +- `setNestedCards(cards)` establishes the owning You card as QObject/layout + parent for all represented child cards in canonical order. +- `setNestedPresentation(value)` applies the established nested visual style + when a card is not itself the Turn owner. +- `setNestedItems(items)` is the generalized form used by the existing nested + layout; it does not confer application ownership. +- `setViewportVisible(value)` pauses purely local visual feedback when a card + cannot paint; it never changes canonical status. +- `commandOutputScrollState()` and `restoreCommandOutputScrollState(state)` + preserve the user's inner-output pause/follow position across a necessary + compatible card reconstruction. +- `foldRequested` reports a local fold gesture. `recoveryRequested` reports + explicit recovery; neither signal performs graph work directly. + +`ContentSizedTextView::setContent` and `CommandOutputView::setOutput` return +whether effective content/geometry changed. `CommandOutputView` alone owns its +inner wheel/follow state; restoring it must not move the outer conversation. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| `data` | returns const DTO reference | Reference is valid until next successful apply or destruction; caller must not retain it across reconciliation. | +| `canApply` | candidate DTO; returns bool | Pure compatibility check; no QWidget mutation. | +| `apply` | complete candidate DTO; returns visible-change bool | Requires `canApply`; post: `data()` equals candidate and specialized controls show its values. | +| `applyPresentation` | complete candidate DTO; returns impact enum | Same postcondition as `apply`; impact is local and must not be promoted blindly to pane/window invalidation. | +| collapse methods | bool setter / bool query | Fold state is user-owned and geometry changes remain inside owning Turn section. | +| `setAuthoritativeTurnActive` | bool; returns paint-change bool | Valid primarily for the root You card. No geometry change for border-only state. | +| nested-parent methods | ordered child QWidget/card pointers | Pointers must be live Qt-main objects. Post: correct QObject/layout parent and canonical order; no child is temporarily unmanaged when transaction becomes visible. | +| viewport visibility | bool | Affects only local timers/painting, not data or identity. | +| command output state methods | optional state / state const reference | Preserve inner scrollbar value/follow mode without modifying outer anchor. | + +### `middle::ComposerPane` + +`ComposerPane` owns prompt text, attachments, focus, submission keyboard +rules, attention controls, adaptive layout, and the Send/Steer/Stop surfaces. + +- `ComposerPane(anchor)` creates the bottom-aligned overlay relative to the + supplied center anchor. +- `setActions(Actions)` replaces the user-intention callbacks. +- `setExtraOverlayHeightAction(callback)` reports only the height above the + canonical reserve so ConversationView can compensate without resizing its + viewport. +- `setAttachments(values)` replaces the user-owned attachment draft list; + `attachments()` returns it unchanged and in order. +- `setAttentionVisible(value)` shows/hides the current request surface without + resolving it. +- `setAttentionRequest(title, detail, directAccept, acceptLabel)` changes the + effective request presentation and synchronizes geometry only when needed. +- `setAttentionEnabled(value)` retains the old contract: all visible request + actions share the same enablement. +- `setAttentionActionEnabled(providerEnabled, reviewEnabled)` is the narrow + additive form that can keep explicit recovery Review available while direct + provider Accept/Reject is disabled. +- `setActiveTurn(value)` chooses the established Steer/Stop versus Send + presentation; it does not infer the target. +- `setCanSubmit(value)` supplies canonical eligibility. The final Send/Steer + button also requires non-whitespace editor content. +- `setSettingsEnabled(value)` changes only settings control eligibility. +- `clearDraft()` clears prompt and attachments exactly once after successful + admission or an explicit new-thread reset. +- `synchronizeGeometry()` performs the old local layout transaction and emits + overlay-height change only when the effective height changed. +- `canonicalReserve()`, `canonicalReserveHeight()`, `extraOverlayHeight()`, + `promptEditor()`, and `turnSettings()` expose established child surfaces to + the middle/shell coordinators without transferring ownership. +- `Actions::submit` returns `true` only for guaranteed admission. `stop`, + `attach`, `accept`, `review`, and `deny` are one-shot void intentions. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | non-null geometry `anchor` | Anchor outlives pane. Constructs reserve/overlay/editor/settings surfaces. | +| `setActions` | replacement callback value | Does not submit or clear current draft. | +| `setExtraOverlayHeightAction` | replacement `void(int)` | Callback receives effective extra pixels only after a change and outside graph access. | +| attachment methods | ordered vector value / const reference query | Setter replaces only attachment UI state. Query reference is valid until next setter/destruction. | +| attention methods | visible flag, display values, action flags | Patch effective controls only. No method resolves a request. `reviewEnabled` affects Review only. | +| `setActiveTurn` | bool | Changes button labels/visibility and style only on value change. | +| `setCanSubmit` | canonical eligibility bool | Effective button additionally depends on trimmed editor content; setter never clears it. | +| `setSettingsEnabled` | bool | Delegates eligibility without resetting touched values. | +| `clearDraft` | no parameters | Clears editor and attachments and resynchronizes geometry. Caller may invoke only after admitted send or explicit draft reset. | +| `synchronizeGeometry` | no parameters | Idempotently computes canonical/extra height and positions overlay. Guards reentrant layout requests. | +| child/height accessors | no parameters; borrowed pointer/value | Pure; ownership remains with pane. | + +### `codex::TurnSettingsWidget` + +This widget owns locally touched settings fields and their menus/controls. + +- `setContext(identity, canonical, models, permissionProfiles, revision, + update)` retains the complete legacy entry point and reconciles all three + canonical sources. +- `setCanonicalContext(identity, canonical, revision, update)` is an additive + narrow update for selected-thread settings only. +- `setModelCatalog(models)` and + `setPermissionProfileCatalog(permissionProfiles)` are additive independent + catalog updates; identical catalog revisions do no control rebuilding. +- `setControlsEnabled(value)` applies effective edit eligibility. +- `setWorkspace(path)` is an explicit user/local-draft change. +- `workspace(fallback)` returns the selected cwd or fallback. +- `threadStartOptions()` returns only options valid for thread creation. +- `turnStartOptions()` returns only options valid for a new turn. Neither + accessor performs provider work or mutates controls. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | optional QWidget parent | Builds controls with default/local values, no provider call. | +| `setContext` | identity, canonical JSON, model JSON, permission JSON, revision, sparse update | Complete compatibility entry point. Applies only untouched canonical fields and refreshes changed catalogs. | +| `setCanonicalContext` | identity, canonical JSON, revision, sparse update | Does not alter cached catalog values. Identity change establishes a new touched-field scope. | +| catalog setters | JSON catalog value | Preserve canonical settings and touched controls; identical value is a no-op. | +| `setControlsEnabled` | bool | Changes interactivity only, not values. | +| `setWorkspace` | QString path | Explicitly changes the local workspace field and marks it touched. | +| `workspace` | UTF-8 fallback; returns UTF-8 path | Pure value extraction with fallback for blank current value. | +| option accessors | no parameters; return owned JSON value | Pure serialization of current effective controls into the correct protocol scope. | + +### `middle::InspectorPane` + +The pane owns tabs, per-tab comparison state, Agents expansion, State/Protocol +scroll, bounded protocol lines, and the pre-existing asynchronous Changes +viewer. Its DTO is current presentation input, never authority. + +- `InspectorPane(parent)` constructs the established Plan, Agents, Changes, + Requests, State, and Protocol surfaces. +- `setHideAction(callback)` installs the one pane-hide gesture. +- `setRequestActions(review, accept, reject)` installs exact interaction-ID + callbacks. Dialog validation remains inside the existing request workflow. +- `refresh(snapshot)` retains the newest complete DTO and refreshes only the + visible tab when the pane is visible. Hidden tabs do no QWidget work and + render the latest value once when activated. +- `appendProtocolFrame(frame)` preserves the legacy diagnostic entry point but + extracts only safe metadata and delegates to the bounded diagnostic path. +- `appendProtocolDiagnostic(effect)` appends one already-redacted metadata + record, preserves tail/paused scroll, detects sequence gaps, and never stores + raw payloads or changes application authority. +- `tabs()` exposes the established tab widget for Request navigation and saved + user selection. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | optional QWidget parent | Builds all established tab shells but does not populate history rows. | +| `setHideAction` | replacement callback | Called once by Hide gesture. | +| `setRequestActions` | three exact-ID callbacks | Missing callback disables/no-ops that gesture; pane never guesses a target. | +| `refresh` | complete Inspector snapshot const reference | Retains newest value. If visible, refreshes only active tab; if hidden, performs no QWidget projection. Repeated per-tab value is a no-op. | +| `appendProtocolFrame` | legacy safe JSON diagnostic | Input valid for call only. Extracts bounded metadata, discards raw payload, then invokes diagnostic append semantics. | +| `appendProtocolDiagnostic` | typed `UiEffect` const reference | Non-diagnostic kind is ignored. Diagnostic values are bounded/redacted; visible Protocol appends only new lines, hidden Protocol updates retained bounded text only. | +| `tabs` | returns borrowed QTabWidget pointer | Pure; pane retains ownership. | + +### `middle::MiddleRegionWidget` + +This class remains the sole geometry and cross-pane event owner. + +- `threads()`, `conversation()`, `composer()`, and `inspector()` return the + existing owned pane instances; callers must not replace them. +- `splitterWidget()` exposes the established splitter for saved sizing/tests. +- `setThreadHeading(title, metadata, trailingMetadata, state, tone)` patches + only changed heading fields and style tone. +- `showNotice(message, error)` presents the existing non-layout-shifting timed + notice overlay. +- `showSidebar(value)` and `showInspector(value)` preserve splitter geometry + and user visibility choice; `sidebarVisible()` and `inspectorVisible()` are + side-effect-free queries. +- `setPaneVisibilityAction(callback)` reports user-visible pane state so shell + restore controls can mirror it. +- `routeScrollEvent(watched, event)` preserves nested-scroll precedence and + returns `true` only when the conversation consumed the gesture. + +| Method | Parameters / return | Preconditions and observable effect | +| --- | --- | --- | +| constructor | optional parent | Constructs exactly one ThreadPane, conversation region, ComposerPane, and InspectorPane in the established splitter. | +| pane accessors | no parameters; borrowed references | Pure; lifetime is the middle region's. | +| splitter accessor | no parameters; borrowed pointer | Pure; caller may inspect/persist sizes but not replace ownership. | +| `setThreadHeading` | five display values | Repeated effective tuple is a no-op. Tone changes repolish only the state label. | +| `showNotice` | message value, error flag | Empty/updated notice uses existing overlay and timer; does not change center layout allocation. | +| pane visibility methods | bool setters / bool getters | Preserve splitter sizes and report effective visibility once through callback. | +| `setPaneVisibilityAction` | replacement callback | Does not emit until a visibility transition. | +| `routeScrollEvent` | watched QObject and live QEvent; returns bool | Does not take ownership. Routes only supported wheel gestures and prevents recursion. | + +### `ShellWidget` + +`ShellWidget` is the coordinator, not a renderer or model. Its constructor +wires the existing callbacks to typed actions, QSocketNotifier delivery, the +adapter, dialogs, and pane routing. Its event filter delegates wheel behavior +to `MiddleRegionWidget`. It must not parse app-server JSON, call CodexBridge, +or retain another domain model. UI-local history-window counters, current +selection, pending authored recovery, and last effective chrome values are +coordination state permitted by this contract. + +`ShellWidget(QWidget*)` requires a running `FrontendSession` owned by its +implementation and creates all visible child panes on Qt-main. Destruction +removes application event filters/notifiers before child teardown. Its +`eventFilter(QObject*, QEvent*)` returns the middle region's decision for +eligible wheel events and otherwise preserves Qt's normal dispatch. Graph +notifications are frame-coalesced only after worker reduction; removals are +handled synchronously. Shell never waits for graph access and never clears +user input merely because a wake write failed after queue admission. + +### DTO identity and value types + +- `ui::ThreadListSnapshot` / `ThreadListRow` are complete sidebar values. +- `ui::InspectorSnapshot` and its Plan, Agents, Changes, Requests, and State + children are complete current Inspector values. +- `middle::ConversationSnapshot` / `TurnSection` / `VisibleCardData` are the + complete retained conversation presentation. +- `AuthoritativeItemKey`, `TurnPlanKey`, and `LocalPromptKey` are stable visual + identities; their `stableKey` encoding is the QWidget reconciliation key. +- `CardPayload` is a closed variant of the established specialized card DTOs. + Unknown protocol alternatives use `GenericActivityData`, retaining safe + bounded visible detail but not a raw protocol authority. +- `AttachmentDraft` is user-owned local editor input until action admission. + NodeGraph `Attachment` values are moved copies owned by an admitted command. + +Every DTO equality operator is part of the no-op contract. Adding a field that +does not influence visible presentation must not force a widget refresh; such +fields belong in adapter control metadata instead. + +## Data contracts + +### Thread list + +`ui::ThreadListSnapshot` is a complete value for one refresh: + +- `selectedThreadId` is the canonical currently selected thread or empty; +- `providerReady` requires both a connected transport and provider state + `ready`; +- `canControl` additionally requires controller role; +- `roots` contains every reachable confirmed root in canonical relation order, + followed by any temporarily unreachable canonical thread so paging or a late + owner cannot make a selectable thread disappear. + +Each `ThreadListRow` carries canonical ID, display title fallback, cwd, status, +created/updated/recency values, effective last activity, pending count, +archive state, and ordered children. Effective last activity is the maximum of +provider activity, update/recency, and admitted local prompt activity. The +widget, not the adapter, owns sorting, expansion, optimistic animation, +selection visuals, context menus, and row QWidget identity. + +### Conversation + +`middle::ConversationSnapshot` is the complete currently retained activity +window for one thread. The adapter must never provide a prefix or suffix that +it knows belongs to an unfinished authoritative hydration. + +- `threadId` selects the per-thread scroll/follow state. +- `sections` are ordered Turns. Their stable key is the length-delimited + `turn:::` form used by the old + projection, preventing ambiguous concatenation. +- `rootCardKey` identifies the real opening You item even when it lies before + the retained 80-item suffix. That root is pinned into the snapshot without + consuming the activity budget. +- `hiddenAuthoritativeItemCount` excludes pinned roots and local optimistic + prompts. +- `hasMore` is true for retained hidden items or a provider continuation. +- `activeTurnId` is canonical active-turn identity. A locally admitted pending + new Turn is visually active until provider acknowledgement. + +All cards for the selected 80-item window (and each explicitly requested next +80) are created and laid out while updates are suppressed for the shortest +existing reconciliation transaction. They are then exposed in one final +frame. Cards are retained while they remain in the selected window; scrolling +offscreen does not destroy and recreate them. New incoming cards for the +selected thread are materialized in the same transaction even when the user is +paused above them, and anchor restoration prevents vertical or horizontal +movement. + +`VisibleCardData::key` is visual identity. Canonical items use thread/turn/item +identity; a prompt that began locally keeps its process-wide `LocalPromptKey` +through materialization. `target` is a narrow opaque action/lifetime token for +the integration callbacks. Existing widget code may retain or return it, but +must never read the graph through it. `activeWork` is a canonical presentation +fact for delayed-result cards; it drives the established emphasized border +without making the card infer lifecycle from display strings. + +### Composer and settings + +The composer owns draft text, attachment presentation, focus, keyboard rules, +and geometry. `Actions::submit` is called with a copy/move of current authored +input, and returns `true` only after exact queue admission. The composer clears +the draft only on `true`. + +Send is eligible only when the draft is nonblank, the bridge/provider is ready, +the client controls the provider, and the visible destination is a valid new +draft or hydrated thread. A canonical active Turn changes the action label to +Steer and sends `turn/steer`; otherwise it sends `turn/start`. Stop targets the +exact active Turn. No action reconstructs its target from prompt text or a +later selection. + +Settings values are canonical provider data plus local touched-field state. +Catalog updates are independent of selected-thread settings. Repeating the +same identity/revision/catalog performs no control reconstruction or style +work. + +### Inspector + +`ui::InspectorSnapshot` contains current values for Plan, Agents, Changes, +Requests, and State. It is not another model: the adapter constructs it from a +short current graph read and the pane retains only render-comparison values +and local expansion/scroll state. + +Request rows carry the exact canonical interaction ID, kind, safe display +facts, provider generation, and actionability. Review/Accept/Reject resolve +that exact live interaction. Agents are a UI-only grouping of canonical +protocol items; grouping never removes or merges graph nodes. Changes retains +the established asynchronous local Git provider. Protocol diagnostics use a +separate bounded metadata append because chronological diagnostics cannot be +derived from current graph state; they never become application authority. + +## Temporal usage recipes + +### Select and hydrate a thread + +1. The ThreadPane callback supplies the visible canonical ID. +2. Shell resolves it to one current `NodeRef` and records the selection. +3. ThreadPane selection/chrome may update immediately. +4. If the selected graph thread is not display-ready, issue one Hydrate action. + Do not project delta-created provider fragments. +5. If a populated conversation is already displayed, keep that surface stable + while the new thread hydrates. If no conversation is displayed, a stable + loading empty state is allowed. +6. Once hydration has usable authoritative IDs/types, obtain one complete + snapshot and call `ConversationView::reconcile` once. Refresh the selected + Inspector state from the same ready boundary. + +### Receive a graph change + +1. Detach removals synchronously. +2. Route only identities relevant to ThreadPane, selected conversation, + visible Inspector behavior, and effective chrome. +3. Union streaming identities for one display frame. +4. Project the latest current DTO for each affected surface. +5. Let the old widget compare stable identities and values. Repeating the same + DTO must perform zero presentation work. + +### Load 80 more activities + +1. Increase the selected thread's requested and effective window by 80. +2. If retained graph history satisfies it, project immediately without a + provider call. +3. Otherwise send one exact `LoadHistory` action only when the provider reports + more history. +4. Preserve the old anchor while the expanded complete snapshot is reconciled; + never expose reserved empty space followed by delayed cards. + +### Admit and acknowledge a prompt + +1. Capture the visibly selected exact thread and active Turn before admission. +2. Attempt one typed action. On rejection return `false` and retain the draft. +3. On admission prepare the old view's local-prompt anchor behavior and clear + the draft once. +4. Render the pending normal or steering You card under its canonical owner, + with pending status and delayed feedback animation. +5. Unrelated items may arrive without changing that ownership or anchor. +6. When the authoritative user item arrives, keep the visual key/widget and + send one prompt-materialized acknowledgement for the exact prompt node. +7. Stop pending feedback on acceptance/failure; never dual-send or infer + acknowledgement from matching text. + +## Compatibility evidence + +Focused adapter, established-widget, conversation-card, shell-integration, +protocol-updater, and worker-logic tests currently pass. A real CodexUI build +connected to the officially running bridge was recorded on Xvfb with the long +`CodexUI - Minimal architecture` thread. The pre-fix sequence exposed partial +provider fragments and a loading/empty text oscillation. The corrected cold +sequence holds one stable loading surface and changes directly to one complete +parented mixed-card viewport; active command and response updates then mutate +the completed surface in place. + +Populated-thread switch staging now preserves the outgoing conversation, +heading, and Inspector until the incoming complete snapshot is ready; the +focused temporal regression passes. A fresh full configure/build and all 17 +native tests pass, as do the independently configured 5-test nodegraph suite, +the 83-test WebUI suite, and the six affected suites under ASan/UBSan. + +The final full-application movie uses the rebuilt Debug binary, an isolated +Xvfb display, and the officially running bridge. It records cold selection of +the long `CodexUI - Minimal architecture` thread, a switch to a second long +thread, and incoming activity while scrolled above the bottom. The cold load +exposes one complete parented layout, the thread switch exposes no partial +replacement, and the paused viewport keeps identical card positions while the +new content is materialized below it. Automated black-frame analysis of the +conversation region found no blank interval. This evidence verifies the +contract; it does not weaken or redefine it. + +A second 30-fps full-application recording validates authored input against +the isolated workspace bridge using the small `GPT-5.6-Sol` model and Low +reasoning. It records a normal prompt from editable draft through admission, +optimistic/authoritative You-card materialization, active Turn state, running +command output, and final response. During that active Turn it records a +steering draft, enabled Steer action, admission, one steering You card under +the same Turn, draft clearing only after admission, and exactly one final +`STEERING ACKNOWLEDGED` response. The active Turn and delayed command retain +their emphasized borders, Stop/Send/Steer eligibility follows canonical state, +and no empty or unparented intermediate conversation frame appears. The proof +artifact is `prompt-and-steering-proof.mp4` in the qualification capture +directory. The official bridge was also tried first, but correctly withheld +controller authority from the second UI; no prompt was sent through that +uncontrolled connection. + +No remote or GitHub operation is used to maintain this document. + +### Final smoothness qualification (2026-09-05) + +The final correction was exercised through the complete Debug application on +Xvfb `:98`, connected to the workspace-isolated bridge and app-server that +remained alive across the scenarios. The retained proof artifacts are under +`../../build/codexui-adapter-qualification/capture/final-smoothness/`: + +- `atomic-thread-selection.mp4` switches from a populated control thread to a + longer mixed thread. The old surface remains complete, a single stable + loading cover is shown while rich cards are staged, and the incoming thread + appears in one committed frame. No card-by-card reveal or reserved blank + extent is exposed. +- `sustained-command-streaming.mp4` records normal prompt admission and an + 1,800-line command with 10 ms output intervals through completion. The exact + command card updates in place with its running border. Of 1,680 captured + frames, 1,204 contain conversation-region motion. Interior pixel-difference + analysis found no visible motion in ThreadPane, Inspector, or shell chrome; + their maximum mean luminance deltas were respectively 0.021, 0.005, and + 0.043. +- `steering-while-scrolled-up.mp4` records a second 1,800-line command, pauses + the outer conversation above the active tail, and admits steering. The + steering You card remains under the same Turn and resolves with the final + answer below the viewport. Frames sampled before and after steering have the + same visible card positions and horizontal coordinates; the full-region + normalized pixel difference is approximately `1.0e-5`, attributable to + capture encoding rather than displacement. + +The deterministic 81-card staging test additionally verifies repeated event +loop heartbeats during hidden construction, no visible partial card tree, a +live delta applied without restarting the stage, one final reveal, and an +unchanged old surface during Load 80. Three consecutive normal offscreen runs +measured the indivisible final geometry commit at 81, 82, and 82 ms, below the +existing 100 ms selection/load boundary. This bounded delay applies only to an +explicit thread selection or Load 80 operation; ordinary card deltas use the +exact retained-card path and do not traverse the loaded history. + +The later append/completion correction is qualified separately under +`../../build/codexui-adapter-qualification/capture/scroll-lag-live/`. +`final-two-pass-all-card-live.mp4` records the complete application at 60 fps +with all four presentation controls checked. It repeatedly sweeps the outer +conversation viewport across the Turn/You card, reasoning/update content, +Agent activity, expanded command output, and final cards while a new command +arrives, streams, and completes. The exact prompt/command/completion interval +starts 7.8 seconds into the movie; conversation-crop freeze detection finds no +static interval of 50 ms or longer during the following 24 seconds. + +One arriving card is now constructed under the hidden staging owner, yields +to the Qt event loop, and only then commits its cached geometry. The focused +80-card Debug benchmark measures card construction phases at approximately +0.6--2.8 ms and cached commits at approximately 2.7--6.4 ms for the ordinary +card kinds exercised by the live turn. The File Changes card's first local +style/layout settlement remains a separate approximately 10--15 ms commit; +it performs no retained-history work. The running-to-completed regression +verifies unchanged card height, scroll range, and paused anchor with zero +conversation geometry passes, including a command first inserted through the +cached append path. The recording cannot exclude a shorter single-frame hitch, +and the user still perceives one occasionally; this residual observation is +retained rather than reported as proven zero-lag behavior. + +The final Debug suite passes 17/17 native tests and the WebUI compatibility +suite passes 83/83. ASan/UBSan executes every suite without a sanitizer +diagnostic; 16/17 pass their functional criteria, while the shell suite's same +strict 100 ms wall-clock assertion measures 163 ms offscreen and 189 ms on +Xvfb under sanitizer instrumentation. The unsanitized criterion and real-app +movie pass; the sanitizer-only timing overrun is not used to relax the product +limit. diff --git a/docs/web-1.0-contract.md b/docs/web-1.0-contract.md index df8805c..5f76fd9 100644 --- a/docs/web-1.0-contract.md +++ b/docs/web-1.0-contract.md @@ -107,11 +107,11 @@ thread read or user-message event can materialize a local prompt alias. Inspector diagnostics likewise project full retained item state only while the State tab is selected. -## Presentation boundary +## Browser presentation boundary -The semantic vocabulary and authority rules of `codexui.presentation` version -1 remain the shared native/web contract. In the browser they form an internal -TypeScript boundary rather than an additional network hop: +`codexui.presentation` version 1 remains an internal TypeScript boundary in +the browser implementation. It is not a network hop, native runtime contract, +or data structure shared with the native application: ```text bridge/app-server input @@ -122,10 +122,12 @@ bridge/app-server input -> React ``` -The TypeScript implementation must match the existing rules for stable IDs, -merge/replace/remove authority, generation retirement, unknown events, -incomplete reconstruction, child-thread ownership, and ordered items. Shared -JSON fixtures verify equivalent C++ and TypeScript normalization/reduction. +The TypeScript implementation must preserve the browser's existing rules for +stable IDs, merge/replace/remove authority, generation retirement, unknown +events, incomplete reconstruction, child-thread ownership, and ordered items. +Browser tests exercise that pipeline. Native tests independently exercise the +native application's single shared `NodeGraph`; parity is measured at +observable behavior rather than a shared normalizer or presentation frame. Equality is judged by observable behavior and state transitions, not source structure or pixel identity. For the same ordered inputs, native and web must diff --git a/src/codex/ClientRuntime.cpp b/src/codex/ClientRuntime.cpp index 86124f4..c925de9 100644 --- a/src/codex/ClientRuntime.cpp +++ b/src/codex/ClientRuntime.cpp @@ -3,9 +3,11 @@ #include "codex/ClientRuntime.h" #include "codex/Configuration.h" -#include "codex/PresentationProtocol.h" -#include "codex/ProtocolNormalizer.h" -#include "codex/ipc/SNodeSocketPairEndpoint.h" +#include "codex/CurrentProtocolAdapters.h" +#include "codex/NodeGraphJson.h" +#include "codex/WorkerMailboxReceiver.h" +#include "codex/nodegraph/PromptText.h" +#include "codex/nodegraph/WorkerLogic.h" #include #include @@ -13,7 +15,6 @@ #if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) #include #endif -#include #include #include #include @@ -37,13 +38,23 @@ #include #include -#include +#include #include #include +#include +#include #include +#include #include +#include +#include #include +#include +#include +#include +#include #include +#include namespace codexui::codex { namespace { @@ -51,7 +62,739 @@ namespace { namespace codex = ai::openai::codex; namespace client = ai::openai::codex::frontend::client; -constexpr std::size_t MaximumIpcReadBytesPerEvent = 256U * 1024U; +nodegraph::Value::Object decodedObject(const nlohmann::json &value) { + if (value.is_object()) + return objectFromJson(value); + return {{"value", valueFromJson(value)}}; +} + +std::string jsonString(const nlohmann::json &object, std::string_view key) { + if (!object.is_object()) + return {}; + const auto found = object.find(std::string(key)); + return found != object.end() && found->is_string() ? found->get() + : std::string{}; +} + +std::string resultError(const nlohmann::json &raw) { + if (!raw.is_object()) + return "Codex operation failed"; + const auto error = raw.find("error"); + if (error != raw.end() && error->is_object()) { + const std::string message = jsonString(*error, "message"); + if (!message.empty()) + return message; + } + const std::string message = jsonString(raw, "message"); + return message.empty() ? "Codex operation failed" : message; +} + +std::optional threadActivityAt(std::string_view method) noexcept { + if (method == "thread/read" || method == "thread/resume") + return std::nullopt; + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +std::int64_t wallClockMilliseconds() noexcept { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +nlohmann::json jsonObject(nodegraph::Value::Object object) { + return jsonFromValue(nodegraph::Value(std::move(object))); +} + +const nodegraph::Value *valueMember(const nodegraph::Value::Object &object, + std::string_view key) { + const auto found = object.find(key); + return found == object.end() ? nullptr : &found->second; +} + +std::string valueString(const nodegraph::Value::Object &object, + std::string_view key) { + const nodegraph::Value *value = valueMember(object, key); + const std::string *string = value ? value->asString() : nullptr; + return string ? *string : std::string{}; +} + +std::optional +valueUnsigned(const nodegraph::Value::Object &object, std::string_view key) { + const nodegraph::Value *value = valueMember(object, key); + if (const std::uint64_t *number = value ? value->asUInt64() : nullptr) + return *number; + if (const std::int64_t *number = value ? value->asInt64() : nullptr; + number && *number >= 0) + return static_cast(*number); + return std::nullopt; +} + +std::optional +requestIdMember(const nlohmann::json &object, std::string_view key) { + if (!object.is_object()) + return std::nullopt; + const auto found = object.find(std::string(key)); + if (found == object.end() || found->is_null()) + return std::nullopt; + try { + return requestIdFromJson(*found); + } catch (...) { + return std::nullopt; + } +} + +std::string boundedProtocolMetadata(std::string result, + std::size_t maximumBytes = 160) { + for (char &character : result) { + const unsigned char byte = static_cast(character); + if (byte < 0x20U || byte == 0x7fU) + character = ' '; + } + if (result.size() > maximumBytes) { + result.resize(maximumBytes); + result += "..."; + } + return result; +} + +std::string boundedProtocolMetadata(const nlohmann::json &value, + std::size_t maximumBytes = 160) { + if (value.is_string()) + return boundedProtocolMetadata(value.get(), maximumBytes); + if (value.is_number_unsigned()) + return boundedProtocolMetadata(std::to_string(value.get()), + maximumBytes); + if (value.is_number_integer()) + return boundedProtocolMetadata(std::to_string(value.get()), + maximumBytes); + return {}; +} + +bool credentialShapedProtocolText(std::string_view value) { + std::string lowered; + lowered.reserve(value.size()); + for (const unsigned char character : value) + lowered.push_back(static_cast(std::tolower(character))); + constexpr std::array markers{ + std::string_view("authorization"), std::string_view("bearer "), + std::string_view("password"), std::string_view("secret"), + std::string_view("token="), std::string_view("token:"), + std::string_view("cookie"), std::string_view("credential"), + std::string_view("api_key"), std::string_view("apikey"), + std::string_view("-----begin"), std::string_view("github_pat_"), + std::string_view("ghp_"), std::string_view("xoxb-"), + std::string_view("xoxp-"), std::string_view("xoxa-")}; + if (std::ranges::any_of(markers, [&lowered](std::string_view marker) { + return lowered.find(marker) != std::string::npos; + })) + return true; + if (lowered.find("sk-") != std::string::npos) + return true; + const std::size_t jwt = value.find("eyJ"); + if (jwt != std::string_view::npos) { + const std::size_t firstDot = value.find('.', jwt); + if (firstDot != std::string_view::npos && + value.find('.', firstDot + 1) != std::string_view::npos) + return true; + } + return false; +} + +std::string protocolIdentifierMetadata(const nlohmann::json &value) { + std::string result = boundedProtocolMetadata(value); + return credentialShapedProtocolText(result) ? "" + : std::move(result); +} + +std::string safeProtocolErrorText(std::string_view raw) { + if (credentialShapedProtocolText(raw) || raw.find('/') != std::string::npos || + raw.find('\\') != std::string::npos || + raw.find('`') != std::string::npos || raw.find('$') != std::string::npos) + return "[redacted error detail]"; + return boundedProtocolMetadata(std::string(raw), 240); +} + +std::string safeProtocolError(const nlohmann::json &message) { + const auto error = message.find("error"); + if (error == message.end() || !error->is_object()) + return {}; + const auto detail = error->find("message"); + if (detail == error->end() || !detail->is_string()) + return {}; + return safeProtocolErrorText(detail->get_ref()); +} + +struct DiagnosticRequestId final { + std::string key; + std::string display; +}; + +std::optional +diagnosticRequestId(const nlohmann::json &message) { + if (!message.is_object()) + return std::nullopt; + const auto found = message.find("id"); + if (found == message.end() || found->is_null()) + return std::nullopt; + if (found->is_string()) { + const std::string &value = found->get_ref(); + if (value.size() <= 160 && !credentialShapedProtocolText(value)) + return DiagnosticRequestId{"string:" + value, value}; + // Keep the chronology visible without retaining a secret or allowing a + // lossy digest collision to associate a response with the wrong request. + return DiagnosticRequestId{{}, ""}; + } + if (found->is_number_unsigned()) { + const std::string value = std::to_string(found->get()); + return DiagnosticRequestId{"unsigned:" + value, value}; + } + if (found->is_number_integer()) { + const std::string value = std::to_string(found->get()); + return DiagnosticRequestId{"signed:" + value, value}; + } + return std::nullopt; +} + +std::string protocolErrorCode(const nlohmann::json &message) { + const auto error = message.find("error"); + if (error == message.end() || !error->is_object()) + return {}; + const auto code = error->find("code"); + return code == error->end() ? std::string{} + : boundedProtocolMetadata(*code, 48); +} + +std::string_view nodeActionDiagnosticSubject(nodegraph::NodeActionKind kind) { + using enum nodegraph::NodeActionKind; + switch (kind) { + case Hydrate: + case Reload: + return "thread/read"; + case LoadHistory: + return "thread/turns/list"; + case Rename: + return "thread/name/set"; + case Fork: + return "thread/fork"; + case Archive: + return "thread/archive"; + case Unarchive: + return "thread/unarchive"; + case Delete: + return "thread/delete"; + case SubmitPrompt: + return "turn/start"; + case InterruptTurn: + return "turn/interrupt"; + case ResolveInteraction: + return "serverRequest/respond"; + case PromptMaterialized: + return "local/prompt/materialized"; + case UiDetached: + return "local/ui/detached"; + } + return "local/node-action"; +} + +std::string_view +runtimeActionDiagnosticSubject(nodegraph::RuntimeActionKind kind) { + using enum nodegraph::RuntimeActionKind; + switch (kind) { + case RefreshThreads: + return "thread/list"; + case CreateThread: + return "thread/start"; + case Connect: + return "connection/connect"; + case Disconnect: + return "connection/disconnect"; + case Reconnect: + return "connection/reconnect"; + case ConfigureConnection: + return "connection/configure"; + case ClaimController: + return "connection/controller/claim"; + case ReleaseController: + return "connection/controller/release"; + case RefreshCatalogs: + return "catalog/refresh"; + } + return "local/runtime-action"; +} + +std::string protocolMutationAuthority(std::string_view method, + bool fromAppServer, bool request, + bool notification, bool response, + bool success, + bool responseObservedInterveningFrame) { + if (!fromAppServer) { + // An outbound response resolves one retained reverse interaction. Outbound + // requests and initialized do not themselves publish provider facts. + return response ? "remove" : "none"; + } + if (request) + return "merge"; + if (!success) + return "none"; + if (notification) { + constexpr std::array removedNotifications{ + std::string_view("thread/deleted"), + std::string_view("serverRequest/resolved"), + std::string_view("thread/goal/cleared")}; + if (std::ranges::find(removedNotifications, method) != + removedNotifications.end()) + return "remove"; + const auto descriptor = nodegraph::findProtocolMethod( + nodegraph::ProtocolDirection::ServerNotification, method); + if (!descriptor || descriptor->get().disposition != + nodegraph::MessageDisposition::GraphUpdate) + return "none"; + // These messages invalidate or report transient provider facilities, but + // do not themselves author current graph facts. + if (method == "skills/changed" || + method == "mcpServer/event/stream/notification") + return "none"; + if (method == "thread/name/updated" || method == "thread/goal/updated" || + method == "thread/queue/changed" || + method == "thread/project/updated" || + method == "thread/tokenUsage/updated" || + method == "turn/diff/updated" || method == "turn/plan/updated" || + method == "item/fileChange/patchUpdated" || + method == "account/updated" || method == "account/rateLimits/updated" || + method == "app/list/updated" || + method == "remoteControl/status/changed" || + method == "turn/moderationMetadata" || + method == "model/safetyBuffering/updated" || + method == "thread/realtime/sdp") + return "replace"; + return "merge"; + } + if (!response) + return "none"; + if (method == "thread/read") + return responseObservedInterveningFrame ? "merge" : "replace"; + constexpr std::array mergedResults{ + std::string_view("thread/list"), std::string_view("thread/start"), + std::string_view("thread/resume"), std::string_view("thread/fork"), + std::string_view("turn/start")}; + if (std::ranges::find(mergedResults, method) != mergedResults.end()) + return "merge"; + constexpr std::array replacedResults{ + std::string_view("thread/turns/list"), + std::string_view("thread/items/list"), + std::string_view("thread/queue/list"), + std::string_view("thread/backgroundTerminals/list"), + std::string_view("thread/timeline/list"), + std::string_view("thread/realtime/listVoices"), + std::string_view("project/list"), + std::string_view("project/read"), + std::string_view("threadSection/list"), + std::string_view("skills/list"), + std::string_view("hooks/list"), + std::string_view("plugin/list"), + std::string_view("plugin/read"), + std::string_view("plugin/installed"), + std::string_view("app/read"), + std::string_view("app/list"), + std::string_view("app/installed"), + std::string_view("model/list"), + std::string_view("modelProvider/capabilities/read"), + std::string_view("experimentalFeature/list"), + std::string_view("permissionProfile/list"), + std::string_view("collaborationMode/list"), + std::string_view("mcpServerStatus/list"), + std::string_view("config/read"), + std::string_view("configRequirements/read"), + std::string_view("account/read"), + std::string_view("account/rateLimits/read"), + std::string_view("account/usage/read"), + std::string_view("account/workspaceMessages/read"), + std::string_view("windowsSandbox/readiness")}; + if (std::ranges::find(replacedResults, method) != replacedResults.end()) + return "replace"; + // The current protocol contains additional read/list/get families which do + // not need bespoke UI handling. Their successful results still replace the + // addressed current catalog or domain value, matching the original + // Inspector contract. + if (method.ends_with("/list") || method.ends_with("/read") || + method.ends_with("/get")) + return "replace"; + return "none"; +} + +class ProtocolDiagnosticEmitter final { +public: + void setGenerations(nodegraph::WorkerGenerations generations) { + if (generationsKnown_ && generations != generations_) { + clientRequests_.clear(); + serverRequests_.clear(); + clientRequestOrder_.clear(); + serverRequestOrder_.clear(); + } + generations_ = generations; + generationsKnown_ = true; + } + + void observeLifecycle(std::string_view state, std::string_view detail, + nodegraph::ThreadChannels &channels) { + nodegraph::Value::Object fields{ + {"direction", nodegraph::Value("transport event")}, + {"source", nodegraph::Value("CodexBridge")}, + {"authority", nodegraph::Value("none")}, + {"subject", nodegraph::Value("connection.lifecycle")}, + {"state", + nodegraph::Value(boundedProtocolMetadata(std::string(state)))}}; + if (state == "failure" || state == "disconnected") { + fields.emplace("outcome", nodegraph::Value("ERROR")); + if (!detail.empty()) + fields.emplace("error", + nodegraph::Value(safeProtocolErrorText(detail))); + } + deliver(std::move(fields), channels); + } + + void observeBridge(const nlohmann::json &message, + nodegraph::ThreadChannels &channels) { + const std::string kind = jsonString(message, "kind"); + std::string subject = "bridge.unknown"; + std::string authority = "none"; + if (kind == "bridge.connection") + subject = "connection.bridge"; + else if (kind == "bridge.controller") { + subject = "connection.controller"; + authority = "replace"; + } else if (kind == "bridge.provider") { + subject = "connection.provider"; + authority = "replace"; + } else if (kind == "bridge.diagnostic") { + subject = "bridge.diagnostic"; + } + nodegraph::Value::Object fields{ + {"direction", nodegraph::Value("bridge event")}, + {"source", nodegraph::Value("CodexBridge")}, + {"authority", nodegraph::Value(std::move(authority))}, + {"subject", nodegraph::Value(std::move(subject))}}; + for (std::string_view key : {"connectionId", "role", "state", "event"}) { + const auto found = message.find(std::string(key)); + if (found == message.end()) + continue; + std::string value = protocolIdentifierMetadata(*found); + if (!value.empty()) + fields.emplace(std::string(key), nodegraph::Value(std::move(value))); + } + if (kind == "bridge.diagnostic") { + fields.emplace("outcome", nodegraph::Value("ERROR")); + fields.emplace("errorCategory", nodegraph::Value("bridge")); + const std::string code = + protocolIdentifierMetadata(message.value("code", nlohmann::json{})); + if (!code.empty()) + fields.emplace("errorCode", nodegraph::Value(code)); + const std::string raw = jsonString(message, "message"); + if (!raw.empty()) + fields.emplace("error", nodegraph::Value(safeProtocolErrorText(raw))); + } + deliver(std::move(fields), channels); + } + + void observeLocalRejection(std::string_view subject, + std::string_view correlation, + const nodegraph::NodeRef &target, + std::string_view error, + nodegraph::ThreadChannels &channels) { + nodegraph::Value::Object fields{ + {"direction", nodegraph::Value("local result")}, + {"source", nodegraph::Value("CodexUI")}, + {"authority", nodegraph::Value("none")}, + {"subject", + nodegraph::Value(boundedProtocolMetadata(std::string(subject), 192))}, + {"outcome", nodegraph::Value("ERROR")}, + {"errorCategory", nodegraph::Value("local-validation")}, + {"error", nodegraph::Value(safeProtocolErrorText(error))}}; + std::string displayedCorrelation = boundedProtocolMetadata(std::string( + correlation.empty() && target ? std::string_view(target->id().canonical) + : correlation)); + if (credentialShapedProtocolText(displayedCorrelation)) + displayedCorrelation = ""; + if (!displayedCorrelation.empty()) + fields.emplace("correlation", + nodegraph::Value(std::move(displayedCorrelation))); + if (target) { + std::string targetId = boundedProtocolMetadata(target->id().canonical); + if (credentialShapedProtocolText(targetId)) + targetId = ""; + const std::string scope = targetId; + fields.emplace("targetId", nodegraph::Value(std::move(targetId))); + std::string_view scopeKey; + switch (target->id().kind) { + case nodegraph::NodeKind::Thread: + scopeKey = "threadId"; + break; + case nodegraph::NodeKind::Turn: + scopeKey = "turnId"; + break; + case nodegraph::NodeKind::Item: + scopeKey = "itemId"; + break; + case nodegraph::NodeKind::Interaction: + scopeKey = "requestId"; + break; + default: + break; + } + if (!scopeKey.empty()) + fields.emplace(std::string(scopeKey), nodegraph::Value(scope)); + } + deliver(std::move(fields), channels); + } + + void observe(codex::protocol::AppServerDirection direction, + const nlohmann::json &message, + nodegraph::ThreadChannels &channels) { + const nodegraph::WorkerGenerations generations = generations_; + const bool fromAppServer = + direction == codex::protocol::AppServerDirection::FromAppServer; + const std::optional wireMethod = + codex::protocol::jsonRpcMethod(message); + const std::optional requestId = + diagnosticRequestId(message); + const bool request = wireMethod && requestId; + const bool notification = wireMethod && !requestId; + const bool response = !wireMethod && requestId; + const bool success = message.find("error") == message.end(); + + nodegraph::Value::Object scopeDetails; + const nlohmann::json *scope = nullptr; + if (wireMethod) { + const auto parameters = message.find("params"); + if (parameters != message.end() && parameters->is_object()) + scope = &*parameters; + } else { + const auto result = message.find("result"); + if (result != message.end() && result->is_object()) + scope = &*result; + } + if (scope) + addScope(scopeDetails, *scope); + + std::string method = + boundedProtocolMetadata(wireMethod.value_or(std::string{}), 192); + const std::string correlationKey = + requestId ? requestId->key : std::string{}; + const std::string correlation = + requestId ? requestId->display : std::string{}; + bool responseObservedInterveningFrame = false; + if (request && !correlationKey.empty()) { + auto &requests = fromAppServer ? serverRequests_ : clientRequests_; + auto &order = fromAppServer ? serverRequestOrder_ : clientRequestOrder_; + remember( + requests, order, correlationKey, + PendingCorrelation{method, scopeDetails, generations, sequence_ + 1}); + } else if (response && !correlationKey.empty()) { + auto &requests = fromAppServer ? clientRequests_ : serverRequests_; + const auto correlated = requests.find(correlationKey); + if (correlated != requests.end()) { + method = correlated->second.method; + responseObservedInterveningFrame = + sequence_ != correlated->second.observedSequence; + if (correlated->second.generations == generations) { + for (const auto &[key, value] : correlated->second.scope) + scopeDetails.try_emplace(key, value); + } + requests.erase(correlated); + } + } + if (method.empty()) + method = ""; + + std::string directionName; + if (request) + directionName = fromAppServer ? "server request" : "client request"; + else if (notification) + directionName = + fromAppServer ? "server notification" : "client notification"; + else if (response) + directionName = fromAppServer + ? (success ? "client result" : "client error") + : (success ? "server result" : "server error"); + else + directionName = fromAppServer ? "server frame" : "client frame"; + + nodegraph::Value::Object details{ + {"direction", nodegraph::Value(std::move(directionName))}, + {"source", nodegraph::Value(fromAppServer ? "app-server" : "CodexUI")}, + {"authority", + nodegraph::Value(protocolMutationAuthority( + method, fromAppServer, request, notification, response, success, + responseObservedInterveningFrame))}, + {"subject", nodegraph::Value(method)}}; + if (!correlation.empty()) + details.emplace("correlation", nodegraph::Value(correlation)); + if (response) + details.emplace("outcome", nodegraph::Value(success ? "ok" : "ERROR")); + if (!success) { + details.emplace("errorCategory", nodegraph::Value("json-rpc")); + std::string code = protocolErrorCode(message); + if (!code.empty()) + details.emplace("errorCode", nodegraph::Value(std::move(code))); + std::string error = safeProtocolError(message); + if (!error.empty()) + details.emplace("error", nodegraph::Value(std::move(error))); + } + for (auto &[key, value] : scopeDetails) + details.try_emplace(std::move(key), std::move(value)); + deliver(std::move(details), channels); + } + +private: + struct PendingCorrelation final { + std::string method; + nodegraph::Value::Object scope; + nodegraph::WorkerGenerations generations; + std::uint64_t observedSequence = 0; + std::uint64_t order = 0; + }; + + using CorrelationMap = std::map>; + using CorrelationOrder = std::deque>; + + void deliver(nodegraph::Value::Object details, + nodegraph::ThreadChannels &channels) { + details.emplace("sequence", nodegraph::Value(++sequence_)); + details.emplace("connectionGeneration", + nodegraph::Value(generations_.connection)); + details.emplace("providerGeneration", + nodegraph::Value(generations_.provider)); + if (dropped_ != 0) + details.emplace("droppedBefore", nodegraph::Value(dropped_)); + nodegraph::UiEffect effect{nodegraph::UiEffectKind::ProtocolDiagnostic, + std::nullopt, + {}, + std::move(details)}; + const nodegraph::ChannelSendStatus status = channels.sendUiEffect(effect); + if (status == nodegraph::ChannelSendStatus::QueueFull) { + ++dropped_; + return; + } + dropped_ = 0; + } + + void remember(CorrelationMap &requests, CorrelationOrder &order, + const std::string &key, PendingCorrelation correlation) { + if (order.size() >= MaximumPendingCorrelations * 2U) { + CorrelationOrder compacted; + for (const auto &[orderedKey, serial] : order) { + const auto current = requests.find(orderedKey); + if (current != requests.end() && current->second.order == serial) + compacted.emplace_back(orderedKey, serial); + } + order = std::move(compacted); + } + while (requests.size() >= MaximumPendingCorrelations && + !requests.contains(key) && !order.empty()) { + const auto [oldestKey, serial] = std::move(order.front()); + order.pop_front(); + const auto oldest = requests.find(oldestKey); + if (oldest != requests.end() && oldest->second.order == serial) + requests.erase(oldest); + } + correlation.order = ++correlationOrder_; + order.emplace_back(key, correlation.order); + requests.insert_or_assign(key, std::move(correlation)); + } + + void addScope(nodegraph::Value::Object &details, + const nlohmann::json &scope) const { + constexpr std::array keys{ + std::string_view("threadId"), std::string_view("turnId"), + std::string_view("itemId"), std::string_view("requestId"), + std::string_view("processId")}; + for (std::string_view key : keys) { + const auto found = scope.find(std::string(key)); + if (found == scope.end()) + continue; + std::string value = protocolIdentifierMetadata(*found); + if (!value.empty()) + details.emplace(std::string(key), nodegraph::Value(std::move(value))); + } + constexpr std::array nested{ + std::pair{std::string_view("thread"), std::string_view("threadId")}, + std::pair{std::string_view("turn"), std::string_view("turnId")}, + std::pair{std::string_view("item"), std::string_view("itemId")}}; + for (const auto &[objectName, idName] : nested) { + if (details.contains(idName)) + continue; + const auto object = scope.find(std::string(objectName)); + if (object == scope.end() || !object->is_object()) + continue; + const auto id = object->find("id"); + if (id == object->end()) + continue; + std::string value = protocolIdentifierMetadata(*id); + if (!value.empty()) + details.emplace(std::string(idName), + nodegraph::Value(std::move(value))); + } + } + + static constexpr std::size_t MaximumPendingCorrelations = 4096; + CorrelationMap clientRequests_; + CorrelationMap serverRequests_; + CorrelationOrder clientRequestOrder_; + CorrelationOrder serverRequestOrder_; + nodegraph::WorkerGenerations generations_; + std::uint64_t correlationOrder_ = 0; + std::uint64_t sequence_ = 0; + std::uint64_t dropped_ = 0; + bool generationsKnown_ = false; +}; + +nlohmann::json +promptInput(const std::string &prompt, + const std::vector &attachments) { + nlohmann::json input = nlohmann::json::array( + {{{"type", "text"}, + {"text", nodegraph::composePromptMarkdown(prompt, attachments)}, + {"text_elements", nlohmann::json::array()}}}); + for (const nodegraph::Attachment &attachment : attachments) { + if (attachment.mimeType.starts_with("image/")) + input.push_back({{"type", "localImage"}, {"path", attachment.path}}); + else if (attachment.mimeType.starts_with("audio/")) + input.push_back({{"type", "localAudio"}, {"path", attachment.path}}); + } + return input; +} + +std::optional +decodedRequestId(const nlohmann::json &value) { + if (value.is_null()) + return std::nullopt; + return requestIdFromJson(value); +} + +void applyBridgeState(nodegraph::WorkerLogic &logic, + const codex::frontend::CodexBridge &sdk, + const nlohmann::json &message) { + const std::string kind = jsonString(message, "kind"); + if (kind != "bridge.connection" && kind != "bridge.controller" && + kind != "bridge.provider") + return; + + const std::string connectionId = sdk.connectionId().value_or(""); + const std::string controllerId = sdk.controllerConnectionId().value_or(""); + const std::string role = + sdk.role() ? std::string(codex::protocol::toString(*sdk.role())) : ""; + std::optional providerState; + std::string detail; + if (kind == "bridge.provider") { + providerState = jsonString(message, "state"); + detail = jsonString(message, "reason"); + } + static_cast(logic.bridgeState( + connectionId, role, controllerId, sdk.providerGeneration(), + std::move(providerState), std::move(detail))); +} template void configureStreamClient(Client &configuredClient, bool disabled) { @@ -64,113 +807,486 @@ void configureStreamClient(Client &configuredClient, bool disabled) { DefaultMaximumWriteQueueBytes); } -template -void dispatchRequest(codex::frontend::CodexBridge &sdk, - const nlohmann::json ¶meters, std::string action, - std::string correlationId, - ProtocolNormalizer &normalizer) { - const std::uint64_t startedAtSequence = normalizer.sequence(); - sdk.request( +struct RequestOutcome final { + bool ok = false; + std::optional requestId; + nodegraph::Value::Object payload; + std::string error; + std::string threadId; + std::string turnId; +}; + +void identifyResultEntities(RequestOutcome &outcome) { + outcome.threadId = valueString(outcome.payload, "threadId"); + outcome.turnId = valueString(outcome.payload, "turnId"); + if (const nodegraph::Value *thread = valueMember(outcome.payload, "thread")) { + if (const nodegraph::Value::Object *object = thread->asObject()) + if (outcome.threadId.empty()) + outcome.threadId = valueString(*object, "id"); + } + if (const nodegraph::Value *turn = valueMember(outcome.payload, "turn")) { + if (const nodegraph::Value::Object *object = turn->asObject()) + if (outcome.turnId.empty()) + outcome.turnId = valueString(*object, "id"); + } +} + +template +std::string dispatchRequestHandled(codex::frontend::CodexBridge &sdk, + nlohmann::json parameters, + nodegraph::WorkerLogic &workerLogic, + nodegraph::NodeRef requestTarget, + Published published, Completed completed) { + struct RequestPublication final { + bool requestPublished = false; + nodegraph::NodeRef operation; + std::optional synchronousResult; + }; + + auto publication = std::make_shared(); + const auto expectedGenerations = workerLogic.generations(); + auto process = [&workerLogic, expectedGenerations, publication, + completed = + std::move(completed)](RequestOutcome outcome) mutable { + if (workerLogic.generations() != expectedGenerations) + return; + nodegraph::DecodedMessage decoded{ + outcome.ok ? nodegraph::DecodedMessageKind::ClientResult + : nodegraph::DecodedMessageKind::ClientError, + std::string(Operation::method), + outcome.requestId, + std::move(outcome.payload), + publication->operation, + threadActivityAt(Operation::method)}; + completed(std::move(outcome), std::move(decoded)); + }; + const std::string requestId = sdk.request( typename Operation::Params{parameters}, - [action = std::move(action), correlationId = std::move(correlationId), - context = parameters, startedAtSequence, - &normalizer](typename Operation::Response &response) mutable { - normalizer.operationResult(std::move(action), std::move(correlationId), - std::move(context), response.getRaw(), - startedAtSequence); + [publication, process](typename Operation::Response &response) mutable { + const bool ok = response.ok(); + const nlohmann::json &raw = response.getRaw(); + const nlohmann::json payload = + ok ? response.getPayload() + : (raw.is_object() && raw.contains("error") ? raw["error"] + : raw); + RequestOutcome outcome{ok, decodedRequestId(response.jsonRpcId()), + decodedObject(payload), + ok ? std::string{} : resultError(raw)}; + identifyResultEntities(outcome); + if (publication->requestPublished) + process(std::move(outcome)); + else + publication->synchronousResult.emplace(std::move(outcome)); + }); + const nodegraph::ProtocolRequestId typedRequestId(requestId); + nodegraph::DecodedMessage decodedRequest{ + nodegraph::DecodedMessageKind::ClientRequest, + std::string(Operation::method), + typedRequestId, + decodedObject(parameters), + {}, + threadActivityAt(Operation::method)}; + decodedRequest.requestTarget = std::move(requestTarget); + nodegraph::WorkerApplyResult applied = + workerLogic.applyDetailed(std::move(decodedRequest)); + publication->operation = std::move(applied.primary); + published(typedRequestId); + publication->requestPublished = true; + if (publication->synchronousResult) + process(std::move(*publication->synchronousResult)); + return requestId; +} + +template +std::string dispatchRequest(codex::frontend::CodexBridge &sdk, + nlohmann::json parameters, + nodegraph::WorkerLogic &workerLogic, + nodegraph::NodeRef requestTarget, + Published published, Completed completed) { + return dispatchRequestHandled( + sdk, std::move(parameters), workerLogic, std::move(requestTarget), + std::move(published), + [&workerLogic, completed = std::move(completed)]( + RequestOutcome outcome, nodegraph::DecodedMessage decoded) mutable { + static_cast(workerLogic.applyDetailed(std::move(decoded))); + completed(std::move(outcome)); }); } +template +std::string +dispatchRequest(codex::frontend::CodexBridge &sdk, nlohmann::json parameters, + nodegraph::WorkerLogic &workerLogic, + nodegraph::NodeRef requestTarget, Completed completed) { + return dispatchRequest( + sdk, std::move(parameters), workerLogic, std::move(requestTarget), + [](const nodegraph::ProtocolRequestId &) {}, std::move(completed)); +} + +template +std::string dispatchRequest(codex::frontend::CodexBridge &sdk, + nlohmann::json parameters, + nodegraph::WorkerLogic &workerLogic, + nodegraph::NodeRef requestTarget = {}) { + return dispatchRequest(sdk, std::move(parameters), workerLogic, + std::move(requestTarget), + [](RequestOutcome) {}); +} + } // namespace -int runClientRuntime(int socketPairDescriptor, Configuration &configuration, - bool connectBridge) { +int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, + nodegraph::ThreadChannels &channels, bool connectBridge) { using StreamFactory = client::StreamSocketContextFactory; const std::size_t maximumFrameBytes = configuration.maximumFrameBytes(); + nodegraph::WorkerLogic workerLogic(graph, channels); - auto *ipcEndpoint = ipc::SNodeSocketPairEndpoint::create( - socketPairDescriptor, DefaultMaximumWriteQueueBytes, - MaximumIpcReadBytesPerEvent); - if (!ipcEndpoint) - return 1; + using ServerRequestParams = std::variant< + codex::generated::server_requests::CommandExecutionRequestApproval:: + Params, + codex::generated::server_requests::FileChangeRequestApproval::Params, + codex::generated::server_requests::ToolRequestUserInput::Params, + codex::generated::server_requests::McpServerElicitationRequest::Params, + codex::generated::server_requests::PermissionsRequestApproval::Params, + codex::generated::server_requests::DynamicToolCall::Params, + codex::generated::server_requests::ChatgptAuthTokensRefresh::Params, + codex::generated::server_requests::AttestationGenerate::Params, + codex::generated::server_requests::ApplyPatchApproval::Params, + codex::generated::server_requests::ExecCommandApproval::Params>; + struct PendingServerRequest final { + nodegraph::ProtocolRequestId requestId; + nodegraph::WorkerGenerations generations; + std::string method; + ServerRequestParams request; + }; - codex::protocol::JsonLineFramer ipcFramer(maximumFrameBytes); - codex::frontend::CodexBridge sdk({}); + std::unordered_map + pendingServerRequests; + std::unordered_set pendingHydrations; + std::unordered_set interactiveHydrations; + std::unordered_set pendingHistoricalHydrations; + std::unordered_set + historicalHydrationVisited; + std::deque historicalHydrationQueue; + bool historicalHydrationPumpActive = false; + std::unordered_set pendingHistoryLoads; + std::unordered_set resumedPromptAdmissions; + std::unordered_map + promptsWaitingForHydration; + bool threadListPending = false; + bool modelListPending = false; + bool permissionProfilesPending = false; - const auto sendToQt = [&ipcEndpoint, - maximumFrameBytes](const nlohmann::json &message) { - if (!ipcEndpoint) - return false; - try { - return ipcEndpoint->send( - codex::protocol::JsonLineFramer::encode(message, maximumFrameBytes)); - } catch (...) { - return false; - } + const auto clearTransientState = [&] { + pendingServerRequests.clear(); + pendingHydrations.clear(); + interactiveHydrations.clear(); + pendingHistoricalHydrations.clear(); + historicalHydrationVisited.clear(); + historicalHydrationQueue.clear(); + historicalHydrationPumpActive = false; + pendingHistoryLoads.clear(); + resumedPromptAdmissions.clear(); + promptsWaitingForHydration.clear(); + threadListPending = false; + modelListPending = false; + permissionProfilesPending = false; }; - ProtocolNormalizer normalizer(sendToQt); + const auto showNotice = [&workerLogic](std::string message) { + static_cast(workerLogic.showNotice(std::move(message))); + }; + + codex::frontend::CodexBridge sdk({}); + ProtocolDiagnosticEmitter protocolDiagnostics; + const auto rejectNodeAction = + [&channels, &protocolDiagnostics, &showNotice, + &workerLogic](const nodegraph::NodeAction &action, std::string message) { + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLocalRejection( + nodeActionDiagnosticSubject(action.kind), action.correlation, + action.target, message, channels); + showNotice(std::move(message)); + }; + const auto rejectRuntimeAction = + [&channels, &protocolDiagnostics, &showNotice, &workerLogic]( + const nodegraph::RuntimeAction &action, std::string message) { + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLocalRejection( + runtimeActionDiagnosticSubject(action.kind), action.correlation, {}, + message, channels); + showNotice(std::move(message)); + }; std::function requestReconnect; std::function requestShutdown; - normalizer.setDeliveryFailureHandler([&requestShutdown] { - if (requestShutdown) - requestShutdown(); - }); + std::function hydrateProvider; + std::function hydrateHistoricalChildren; std::string expectedDisconnectReason; bool desiredConnected = connectBridge; client::ClientConnection connection( - sdk, client::ClientConnectionCallbacks{ - .onConnected = - [&normalizer] { normalizer.transportEvent("connected"); }, - .onDisconnected = - [&normalizer, &expectedDisconnectReason, - &desiredConnected] { - std::string reason = - std::exchange(expectedDisconnectReason, {}); - normalizer.transportEvent( - desiredConnected ? "retrying" : "disconnected", - std::move(reason)); - }, - .onFailure = - [&normalizer](std::string reason) { - normalizer.transportEvent("failure", std::move(reason)); - }}); - - sdk.onRawJson([&normalizer](codex::protocol::AppServerDirection direction, - const nlohmann::json &message) { - if (direction == codex::protocol::AppServerDirection::FromAppServer) - normalizer.observeRawInbound(message); + sdk, + client::ClientConnectionCallbacks{ + .onConnected = + [&channels, &clearTransientState, &protocolDiagnostics, + &workerLogic] { + clearTransientState(); + static_cast(workerLogic.transportEvent("connected")); + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLifecycle("connected", {}, channels); + }, + .onDisconnected = + [&channels, &clearTransientState, &expectedDisconnectReason, + &desiredConnected, &protocolDiagnostics, &workerLogic] { + clearTransientState(); + std::string reason = + std::exchange(expectedDisconnectReason, {}); + const std::string state = + desiredConnected ? "retrying" : "disconnected"; + static_cast(workerLogic.transportEvent(state, reason)); + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLifecycle(state, reason, channels); + }, + .onFailure = + [&channels, &clearTransientState, &protocolDiagnostics, + &workerLogic](std::string reason) { + clearTransientState(); + const std::string diagnosticReason = reason; + static_cast( + workerLogic.transportEvent("failure", std::move(reason))); + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLifecycle( + "failure", diagnosticReason, channels); + }}); + + sdk.onRawJson([&channels, &protocolDiagnostics, + &workerLogic](codex::protocol::AppServerDirection direction, + const nlohmann::json &message) { + // Observe the already-decoded envelope once and immediately reduce it to + // bounded metadata. No raw payload crosses to Qt or survives this call. + protocolDiagnostics.observe(direction, message, channels); + const std::optional method = + codex::protocol::jsonRpcMethod(message); + if (!method) + return; + + if (direction == codex::protocol::AppServerDirection::ToAppServer && + *method == "initialized") { + const auto parameters = message.find("params"); + const nlohmann::json payload = + parameters == message.end() ? nlohmann::json::object() : *parameters; + static_cast(workerLogic.applyDetailed(nodegraph::DecodedMessage{ + nodegraph::DecodedMessageKind::ClientNotification, + *method, + std::nullopt, + decodedObject(payload), + {}})); + return; + } + + if (direction != codex::protocol::AppServerDirection::FromAppServer) + return; + + const std::optional requestId = + requestIdMember(message, "id"); + const nodegraph::DecodedMessageKind kind = + requestId ? nodegraph::DecodedMessageKind::ServerRequest + : nodegraph::DecodedMessageKind::ServerNotification; + const nodegraph::ProtocolDirection catalogDirection = + requestId ? nodegraph::ProtocolDirection::ServerRequest + : nodegraph::ProtocolDirection::ServerNotification; + if (nodegraph::findProtocolMethod(catalogDirection, *method)) + return; // The registered typed callback is the sole known-message path. + + const auto parameters = message.find("params"); + const nlohmann::json payload = + parameters == message.end() ? nlohmann::json::object() : *parameters; + static_cast(workerLogic.applyDetailed(nodegraph::DecodedMessage{ + kind, *method, requestId, decodedObject(payload), {}})); }); - sdk.onBridgeEvent([&normalizer](const nlohmann::json &message) { - normalizer.bridgeEvent(message); + sdk.onBridgeEvent([&channels, &clearTransientState, &hydrateProvider, + &protocolDiagnostics, &workerLogic, + &sdk](const nlohmann::json &message) { + const std::uint64_t before = workerLogic.generations().provider; + const std::string kind = jsonString(message, "kind"); + applyBridgeState(workerLogic, sdk, message); + const auto after = workerLogic.generations(); + protocolDiagnostics.setGenerations(after); + protocolDiagnostics.observeBridge(message, channels); + if (after.provider != before) + clearTransientState(); + if (kind == "bridge.provider" && sdk.providerReady() && hydrateProvider) + hydrateProvider(); }); -#define CODEXUI_REGISTER_SERVER_REQUEST(OperationName, methodName) \ - sdk.on##OperationName( \ - [&normalizer]( \ - codex::generated::server_requests::OperationName::Params &request) { \ - normalizer.serverRequest( \ - codex::generated::server_requests::OperationName::method, \ - request.jsonRpcId(), request.getPayload()); \ - }); - AI_OPENAI_CODEX_SERVER_REQUESTS(CODEXUI_REGISTER_SERVER_REQUEST) -#undef CODEXUI_REGISTER_SERVER_REQUEST + const auto registerServerRequest = [&]() { + sdk.onServerRequest([&pendingServerRequests, &sdk, &workerLogic]( + typename Operation::Params &request) { + const auto requestId = decodedRequestId(request.jsonRpcId()); + if (!requestId) + return; + for (auto entry = pendingServerRequests.begin(); + entry != pendingServerRequests.end();) { + if (entry->second.requestId == *requestId) + entry = pendingServerRequests.erase(entry); + else + ++entry; + } + nodegraph::WorkerApplyResult applied = + workerLogic.applyDetailed(nodegraph::DecodedMessage{ + nodegraph::DecodedMessageKind::ServerRequest, + std::string(Operation::method), + requestId, + decodedObject(request.getPayload()), + {}, + threadActivityAt(Operation::method)}); + if (!applied.primary) + return; + pendingServerRequests.emplace( + applied.primary, + PendingServerRequest{*requestId, workerLogic.generations(), + std::string(Operation::method), + ServerRequestParams(request)}); + }); + }; + + using namespace codex::generated::server_requests; + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); + registerServerRequest.template operator()(); #define CODEXUI_REGISTER_SERVER_NOTIFICATION(OperationName, methodName) \ - sdk.on##OperationName( \ - [&normalizer]( \ - codex::generated::server_notifications::OperationName::Params \ - ¬ification) { \ - normalizer.serverNotification( \ - codex::generated::server_notifications::OperationName::method, \ - notification.getPayload()); \ - }); + sdk.on##OperationName([&hydrateHistoricalChildren, &pendingServerRequests, \ + &workerLogic]( \ + codex::generated::server_notifications:: \ + OperationName::Params ¬ification) { \ + nodegraph::NodeRef expected; \ + const bool resolved = \ + std::string_view( \ + codex::generated::server_notifications::OperationName::method) == \ + "serverRequest/resolved"; \ + if (resolved) { \ + const auto id = requestIdMember(notification.getPayload(), "requestId"); \ + if (id) { \ + for (const auto &[node, pending] : pendingServerRequests) { \ + if (pending.requestId == *id && \ + pending.generations == workerLogic.generations()) { \ + expected = node; \ + break; \ + } \ + } \ + } \ + } \ + if (resolved && !expected) \ + return; \ + static_cast(workerLogic.applyDetailed(nodegraph::DecodedMessage{ \ + nodegraph::DecodedMessageKind::ServerNotification, \ + std::string( \ + codex::generated::server_notifications::OperationName::method), \ + std::nullopt, decodedObject(notification.getPayload()), expected, \ + threadActivityAt( \ + codex::generated::server_notifications::OperationName::method)})); \ + constexpr std::string_view appliedMethod = \ + codex::generated::server_notifications::OperationName::method; \ + if (hydrateHistoricalChildren && (appliedMethod == "item/started" || \ + appliedMethod == "item/completed")) { \ + const std::string threadId = \ + jsonString(notification.getPayload(), "threadId"); \ + if (!threadId.empty()) \ + hydrateHistoricalChildren(threadId); \ + } \ + if (expected) \ + pendingServerRequests.erase(expected); \ + }); AI_OPENAI_CODEX_SERVER_NOTIFICATIONS(CODEXUI_REGISTER_SERVER_NOTIFICATION) #undef CODEXUI_REGISTER_SERVER_NOTIFICATION + using CurrentTimeRead = current_protocol::server_requests::CurrentTimeRead; + sdk.onServerRequest([&pendingServerRequests, &showNotice, + &workerLogic, &sdk]( + CurrentTimeRead::Params &request) { + const auto requestId = requestIdFromJson(request.jsonRpcId()); + for (auto entry = pendingServerRequests.begin(); + entry != pendingServerRequests.end();) { + if (entry->second.requestId == requestId) + entry = pendingServerRequests.erase(entry); + else + ++entry; + } + nodegraph::WorkerApplyResult applied = workerLogic.applyDetailed( + nodegraph::DecodedMessage{nodegraph::DecodedMessageKind::ServerRequest, + std::string(CurrentTimeRead::method), + requestId, + decodedObject(request.getPayload()), + {}, + threadActivityAt(CurrentTimeRead::method)}); + const std::int64_t currentTime = + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const CurrentTimeRead::Response response( + nlohmann::json{{"currentTimeAt", currentTime}}); + const bool accepted = sdk.respond(request, response); + // This interaction is handled entirely by the worker and has no + // retained typed request for a later UI response. Whether the + // transport accepted the automatic reply or not, it is terminal and + // must never remain as an apparently actionable interaction node. + if (applied.primary) + static_cast( + workerLogic.resolveInteraction(applied.primary, true, {})); + if (!accepted) + showNotice("Current-time server response was rejected"); + }); + +#define CODEXUI_REGISTER_CURRENT_NOTIFICATION(OperationName) \ + sdk.onServerNotification< \ + current_protocol::server_notifications::OperationName>( \ + [&workerLogic]( \ + current_protocol::server_notifications::OperationName::Params \ + ¬ification) { \ + static_cast(workerLogic.applyDetailed(nodegraph::DecodedMessage{ \ + nodegraph::DecodedMessageKind::ServerNotification, \ + std::string(current_protocol::server_notifications:: \ + OperationName::method), \ + std::nullopt, \ + decodedObject(notification.getPayload()), \ + {}, \ + threadActivityAt(current_protocol::server_notifications:: \ + OperationName::method)})); \ + }); + CODEXUI_REGISTER_CURRENT_NOTIFICATION(ModelProviderAuthRecoveryStarted) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(ModelProviderAuthRecoveryCompleted) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(RawResponseItemCompleted) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(RawResponseCompleted) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(ThreadRealtimeItemStarted) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(ThreadRealtimeItemTranscriptDelta) + CODEXUI_REGISTER_CURRENT_NOTIFICATION(ThreadRealtimeItemCompleted) +#undef CODEXUI_REGISTER_CURRENT_NOTIFICATION + + const auto publishTransportEvent = [&channels, &clearTransientState, + &protocolDiagnostics, + &workerLogic](std::string state, + std::string detail = {}) { + if (state == "retrying" || state == "disconnected" || state == "failure") + clearTransientState(); + const std::string diagnosticState = state; + const std::string diagnosticDetail = detail; + static_cast( + workerLogic.transportEvent(std::move(state), std::move(detail))); + protocolDiagnostics.setGenerations(workerLogic.generations()); + protocolDiagnostics.observeLifecycle(diagnosticState, diagnosticDetail, + channels); + }; + net::un::stream::legacy::SocketClient unixClient("codex-ui-unix", connection, std::size_t(maximumFrameBytes)); @@ -270,14 +1386,11 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, std::string selectedTransportLabel; bool transitionPending = false; bool shutdownRequested = false; - bool shutdownDraining = false; bool eventLoopRunning = false; std::function continueTransition; std::function terminatingFlowTerminated; std::function pendingSelection; std::chrono::steady_clock::time_point transitionDeadline; - std::chrono::steady_clock::time_point shutdownDrainDeadline; - std::function finishShutdownAfterDrain; const auto selectClient = [&](auto &configuredClient, std::string transport, std::string label) { @@ -291,8 +1404,7 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, selectedTransportLabel = std::move(label); const std::string connectionLabel = selectedTransportLabel; connectSelected = [&, clientHandle, flow, connectionLabel] { - normalizer.transportEvent("retrying", - "Connecting using " + connectionLabel); + publishTransportEvent("retrying", "Connecting using " + connectionLabel); clientHandle->connect([&, flow, connectionLabel]( const auto &, core::socket::State state) { if (state == core::socket::State::OK || @@ -302,7 +1414,7 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, "failed to connect using " + connectionLabel + ": " + state.what(); core::EventReceiver::atNextTick([&, flow, failure] { if (eventLoopRunning && !shutdownRequested && flow->isTerminated()) - normalizer.transportEvent("failure", failure); + publishTransportEvent("failure", failure); }); }); }; @@ -372,7 +1484,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, }; const auto publishConnectionSettings = [&] { - normalizer.connectionSettings(connectionSettings()); + nlohmann::json settings = connectionSettings(); + static_cast(workerLogic.connectionSettings(decodedObject(settings))); }; continueTransition = [&] { @@ -381,7 +1494,7 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, if ((terminatingFlowTerminated && !terminatingFlowTerminated()) || connection.attached()) { if (std::chrono::steady_clock::now() >= transitionDeadline) { - normalizer.transportEvent("failure", "connection transition timed out"); + publishTransportEvent("failure", "connection transition timed out"); requestShutdown(); return; } @@ -460,483 +1573,1013 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, connection.shutdown(); if (terminateSelected) terminateSelected(); - if (ipcEndpoint) - ipcEndpoint->close(); if (eventLoopRunning) core::SNodeC::stop(); }; - finishShutdownAfterDrain = [&] { - if (shutdownRequested) + const auto currentThreadId = + [&graph](const nodegraph::NodeRef &target) -> std::optional { + if (!target) + return std::nullopt; + auto write = graph.write(); + if (write.find(target->id()) != target) { + static_cast(write.finish()); + return std::nullopt; + } + nodegraph::NodeRef current = target; + while (current && current->id().kind != nodegraph::NodeKind::Thread) + current = write.parent(current); + const std::optional result = + current ? std::optional(current->id().canonical) + : std::nullopt; + static_cast(write.finish()); + return result; + }; + + const auto currentNodeState = [&graph](const nodegraph::NodeRef &target) + -> std::shared_ptr { + if (!target) + return {}; + auto write = graph.write(); + std::shared_ptr result; + if (write.find(target->id()) == target) + result = write.state(target); + static_cast(write.finish()); + return result; + }; + + const auto currentNode = [&graph](nodegraph::NodeId id) { + auto write = graph.write(); + nodegraph::NodeRef result = write.find(id); + static_cast(write.finish()); + return result; + }; + + const auto requestThreadList = [&](nlohmann::json parameters) { + if (threadListPending) return; - if (ipcEndpoint && ipcEndpoint->queuedBytes() != 0 && - std::chrono::steady_clock::now() < shutdownDrainDeadline) { - static_cast(core::timer::Timer::singleshotTimer( - finishShutdownAfterDrain, utils::Timeval({0, 10000}))); + threadListPending = true; + dispatchRequest( + sdk, std::move(parameters), workerLogic, {}, + [&threadListPending, &showNotice](RequestOutcome outcome) { + threadListPending = false; + if (!outcome.ok) + showNotice(outcome.error); + }); + }; + + const auto requestModelList = [&](nlohmann::json parameters) { + if (modelListPending) + return; + modelListPending = true; + dispatchRequest( + sdk, std::move(parameters), workerLogic, {}, + [&modelListPending, &showNotice](RequestOutcome outcome) { + modelListPending = false; + if (!outcome.ok) + showNotice(outcome.error); + }); + }; + + const auto requestPermissionProfiles = [&](nlohmann::json parameters) { + if (permissionProfilesPending) + return; + permissionProfilesPending = true; + dispatchRequest( + sdk, std::move(parameters), workerLogic, {}, + [&permissionProfilesPending, &showNotice](RequestOutcome outcome) { + permissionProfilesPending = false; + if (!outcome.ok) + showNotice(outcome.error); + }); + }; + + hydrateProvider = [&] { + requestThreadList(nlohmann::json::object()); + requestModelList(nlohmann::json::object()); + requestPermissionProfiles(nlohmann::json::object()); + }; + + std::function dispatchPrompt; + const auto failPromptAndContinue = [&](const nodegraph::NodeRef &localPrompt, + std::string error) { + nodegraph::PromptTransition transition = + workerLogic.failPrompt(localPrompt, error); + showNotice(std::move(error)); + if (transition.command) + dispatchPrompt(std::move(*transition.command)); + }; + dispatchPrompt = [&](nodegraph::PromptCommand command) { + using nodegraph::PromptCommandKind; + if (!command.localPrompt) + return; + if (!sdk.providerReady() || !sdk.isController()) { + failPromptAndContinue(command.localPrompt, + "Codex is not ready for a controlled turn"); + return; + } + + if (command.kind == PromptCommandKind::CreateThread) { + auto pending = + std::make_shared(std::move(command)); + dispatchRequestHandled( + sdk, jsonObject(pending->options), workerLogic, pending->localPrompt, + [](const nodegraph::ProtocolRequestId &) {}, + [&, pending](RequestOutcome outcome, + nodegraph::DecodedMessage decoded) mutable { + if (!outcome.ok) { + const std::string error = outcome.error; + nodegraph::PromptTransition transition = + workerLogic.completePromptResult( + std::move(decoded), pending->localPrompt, false, error); + showNotice(error); + if (transition.command) + dispatchPrompt(std::move(*transition.command)); + return; + } + std::string threadId = std::move(outcome.threadId); + if (threadId.empty()) { + constexpr std::string_view MissingThread = + "Thread creation returned no thread identifier"; + nodegraph::PromptTransition transition = + workerLogic.completePromptResult(std::move(decoded), + pending->localPrompt, false, + std::string(MissingThread)); + showNotice(std::string(MissingThread)); + if (transition.command) + dispatchPrompt(std::move(*transition.command)); + return; + } + const std::string requestedName = pending->requestedName; + static_cast(workerLogic.completeCreatedThread( + std::move(decoded), *pending, threadId)); + if (pending->kind == PromptCommandKind::CreateThread || + !pending->thread) { + failPromptAndContinue( + pending->localPrompt, + "Could not attach the prompt to the created thread"); + return; + } + if (!requestedName.empty()) { + dispatchRequest( + sdk, + nlohmann::json{{"threadId", threadId}, + {"name", requestedName}}, + workerLogic, pending->thread, + [&showNotice](RequestOutcome renameOutcome) { + if (!renameOutcome.ok) + showNotice(renameOutcome.error); + }); + } + dispatchPrompt(std::move(*pending)); + }); + return; + } + + if (pendingHydrations.contains(command.thread)) { + promptsWaitingForHydration.insert_or_assign(command.thread, + std::move(command)); + return; + } + + const auto threadState = currentNodeState(command.thread); + const std::string hydrationState = + threadState ? valueString(threadState->fields, "hydrationState") + : std::string{}; + if (!threadState || hydrationState == "failed") { + failPromptAndContinue( + command.localPrompt, + hydrationState == "failed" + ? "Reload this thread before sending the preserved prompt" + : "The destination thread is no longer available"); + return; + } + const bool needsResume = + threadState && + (threadState->status == nodegraph::NodeStatus::NotLoaded || + valueString(threadState->fields, "status") == "notLoaded"); + const bool explicitlyResumed = + resumedPromptAdmissions.erase(command.localPrompt) != 0; + if (needsResume && !explicitlyResumed) { + auto pending = + std::make_shared(std::move(command)); + dispatchRequest( + sdk, nlohmann::json{{"threadId", pending->thread->id().canonical}}, + workerLogic, pending->thread, + [&, pending](RequestOutcome outcome) mutable { + if (!outcome.ok) { + failPromptAndContinue(pending->localPrompt, + std::move(outcome.error)); + return; + } + resumedPromptAdmissions.insert(pending->localPrompt); + dispatchPrompt(std::move(*pending)); + }); return; } - requestShutdown(); + + nlohmann::json parameters = jsonObject(command.options); + parameters["threadId"] = command.thread->id().canonical; + parameters["clientUserMessageId"] = command.clientUserMessageId; + parameters["input"] = promptInput(command.promptText, command.attachments); + + const nodegraph::NodeRef localPrompt = command.localPrompt; + const auto published = [&workerLogic, localPrompt]( + const nodegraph::ProtocolRequestId &requestId) { + static_cast( + workerLogic.markPromptDispatched(localPrompt, requestId)); + }; + const auto completed = [&, localPrompt](RequestOutcome outcome, + nodegraph::DecodedMessage decoded) { + std::optional turnId; + std::string id = std::move(outcome.turnId); + if (!id.empty()) + turnId = std::move(id); + nodegraph::PromptTransition transition = workerLogic.completePromptResult( + std::move(decoded), localPrompt, outcome.ok, outcome.error, + std::move(turnId)); + if (!outcome.ok) + showNotice(outcome.error); + if (transition.command) + dispatchPrompt(std::move(*transition.command)); + }; + + if (command.kind == PromptCommandKind::SteerTurn) { + parameters["expectedTurnId"] = command.expectedTurnId; + dispatchRequestHandled( + sdk, std::move(parameters), workerLogic, localPrompt, published, + completed); + } else { + dispatchRequestHandled( + sdk, std::move(parameters), workerLogic, localPrompt, published, + completed); + } + }; + + const auto continuePromptAfterHydration = + [&](const nodegraph::NodeRef &thread, std::string error, + bool resumed = false) { + auto waiting = promptsWaitingForHydration.find(thread); + if (waiting == promptsWaitingForHydration.end()) + return; + nodegraph::PromptCommand command = std::move(waiting->second); + promptsWaitingForHydration.erase(waiting); + if (!error.empty()) { + failPromptAndContinue(command.localPrompt, std::move(error)); + return; + } + if (resumed) + resumedPromptAdmissions.insert(command.localPrompt); + dispatchPrompt(std::move(command)); + }; + + const auto hydrationReady = [&](const nodegraph::NodeRef &thread) { + const auto state = currentNodeState(thread); + return state && valueString(state->fields, "hydrationState") == "ready" && + valueUnsigned(state->fields, "hydrationConnectionGeneration") == + std::optional( + workerLogic.generations().connection); + }; + + constexpr std::size_t MaximumHistoricalHydrations = 8; + std::function pumpHistoricalHydrations; + std::function + startThreadHydration; + std::function queueActiveAgentChildren; + + queueActiveAgentChildren = [&](const nodegraph::NodeRef &parent) { + for (nodegraph::NodeRef child : workerLogic.activeAgentChildren(parent)) { + if (!child || hydrationReady(child) || + pendingHydrations.contains(child) || + !historicalHydrationVisited.insert(child->id()).second) + continue; + historicalHydrationQueue.emplace_back(std::move(child)); + } + if (pumpHistoricalHydrations) + pumpHistoricalHydrations(); }; - const auto dispatchCommand = [&](nlohmann::json command) { - if (!presentation::isPresentationFrame(command) || - presentation::stringMember(command, "kind") != "command") { - normalizer.transportEvent("failure", - "invalid CodexUI presentation command"); + startThreadHydration = [&](const nodegraph::NodeRef &thread, bool interactive, + bool force) { + const std::optional threadId = currentThreadId(thread); + if (!threadId) { + if (interactive) + showNotice("The selected thread is no longer available"); return; } + if (!force && hydrationReady(thread)) + return; + if (interactive) + interactiveHydrations.insert(thread); + if (!pendingHydrations.insert(thread).second) + return; + static_cast(workerLogic.threadHydration(thread, "loading")); - const std::string action = presentation::stringMember(command, "action"); - const std::string correlationId = - presentation::stringMember(command, "correlationId"); - const nlohmann::json parameters = - presentation::member(command, "data", nlohmann::json::object()); + dispatchRequestHandled( + sdk, nlohmann::json{{"threadId", *threadId}, {"includeTurns", true}}, + workerLogic, thread, [](const nodegraph::ProtocolRequestId &) {}, + [&, thread, id = *threadId](RequestOutcome outcome, + nodegraph::DecodedMessage decoded) { + const bool wasHistorical = + pendingHistoricalHydrations.erase(thread) != 0; + const bool needsSettings = interactiveHydrations.erase(thread) != 0; + pendingHydrations.erase(thread); - if (!parameters.is_object()) { - normalizer.operationRejected(action, correlationId, -32602, - "presentation command data must be an object"); + if (!outcome.ok) { + const std::string error = outcome.error.empty() + ? "Thread hydration failed" + : outcome.error; + static_cast(workerLogic.completeThreadHydration( + std::move(decoded), thread, "failed", error)); + continuePromptAfterHydration(thread, error); + if (wasHistorical && pumpHistoricalHydrations) + pumpHistoricalHydrations(); + return; + } + static_cast(workerLogic.completeThreadHydration( + std::move(decoded), thread, "ready")); + if (!currentThreadId(thread)) { + continuePromptAfterHydration( + thread, "The selected thread is no longer available"); + if (wasHistorical && pumpHistoricalHydrations) + pumpHistoricalHydrations(); + return; + } + queueActiveAgentChildren(thread); + // Observers retain read-only thread hydration. Controller authority + // gates mutations and settings resume, not ordinary selection/read. + if (!needsSettings || !sdk.isController()) { + continuePromptAfterHydration(thread, {}); + if (wasHistorical && pumpHistoricalHydrations) + pumpHistoricalHydrations(); + return; + } + dispatchRequest( + sdk, nlohmann::json{{"threadId", id}, {"excludeTurns", true}}, + workerLogic, thread, + [&, thread](RequestOutcome resumeOutcome) mutable { + if (!resumeOutcome.ok) + showNotice(resumeOutcome.error.empty() + ? "Thread settings refresh failed" + : resumeOutcome.error); + // A successful thread/read is hydrated even when the + // controller-only settings resume fails. A later prompt to a + // provider-notLoaded thread performs its own bounded resume. + continuePromptAfterHydration(thread, {}, resumeOutcome.ok); + }); + if (wasHistorical && pumpHistoricalHydrations) + pumpHistoricalHydrations(); + }); + }; + + pumpHistoricalHydrations = [&] { + if (historicalHydrationPumpActive) return; + historicalHydrationPumpActive = true; + while (pendingHistoricalHydrations.size() < MaximumHistoricalHydrations && + !historicalHydrationQueue.empty()) { + nodegraph::NodeRef child = std::move(historicalHydrationQueue.front()); + historicalHydrationQueue.pop_front(); + if (!child || hydrationReady(child) || !currentThreadId(child) || + pendingHydrations.contains(child)) + continue; + pendingHistoricalHydrations.insert(child); + startThreadHydration(child, false, false); } + historicalHydrationPumpActive = false; + }; - if (action == "runtime.shutdown") { - if (!shutdownDraining) { - shutdownDraining = true; - normalizer.localOperationResult(action, correlationId, true, - nlohmann::json::object()); - shutdownDrainDeadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(500); - finishShutdownAfterDrain(); - } + const auto hydrateThread = [&](const nodegraph::NodeRef &thread, + bool force = false) { + if (!force && hydrationReady(thread)) + return; + interactiveHydrations.insert(thread); + startThreadHydration(thread, true, force); + }; + + hydrateHistoricalChildren = [&](std::string threadId) { + nodegraph::NodeRef parent = + currentNode({nodegraph::NodeKind::Thread, std::move(threadId)}); + if (parent) + queueActiveAgentChildren(parent); + }; + + const auto loadHistory = [&](nodegraph::NodeAction action) { + const std::optional threadId = currentThreadId(action.target); + if (!threadId) { + rejectNodeAction(action, "The selected thread is no longer available"); return; } - if (action == "connection.reconnect") { - requestReconnect(); + if (!pendingHistoryLoads.insert(action.target).second) return; + nlohmann::json parameters = jsonObject(std::move(action.payload)); + parameters["threadId"] = *threadId; + if (!parameters.contains("cursor")) { + if (const auto state = currentNodeState(action.target)) { + const std::string cursor = + valueString(state->fields, "historyNextCursor"); + if (!cursor.empty()) + parameters["cursor"] = cursor; + } } - if (action == "connection.connect") { - requestConnect(); + if (!parameters.contains("limit")) + parameters["limit"] = 80; + if (!parameters.contains("sortDirection")) + parameters["sortDirection"] = "desc"; + if (!parameters.contains("itemsView")) + parameters["itemsView"] = "full"; + const nodegraph::NodeRef thread = std::move(action.target); + dispatchRequest( + sdk, std::move(parameters), workerLogic, thread, + [&, thread](RequestOutcome outcome) { + pendingHistoryLoads.erase(thread); + if (!outcome.ok) + showNotice(outcome.error); + }); + }; + + const auto resolveInteraction = [&](nodegraph::NodeAction action) { + auto reject = [&](std::string message) { + static_cast(workerLogic.rejectInteractionResponse( + action.target, std::move(action.payload), message)); + rejectNodeAction(action, std::move(message)); + }; + auto found = pendingServerRequests.find(action.target); + if (found == pendingServerRequests.end() || + found->second.generations != workerLogic.generations() || + !sdk.providerReady() || !sdk.isController()) { + reject("The pending request is no longer actionable"); return; } - if (action == "connection.disconnect") { - requestDisconnect(); + const std::shared_ptr state = + currentNodeState(action.target); + if (!state || + valueString(state->fields, "method") != found->second.method) { + reject("The pending request is no longer actionable"); return; } - if (action == "connection.configure") { - if (transitionPending) { - normalizer.localOperationResult( - action, correlationId, false, - {{"code", -32000}, - {"message", "connection transition in progress"}}); + // A bridge sender may synchronously feed serverRequest/resolved back into + // CodexBridge. Visit a local typed copy so that such reentrancy can erase + // the map entry without invalidating the request currently being encoded. + PendingServerRequest pending = found->second; + + std::string decision = valueString(action.payload, "decision"); + if (decision.empty()) + decision = valueString(action.payload, "action"); + const nodegraph::Value *authoredDecision = + valueMember(action.payload, "decision"); + const std::string scope = valueString(action.payload, "scope"); + const nodegraph::Value *answers = valueMember(action.payload, "answers"); + const nodegraph::Value *content = valueMember(action.payload, "content"); + const nodegraph::Value *meta = valueMember(action.payload, "_meta"); + bool accepted = false; + + std::visit( + [&](auto &request) { + using Request = std::decay_t; + if constexpr (std::is_same_v< + Request, CommandExecutionRequestApproval::Params>) { + CommandExecutionRequestApproval::Response response( + nlohmann::json{{"decision", decision}}); + accepted = + sdk.respond(request, response); + } else if constexpr (std::is_same_v< + Request, + FileChangeRequestApproval::Params>) { + FileChangeRequestApproval::Response response( + nlohmann::json{{"decision", decision}}); + accepted = + sdk.respond(request, response); + } else if constexpr (std::is_same_v) { + ToolRequestUserInput::Response response(nlohmann::json{ + {"answers", answers ? jsonFromValue(*answers) + : nlohmann::json::object()}}); + accepted = sdk.respond(request, response); + } else if constexpr (std::is_same_v< + Request, + McpServerElicitationRequest::Params>) { + const bool acceptsContent = decision == "accept"; + McpServerElicitationRequest::Response response( + nlohmann::json{{"action", decision}, + {"content", acceptsContent && content + ? jsonFromValue(*content) + : nlohmann::json(nullptr)}, + {"_meta", meta ? jsonFromValue(*meta) + : nlohmann::json(nullptr)}}); + accepted = + sdk.respond(request, response); + } else if constexpr (std::is_same_v< + Request, + PermissionsRequestApproval::Params>) { + if (scope.empty() || scope == "decline" || decision == "decline") { + accepted = sdk.respondError( + request, -32601, "Permission request declined by user"); + } else { + nlohmann::json responsePayload{ + {"permissions", + request.getPayload().is_object() && + request.getPayload().contains("permissions") + ? request.getPayload()["permissions"] + : nlohmann::json::object()}, + {"scope", scope}}; + PermissionsRequestApproval::Response response( + std::move(responsePayload)); + accepted = + sdk.respond(request, response); + } + } else if constexpr (std::is_same_v) { + const nodegraph::Value *contentItems = + valueMember(action.payload, "contentItems"); + const nodegraph::Value *success = + valueMember(action.payload, "success"); + const std::string message = + valueString(action.payload, "message").empty() + ? "CodexUI does not provide this dynamic tool" + : valueString(action.payload, "message"); + DynamicToolCall::Response response(nlohmann::json{ + {"contentItems", + contentItems ? jsonFromValue(*contentItems) + : nlohmann::json::array({{{"type", "inputText"}, + {"text", message}}})}, + {"success", + success && success->asBool() ? *success->asBool() : false}}); + accepted = sdk.respond(request, response); + } else if constexpr (std::is_same_v< + Request, ChatgptAuthTokensRefresh::Params>) { + accepted = sdk.respondError( + request, -32601, + "CodexUI does not support authentication token refresh"); + } else if constexpr (std::is_same_v) { + accepted = sdk.respondError( + request, -32601, + "CodexUI does not support attestation generation"); + } else if constexpr (std::is_same_v) { + nlohmann::json reviewDecision = "abort"; + if (authoredDecision && authoredDecision->isObject()) + reviewDecision = jsonFromValue(*authoredDecision); + else if (decision == "accept" || decision == "approved") + reviewDecision = "approved"; + else if (decision == "acceptForSession" || + decision == "approved_for_session") + reviewDecision = "approved_for_session"; + else if (decision == "decline" || decision == "denied") + reviewDecision = + nlohmann::json{{"denied", {{"rejection", "Denied by user"}}}}; + ApplyPatchApproval::Response response( + nlohmann::json{{"decision", std::move(reviewDecision)}}); + accepted = sdk.respond(request, response); + } else if constexpr (std::is_same_v) { + nlohmann::json reviewDecision = "abort"; + if (authoredDecision && authoredDecision->isObject()) + reviewDecision = jsonFromValue(*authoredDecision); + else if (decision == "accept" || decision == "approved") + reviewDecision = "approved"; + else if (decision == "acceptForSession" || + decision == "approved_for_session") + reviewDecision = "approved_for_session"; + else if (decision == "decline" || decision == "denied") + reviewDecision = + nlohmann::json{{"denied", {{"rejection", "Denied by user"}}}}; + ExecCommandApproval::Response response( + nlohmann::json{{"decision", std::move(reviewDecision)}}); + accepted = sdk.respond(request, response); + } + }, + pending.request); + + if (accepted) { + static_cast( + workerLogic.resolveInteraction(action.target, true, {})); + pendingServerRequests.erase(action.target); + } else { + static_cast(workerLogic.rejectInteractionResponse( + action.target, std::move(action.payload), + "CodexBridge rejected the server-request response")); + rejectNodeAction(action, "The pending response could not be sent"); + } + }; + + const auto dispatchNodeAction = [&](nodegraph::NodeAction action) { + using enum nodegraph::NodeActionKind; + switch (action.kind) { + case Hydrate: + hydrateThread(action.target); + return; + case Reload: + hydrateThread(action.target, true); + return; + case LoadHistory: + loadHistory(std::move(action)); + return; + case Rename: + case Fork: + case Archive: + case Unarchive: + case Delete: { + if (!sdk.providerReady() || !sdk.isController()) { + rejectNodeAction( + action, "Controller access is unavailable for this thread action"); return; } - const std::string transport = - presentation::stringMember(parameters, "transport"); - std::function selection; - const auto networkEndpoint = - [&]() -> std::optional> { - const std::string host = presentation::stringMember(parameters, "host"); - const auto port = parameters.find("port"); - if (host.empty() || port == parameters.end() || - !port->is_number_integer()) - return std::nullopt; - const std::int64_t value = port->get(); - if (value <= 0 || value > 65535) - return std::nullopt; - return std::pair{host, static_cast(value)}; + if (!action.target || + action.target->id().kind != nodegraph::NodeKind::Thread) { + rejectNodeAction(action, "The selected thread is no longer available"); + return; + } + const std::shared_ptr threadState = + currentNodeState(action.target); + const auto stateFlag = [&threadState](std::string_view name) { + if (!threadState) + return false; + const nodegraph::Value *value = valueMember(threadState->fields, name); + return value && value->asBool() && *value->asBool(); + }; + if (!threadState || stateFlag("local") || stateFlag("recoveryOnly")) { + rejectNodeAction(action, "The selected thread is no longer available"); + return; + } + const bool archived = stateFlag("archived"); + if ((action.kind == Archive && archived) || + (action.kind == Unarchive && !archived)) { + rejectNodeAction(action, "The thread action is no longer applicable"); + return; + } + const std::string threadId = + nodegraph::protocolCanonicalId(*threadState, action.target); + if (threadId.empty()) { + rejectNodeAction(action, "The selected thread is no longer available"); + return; + } + if (action.kind == Rename) { + const nodegraph::Value *nameValue = valueMember(action.payload, "name"); + const std::string *name = nameValue ? nameValue->asString() : nullptr; + if (!name || + name->find_first_not_of(" \t\r\n\f\v") == std::string::npos) { + rejectNodeAction(action, "A non-empty thread name is required"); + return; + } + } + nlohmann::json parameters = jsonObject(std::move(action.payload)); + parameters["threadId"] = threadId; + const nodegraph::NodeRef target = std::move(action.target); + const auto completed = [&showNotice](RequestOutcome outcome) { + if (!outcome.ok) + showNotice(outcome.error); }; - if (transport == "unix") { - const std::string path = presentation::stringMember(parameters, "path"); - if (!path.empty()) - selection = [&, path] { - unixClient.getConfig()->Remote::setSunPath(path); - selectClient(unixClient, "unix", "Unix socket"); + if (action.kind == Rename) + dispatchRequest( + sdk, std::move(parameters), workerLogic, target, completed); + else if (action.kind == Fork) + dispatchRequest( + sdk, std::move(parameters), workerLogic, target, + [&](RequestOutcome outcome) { + if (!outcome.ok) { + showNotice(outcome.error); + return; + } + std::string forkId = std::move(outcome.threadId); + if (forkId.empty()) + return; + nodegraph::NodeRef fork = + currentNode({nodegraph::NodeKind::Thread, forkId}); + if (!fork) + return; + static_cast(workerLogic.selectThread(fork)); + hydrateThread(fork); + }); + else if (action.kind == Archive) + dispatchRequest( + sdk, std::move(parameters), workerLogic, target, completed); + else if (action.kind == Unarchive) + dispatchRequest( + sdk, std::move(parameters), workerLogic, target, completed); + else + dispatchRequest( + sdk, std::move(parameters), workerLogic, target, completed); + return; + } + case SubmitPrompt: { + nodegraph::PromptTransition transition = workerLogic.admitPrompt( + std::move(action), threadActivityAt("turn/start"), + wallClockMilliseconds()); + if (transition.command) { + if (!sdk.providerReady() || !sdk.isController()) { + failPromptAndContinue(transition.command->localPrompt, + "Codex is not ready for a controlled turn"); + } else { + dispatchPrompt(std::move(*transition.command)); + } + } + return; + } + case InterruptTurn: { + if (!sdk.providerReady() || !sdk.isController() || !action.target || + action.target->id().kind != nodegraph::NodeKind::Turn) { + rejectNodeAction(action, + "No controlled active turn is available to stop"); + return; + } + std::optional> currentActiveTurn; + { + auto write = graph.write(); + if (write.find(action.target->id()) == action.target && + write.state(action.target)->status == + nodegraph::NodeStatus::Running) { + const nodegraph::NodeRef thread = write.parent(action.target); + if (thread && thread->id().kind == nodegraph::NodeKind::Thread) { + const std::shared_ptr threadState = + write.state(thread); + const nodegraph::Value *archived = + valueMember(threadState->fields, "archived"); + const nodegraph::Value *local = + valueMember(threadState->fields, "local"); + const nodegraph::Value *recoveryOnly = + valueMember(threadState->fields, "recoveryOnly"); + const bool threadUnavailable = + (archived && archived->asBool() && *archived->asBool()) || + (local && local->asBool() && *local->asBool()) || + (recoveryOnly && recoveryOnly->asBool() && + *recoveryOnly->asBool()); + const std::vector active = + write.related(thread, nodegraph::RelationKind::ActiveTurn); + if (!threadUnavailable && + std::find(active.begin(), active.end(), action.target) != + active.end()) { + const std::shared_ptr turnState = + write.state(action.target); + const nodegraph::Value *turnLocal = + valueMember(turnState->fields, "local"); + if (!turnLocal || !turnLocal->asBool() || !*turnLocal->asBool()) { + currentActiveTurn = std::pair{ + nodegraph::protocolCanonicalId(*threadState, thread), + nodegraph::protocolCanonicalId(*turnState, action.target)}; + } + } + } + } + static_cast(write.finish()); + } + if (!currentActiveTurn || currentActiveTurn->first.empty() || + currentActiveTurn->second.empty()) { + rejectNodeAction(action, + "No controlled active turn is available to stop"); + return; + } + nlohmann::json parameters = jsonObject(std::move(action.payload)); + parameters["threadId"] = currentActiveTurn->first; + parameters["turnId"] = currentActiveTurn->second; + dispatchRequest( + sdk, std::move(parameters), workerLogic, action.target, + [&showNotice](RequestOutcome outcome) { + if (!outcome.ok) + showNotice(outcome.error); + }); + return; + } + case ResolveInteraction: + resolveInteraction(std::move(action)); + return; + case PromptMaterialized: { + static_cast( + workerLogic.promptMaterialized(std::move(action.target))); + return; + } + case UiDetached: + static_cast( + workerLogic.acknowledgeUiDetached(std::move(action.target))); + return; + } + }; + + const auto configureConnection = [&](nodegraph::RuntimeAction action) { + if (transitionPending) { + rejectRuntimeAction(action, + "A connection transition is already in progress"); + return; + } + const nlohmann::json parameters = jsonObject(std::move(action.payload)); + const std::string transport = jsonString(parameters, "transport"); + std::function selection; + const auto networkEndpoint = + [&]() -> std::optional> { + const std::string host = jsonString(parameters, "host"); + const auto port = parameters.find("port"); + if (host.empty() || port == parameters.end() || + !port->is_number_integer()) + return std::nullopt; + const std::int64_t value = port->get(); + if (value <= 0 || value > 65535) + return std::nullopt; + return std::pair{host, static_cast(value)}; + }; + + if (transport == "unix") { + const std::string path = jsonString(parameters, "path"); + if (!path.empty()) + selection = [&, path] { + unixClient.getConfig()->Remote::setSunPath(path); + selectClient(unixClient, "unix", "Unix socket"); + }; + } else if (transport == "ipv4") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + ipv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(ipv4Client, "ipv4", "IPv4"); + }; + } else if (transport == "ipv6") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + ipv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(ipv6Client, "ipv6", "IPv6"); + }; +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + } else if (transport == "tls-ipv4") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + tlsIpv4Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(tlsIpv4Client, "tls-ipv4", "IPv4 TLS"); + }; + } else if (transport == "tls-ipv6") { + if (const auto endpoint = networkEndpoint()) + selection = [&, endpoint] { + tlsIpv6Client.getConfig() + ->Remote::setHost(endpoint->first) + ->setPort(endpoint->second); + selectClient(tlsIpv6Client, "tls-ipv6", "IPv6 TLS"); + }; +#endif +#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) + } else if (transport == "rfcomm" || transport == "rfcomm-tls") { + const std::string address = jsonString(parameters, "address"); + const auto channel = parameters.find("channel"); + if (!address.empty() && channel != parameters.end() && + channel->is_number_integer() && channel->get() > 0 && + channel->get() <= 30) { + const auto value = static_cast(channel->get()); + if (transport == "rfcomm") { + selection = [&, address, value] { + rfcommClient.getConfig()->Remote::setBtAddress(address)->setChannel( + value); + selectClient(rfcommClient, "rfcomm", "RFCOMM"); + }; + } else { + selection = [&, address, value] { + rfcommTlsClient.getConfig() + ->Remote::setBtAddress(address) + ->setChannel(value); + selectClient(rfcommTlsClient, "rfcomm-tls", "RFCOMM TLS"); }; - } else if (transport == "ipv4") { - if (const auto endpoint = networkEndpoint()) - selection = [&, endpoint] { - ipv4Client.getConfig() + } + } +#endif +#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) + } else if (transport == "websocket-ipv4" || transport == "websocket-ipv6" +#if defined(CODEXUI_CODEX_FRONTEND_TLS) + || transport == "wss-ipv4" || transport == "wss-ipv6" +#endif + ) { + const auto endpoint = networkEndpoint(); + const std::string path = jsonString(parameters, "webSocketPath"); + if (endpoint && !path.empty() && path.front() == '/') { + if (transport == "websocket-ipv4") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + webSocketIpv4Client.getConfig() ->Remote::setHost(endpoint->first) ->setPort(endpoint->second); - selectClient(ipv4Client, "ipv4", "IPv4"); + selectClient(webSocketIpv4Client, "websocket-ipv4", + "WebSocket IPv4"); }; - } else if (transport == "ipv6") { - if (const auto endpoint = networkEndpoint()) - selection = [&, endpoint] { - ipv6Client.getConfig() + } else if (transport == "websocket-ipv6") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + webSocketIpv6Client.getConfig() ->Remote::setHost(endpoint->first) ->setPort(endpoint->second); - selectClient(ipv6Client, "ipv6", "IPv6"); + selectClient(webSocketIpv6Client, "websocket-ipv6", + "WebSocket IPv6"); }; #if defined(CODEXUI_CODEX_FRONTEND_TLS) - } else if (transport == "tls-ipv4") { - if (const auto endpoint = networkEndpoint()) - selection = [&, endpoint] { - tlsIpv4Client.getConfig() + } else if (transport == "wss-ipv4") { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + wssIpv4Client.getConfig() ->Remote::setHost(endpoint->first) ->setPort(endpoint->second); - selectClient(tlsIpv4Client, "tls-ipv4", "IPv4 TLS"); + selectClient(wssIpv4Client, "wss-ipv4", "WSS IPv4"); }; - } else if (transport == "tls-ipv6") { - if (const auto endpoint = networkEndpoint()) - selection = [&, endpoint] { - tlsIpv6Client.getConfig() + } else { + selection = [&, endpoint, path] { + currentWebSocketEndpoint = path; + wssIpv6Client.getConfig() ->Remote::setHost(endpoint->first) ->setPort(endpoint->second); - selectClient(tlsIpv6Client, "tls-ipv6", "IPv6 TLS"); + selectClient(wssIpv6Client, "wss-ipv6", "WSS IPv6"); }; #endif -#if defined(CODEXUI_CODEX_FRONTEND_RFCOMM) - } else if (transport == "rfcomm" || transport == "rfcomm-tls") { - const std::string address = - presentation::stringMember(parameters, "address"); - const auto channel = parameters.find("channel"); - if (!address.empty() && channel != parameters.end() && - channel->is_number_integer() && channel->get() > 0 && - channel->get() <= 30) { - const auto value = static_cast(channel->get()); - if (transport == "rfcomm") { - selection = [&, address, value] { - rfcommClient.getConfig() - ->Remote::setBtAddress(address) - ->setChannel(value); - selectClient(rfcommClient, "rfcomm", "RFCOMM"); - }; - } else { - selection = [&, address, value] { - rfcommTlsClient.getConfig() - ->Remote::setBtAddress(address) - ->setChannel(value); - selectClient(rfcommTlsClient, "rfcomm-tls", "RFCOMM TLS"); - }; - } } + } #endif -#if defined(CODEXUI_CODEX_FRONTEND_WEBSOCKET) - } else if (transport == "websocket-ipv4" || transport == "websocket-ipv6" -#if defined(CODEXUI_CODEX_FRONTEND_TLS) - || transport == "wss-ipv4" || transport == "wss-ipv6" -#endif - ) { - const auto endpoint = networkEndpoint(); - const std::string path = - presentation::stringMember(parameters, "webSocketPath"); - if (endpoint && !path.empty() && path.front() == '/') { - if (transport == "websocket-ipv4") { - selection = [&, endpoint, path] { - currentWebSocketEndpoint = path; - webSocketIpv4Client.getConfig() - ->Remote::setHost(endpoint->first) - ->setPort(endpoint->second); - selectClient(webSocketIpv4Client, "websocket-ipv4", - "WebSocket IPv4"); - }; - } else if (transport == "websocket-ipv6") { - selection = [&, endpoint, path] { - currentWebSocketEndpoint = path; - webSocketIpv6Client.getConfig() - ->Remote::setHost(endpoint->first) - ->setPort(endpoint->second); - selectClient(webSocketIpv6Client, "websocket-ipv6", - "WebSocket IPv6"); - }; -#if defined(CODEXUI_CODEX_FRONTEND_TLS) - } else if (transport == "wss-ipv4") { - selection = [&, endpoint, path] { - currentWebSocketEndpoint = path; - wssIpv4Client.getConfig() - ->Remote::setHost(endpoint->first) - ->setPort(endpoint->second); - selectClient(wssIpv4Client, "wss-ipv4", "WSS IPv4"); - }; - } else { - selection = [&, endpoint, path] { - currentWebSocketEndpoint = path; - wssIpv6Client.getConfig() - ->Remote::setHost(endpoint->first) - ->setPort(endpoint->second); - selectClient(wssIpv6Client, "wss-ipv6", "WSS IPv6"); - }; -#endif - } + } + if (!selection) { + rejectRuntimeAction(action, "Invalid connection settings"); + return; + } + beginTransition(true, std::move(selection), "local-transport-switch"); + }; + + const auto dispatchRuntimeAction = [&](nodegraph::RuntimeAction action) { + using enum nodegraph::RuntimeActionKind; + switch (action.kind) { + case RefreshThreads: + requestThreadList(jsonObject(std::move(action.payload))); + return; + case CreateThread: { + nodegraph::PromptTransition transition = workerLogic.admitFirstPrompt( + std::move(action), threadActivityAt("thread/start"), + wallClockMilliseconds()); + if (transition.command) { + if (!sdk.providerReady() || !sdk.isController()) { + failPromptAndContinue(transition.command->localPrompt, + "Codex is not ready to create a thread"); + } else { + dispatchPrompt(std::move(*transition.command)); } -#endif - } - if (!selection) { - normalizer.localOperationResult( - action, correlationId, false, - {{"code", -32602}, {"message", "invalid connection settings"}}); - return; } - beginTransition(true, std::move(selection), "local-transport-switch"); - normalizer.localOperationResult(action, correlationId, true, - {{"accepted", true}}); return; } - if (action == "controller.claim") { - static_cast(sdk.claimController()); + case Connect: + requestConnect(); return; - } - if (action == "controller.release") { - static_cast(sdk.releaseController()); + case Disconnect: + requestDisconnect(); return; - } - - if (action == "diagnostic.raw.send") { - const auto message = parameters.find("message"); - if (message == parameters.end() || !sdk.sendRawJson(*message)) - normalizer.transportEvent("failure", - "raw app-server message was rejected"); + case Reconnect: + requestReconnect(); + return; + case ConfigureConnection: + configureConnection(std::move(action)); + return; + case ClaimController: + if (!sdk.claimController()) + rejectRuntimeAction(action, "Controller claim was rejected"); + return; + case ReleaseController: + if (!sdk.releaseController()) + rejectRuntimeAction(action, "Controller release was rejected"); + return; + case RefreshCatalogs: { + nlohmann::json parameters = jsonObject(std::move(action.payload)); + requestModelList(parameters); + requestPermissionProfiles(std::move(parameters)); return; } - - if (action == "pending-request.resolve") { - const auto requestIdMember = parameters.find("requestId"); - const nlohmann::json requestId = requestIdMember == parameters.end() - ? nlohmann::json(nullptr) - : *requestIdMember; - nlohmann::json response{{"jsonrpc", "2.0"}, {"id", requestId}}; - if (parameters.contains("error")) - response["error"] = parameters["error"]; - else - response["result"] = presentation::member( - parameters, "result", nlohmann::json::object()); - if (requestId.is_null() || !sdk.sendRawJson(response)) - normalizer.transportEvent("failure", - "server-request response was rejected"); - return; - } - - using namespace codex::generated::client_requests; - if (action == "threads.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.read") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.create") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.resume") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.fork") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.rename") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.archive") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.unarchive") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "thread.delete") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "models.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "model-provider-capabilities.read") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); - else if (action == "account.read") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "account.rate-limits.read") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); - else if (action == "account.token-usage.read") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); - else if (action == "config.read") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "permission-profiles.list") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); - else if (action == "experimental-features.list") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); - else if (action == "skills.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "hooks.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "plugins.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "apps.list") - dispatchRequest(sdk, parameters, action, correlationId, - normalizer); - else if (action == "mcp-servers.list") - dispatchRequest(sdk, parameters, action, - correlationId, normalizer); -#define CODEXUI_DISPATCH_PRESENTATION_REQUEST(ActionName, OperationName) \ - else if (action == ActionName) dispatchRequest( \ - sdk, parameters, action, correlationId, normalizer); - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.unsubscribe", - ThreadUnsubscribe) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.set", ThreadGoalSet) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.get", ThreadGoalGet) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.goal.clear", ThreadGoalClear) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.metadata.update", - ThreadMetadataUpdate) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.move", - ThreadSectionMove) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.compact.start", - ThreadCompactStart) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.shell-command.start", - ThreadShellCommand) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.guardian-denial.approve", - ThreadApproveGuardianDeniedAction) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.rollback", ThreadRollback) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.sections.list", - ThreadSectionList) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.create", - ThreadSectionCreate) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.update", - ThreadSectionUpdate) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.section.delete", - ThreadSectionDelete) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("threads.loaded.list", - ThreadLoadedList) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("thread.items.inject", - ThreadInjectItems) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("skills.extra-roots.set", - SkillsExtraRootsSet) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.add", MarketplaceAdd) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.remove", - MarketplaceRemove) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("marketplace.upgrade", - MarketplaceUpgrade) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugins.installed", PluginInstalled) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.read", PluginRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.skill.read", PluginSkillRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.save", PluginShareSave) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.targets.update", - PluginShareUpdateTargets) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.shares.list", PluginShareList) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.checkout", - PluginShareCheckout) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.share.delete", - PluginShareDelete) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("apps.read", AppsRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("apps.installed", AppsInstalled) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.file.read", FsReadFile) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.file.write", FsWriteFile) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.directory.create", - FsCreateDirectory) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.metadata.read", - FsGetMetadata) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.directory.read", - FsReadDirectory) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.remove", FsRemove) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.copy", FsCopy) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.watch", FsWatch) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("filesystem.unwatch", FsUnwatch) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("skills.config.write", - SkillsConfigWrite) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.install", PluginInstall) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("plugin.uninstall", PluginUninstall) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("review.start", ReviewStart) - CODEXUI_DISPATCH_PRESENTATION_REQUEST( - "experimental-features.enablement.set", - ExperimentalFeatureEnablementSet) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-server.oauth-login.start", - McpServerOauthLogin) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-servers.refresh", - McpServerRefresh) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-resource.read", McpResourceRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("mcp-server.tool.call", - McpServerToolCall) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("windows-sandbox.setup.start", - WindowsSandboxSetupStart) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("windows-sandbox.readiness", - WindowsSandboxReadiness) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.login.start", LoginAccount) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.login.cancel", - CancelLoginAccount) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.logout", LogoutAccount) - CODEXUI_DISPATCH_PRESENTATION_REQUEST( - "account.rate-limit-reset-credit.consume", - ConsumeAccountRateLimitResetCredit) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("workspace.messages.read", - GetWorkspaceMessages) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("account.credits-nudge-email.send", - SendAddCreditsNudgeEmail) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("feedback.upload", FeedbackUpload) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.execute", OneOffCommandExec) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.stdin.write", - CommandExecWrite) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.terminate", - CommandExecTerminate) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("command.resize", CommandExecResize) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("external-agent-config.detect", - ExternalAgentConfigDetect) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("external-agent-config.import", - ExternalAgentConfigImport) - CODEXUI_DISPATCH_PRESENTATION_REQUEST( - "external-agent-config.import-history.record", - ExternalAgentConfigImportHistoryRecord) - CODEXUI_DISPATCH_PRESENTATION_REQUEST( - "external-agent-config.import-histories.read", - ExternalAgentConfigImportHistoriesRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.value.write", - ConfigValueWrite) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.batch.write", - ConfigBatchWrite) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("config.requirements.read", - ConfigRequirementsRead) - CODEXUI_DISPATCH_PRESENTATION_REQUEST("workspace.search.start", - FuzzyFileSearch) -#undef CODEXUI_DISPATCH_PRESENTATION_REQUEST - else if (action == "turn.start") dispatchRequest( - sdk, parameters, action, correlationId, normalizer); - else if (action == "turn.steer") dispatchRequest( - sdk, parameters, action, correlationId, normalizer); - else if (action == "turn.interrupt") dispatchRequest( - sdk, parameters, action, correlationId, normalizer); - else normalizer.operationRejected( - action, correlationId, -32601, - "unsupported CodexUI presentation action"); - }; - - ipcEndpoint->setOnData([&](const char *data, std::size_t size) { - try { - const bool accepted = ipcFramer.consume( - std::string_view(data, size), dispatchCommand, - [&normalizer, &requestShutdown](std::string message) { - normalizer.transportEvent("failure", std::move(message)); - requestShutdown(); - }); - if (!accepted) - requestShutdown(); - } catch (const std::exception &exception) { - normalizer.transportEvent( - "failure", std::string("presentation command dispatch failed: ") + - exception.what()); - requestShutdown(); - } catch (...) { - normalizer.transportEvent("failure", - "presentation command dispatch failed"); - requestShutdown(); } - }); - ipcEndpoint->setOnError([&normalizer, &requestShutdown](int errorNumber) { - normalizer.transportEvent("failure", std::string("socketpair failure: ") + - std::to_string(errorNumber)); - requestShutdown(); - }); - ipcEndpoint->setOnClosed([&ipcEndpoint, &requestShutdown] { - ipcEndpoint = nullptr; - requestShutdown(); - }); + }; + const bool workerMailboxReady = + WorkerMailboxReceiver::create( + channels, + [&dispatchNodeAction, &dispatchRuntimeAction, + &requestShutdown](nodegraph::QtToWorkerMessage message) { + std::visit( + [&](auto &payload) { + using Message = std::decay_t; + if constexpr (std::is_same_v) + dispatchNodeAction(std::move(payload)); + else if constexpr (std::is_same_v) + dispatchRuntimeAction(std::move(payload)); + else if (requestShutdown) + requestShutdown(); + }, + message); + }, + [&publishTransportEvent, &requestShutdown](std::string reason) { + publishTransportEvent("failure", std::move(reason)); + if (requestShutdown) + requestShutdown(); + }) != nullptr; eventLoopRunning = true; core::EventReceiver::atNextTick([&] { - normalizer.transportEvent("runtime-started"); + publishTransportEvent("runtime-started"); + if (!workerMailboxReady) { + publishTransportEvent("failure", + "unable to observe the Qt-to-worker eventfd"); + requestShutdown(); + return; + } const std::array disabled{ unixClient.getConfig()->Instance::getDisabled(), ipv4Client.getConfig()->Instance::getDisabled(), @@ -961,7 +2604,7 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, const std::size_t enabled = static_cast( std::count(disabled.begin(), disabled.end(), false)); if (enabled != 1) { - normalizer.transportEvent( + publishTransportEvent( "failure", "exactly one outgoing bridge transport must be enabled; found " + std::to_string(enabled)); @@ -1012,6 +2655,8 @@ int runClientRuntime(int socketPairDescriptor, Configuration &configuration, if (terminateSelected) terminateSelected(); connection.shutdown(); + static_cast(workerLogic.sendWorkerStopped( + result == 0 ? "SNode.C worker stopped" : "SNode.C worker failed")); return result; } diff --git a/src/codex/ClientRuntime.h b/src/codex/ClientRuntime.h index 91d0da7..430eea7 100644 --- a/src/codex/ClientRuntime.h +++ b/src/codex/ClientRuntime.h @@ -7,8 +7,17 @@ namespace codexui::codex { class Configuration; -int runClientRuntime(int socketPairDescriptor, Configuration &configuration, - bool connectBridge); +} // namespace codexui::codex + +namespace codexui::nodegraph { +class NodeGraph; +class ThreadChannels; +} // namespace codexui::nodegraph + +namespace codexui::codex { + +int runClientRuntime(Configuration &configuration, nodegraph::NodeGraph &graph, + nodegraph::ThreadChannels &channels, bool connectBridge); } // namespace codexui::codex diff --git a/src/codex/CurrentProtocolAdapters.h b/src/codex/CurrentProtocolAdapters.h new file mode 100644 index 0000000..9c19bf0 --- /dev/null +++ b/src/codex/CurrentProtocolAdapters.h @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_CURRENTPROTOCOLADAPTERS_H +#define CODEXUI_CODEX_CURRENTPROTOCOLADAPTERS_H + +#include + +#include + +namespace codexui::codex::current_protocol { + +// The installed AISuite predates these current app-server alternatives. The +// bridge only requires an operation's method and an owning Value-compatible +// Params/Response wrapper, so keep the compatibility surface limited to the +// missing operations CodexUI consumes. +using Value = ai::openai::codex::generated::Value; + +namespace client_requests { + +struct ThreadTurnsList final { + static constexpr std::string_view method = "thread/turns/list"; + using Params = Value; + using Response = Value; + static constexpr bool paramsRequired = true; +}; + +} // namespace client_requests + +namespace server_requests { + +struct CurrentTimeRead final { + static constexpr std::string_view method = "currentTime/read"; + using Params = Value; + using Response = Value; + static constexpr bool paramsRequired = true; +}; + +} // namespace server_requests + +namespace server_notifications { + +struct ModelProviderAuthRecoveryStarted final { + static constexpr std::string_view method = + "modelProvider/authRecoveryStarted"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct ModelProviderAuthRecoveryCompleted final { + static constexpr std::string_view method = + "modelProvider/authRecoveryCompleted"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct RawResponseItemCompleted final { + static constexpr std::string_view method = "rawResponseItem/completed"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct RawResponseCompleted final { + static constexpr std::string_view method = "rawResponse/completed"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct ThreadRealtimeItemStarted final { + static constexpr std::string_view method = "thread/realtime/item/started"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct ThreadRealtimeItemTranscriptDelta final { + static constexpr std::string_view method = + "thread/realtime/item/transcript/delta"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +struct ThreadRealtimeItemCompleted final { + static constexpr std::string_view method = "thread/realtime/item/completed"; + using Params = Value; + static constexpr bool paramsRequired = true; +}; + +} // namespace server_notifications +} // namespace codexui::codex::current_protocol + +#endif // CODEXUI_CODEX_CURRENTPROTOCOLADAPTERS_H diff --git a/src/codex/FrontendSession.cpp b/src/codex/FrontendSession.cpp index d07b273..69a01ee 100644 --- a/src/codex/FrontendSession.cpp +++ b/src/codex/FrontendSession.cpp @@ -3,89 +3,57 @@ #include "codex/FrontendSession.h" #include "codex/ClientRuntime.h" -#include "codex/PresentationProtocol.h" -#include "codex/ipc/QtSocketPairEndpoint.h" -#include "codex/ipc/SocketPair.h" -#include - -#include -#include -#include +#include +#include #include +#include #include -#include -#include #include -#include +#include #include +#include namespace codexui::codex { -namespace { - -constexpr std::size_t MaximumFrameBytes = 64U * 1024U * 1024U; -constexpr std::size_t MaximumWriteQueueBytes = 128U * 1024U * 1024U; -constexpr std::size_t MaximumOutstandingRequests = 4096; - -} // namespace FrontendSession::FrontendSession(Configuration &configuration) - : framer(std::make_unique( - MaximumFrameBytes)), - configuration(configuration) { - ipc::SocketPair pair; - if (!pair.isValid()) - throw std::system_error(pair.error(), std::generic_category(), - "unable to create CodexUI socketpair"); - - endpoint = std::make_unique( - pair.releaseFirstEndpoint(), MaximumWriteQueueBytes); - clientDescriptor = pair.releaseSecondEndpoint(); - endpoint->setOnData([this](const char *data, std::size_t size) { - std::string framingError; - try { - const bool accepted = framer->consume( - std::string_view(data, size), - [this](nlohmann::json message) { receiveMessage(std::move(message)); }, - [&framingError](std::string message) { - framingError = std::move(message); - }); - if (!accepted) - terminalFailure(framingError.empty() ? "CodexUI IPC framing failed" - : std::move(framingError)); - } catch (const std::exception &exception) { - terminalFailure(std::string("CodexUI IPC dispatch failed: ") + - exception.what()); - } catch (...) { - terminalFailure("CodexUI IPC dispatch failed with an unknown exception"); - } - }); - endpoint->setOnError([this](int errorNumber) { - terminalFailure(std::string("Qt socketpair failure: ") + - std::strerror(errorNumber)); - }); - endpoint->setOnClosed([this] { - failAllPending(-32020, stopping ? "CodexUI is shutting down" - : "SNode.C client thread disconnected"); - if (!stopping) { - if (!terminal) - reportLocalError("SNode.C client thread disconnected"); - notifyRuntimeStopped(); - } - }); + : configuration(configuration) { + if (!channels.valid()) { + const int error = channels.workerToQtCreationError() != 0 + ? channels.workerToQtCreationError() + : channels.qtToWorkerCreationError(); + throw std::system_error(error != 0 ? error : EIO, std::generic_category(), + "unable to create CodexUI eventfds"); + } + + workerNotifier = std::make_unique( + channels.workerToQtEventFd(), QSocketNotifier::Read); + QObject::connect(workerNotifier.get(), &QSocketNotifier::activated, + workerNotifier.get(), [this] { drainWorkerMessages(); }); + workerWakeRecoveryTimer = std::make_unique(); + workerWakeRecoveryTimer->setInterval(100); + QObject::connect(workerWakeRecoveryTimer.get(), &QTimer::timeout, + workerWakeRecoveryTimer.get(), [this] { + if (!stopping && + (channels.workerToQtSizeApprox() != 0 || + channels.rescanPending() || + workerFinished.load(std::memory_order_acquire))) + drainWorkerMessages(); + }); + workerWakeRecoveryTimer->start(); } FrontendSession::~FrontendSession() { shutdown(); } void FrontendSession::start(bool connectBridge) { - if (started) + if (started || stopping) return; started = true; - const int descriptor = std::exchange(clientDescriptor, -1); - clientThread = std::thread([this, descriptor, connectBridge] { + clientThread = std::thread([this, connectBridge] { static_cast( - runClientRuntime(descriptor, configuration, connectBridge)); + runClientRuntime(configuration, graph, channels, connectBridge)); + workerFinished.store(true, std::memory_order_release); }); } @@ -97,451 +65,268 @@ void FrontendSession::wait() { void FrontendSession::shutdown() { if (stopping) return; - if (started && endpoint && endpoint->isOpen() && - QCoreApplication::instance() && - endpoint->thread() == QThread::currentThread()) { - QEventLoop acknowledgementLoop; - const std::string requestId = - request("runtime.shutdown", nlohmann::json::object(), - [&acknowledgementLoop](const nlohmann::json &) { - acknowledgementLoop.quit(); - }); - QTimer::singleShot(750, &acknowledgementLoop, &QEventLoop::quit); - acknowledgementLoop.exec(); - // A timeout must not leave a callback capturing the completed nested loop. - outstanding.erase(requestId); - } else if (started) { - static_cast(sendMessage(presentation::command("runtime.shutdown"))); - } stopping = true; - failAllPending(-32800, "CodexUI is shutting down"); - if (endpoint) - endpoint->close(); - if (clientDescriptor >= 0) { - ::close(clientDescriptor); - clientDescriptor = -1; - } - wait(); -} - -void FrontendSession::setEventHandler(EventHandler handler) { - eventHandler = std::move(handler); -} - -void FrontendSession::setActivityHandler(ActivityHandler handler) { - activityHandler = std::move(handler); -} - -void FrontendSession::setRuntimeStoppedHandler(RuntimeStoppedHandler handler) { - runtimeStoppedHandler = std::move(handler); -} - -PresentationClient FrontendSession::presentationClient() { - return PresentationClient{ - [this](std::string action, nlohmann::json data, - PresentationClient::Completion completion) { - return request(std::move(action), std::move(data), - std::move(completion)); - }, - [this](std::string action, nlohmann::json data) { - return sendMessage( - presentation::command(std::move(action), std::move(data))); - }, - [this](nlohmann::json requestId, nlohmann::json result, - nlohmann::json error) { - return respondToServerRequest(std::move(requestId), std::move(result), - std::move(error)); - }}; -} -std::string FrontendSession::request(std::string operation, - nlohmann::json parameters, - ResponseHandler handler) { - const std::string requestId = "ui-request-" + std::to_string(nextOperation++); - const std::string threadId = - presentation::stringMember(parameters, "threadId"); - const std::string action = operation; - if (outstanding.size() >= MaximumOutstandingRequests) { - if (handler) { - try { - handler(presentation::result( - 0, activeGeneration, action, requestId, false, - {{"code", -32021}, - {"message", "CodexUI has too many outstanding operations"}})); - } catch (...) { - } - } - return requestId; - } - outstanding.emplace( - requestId, OutstandingRequest{action, threadId, std::move(handler)}); - const bool sent = sendMessage(presentation::command( - std::move(operation), std::move(parameters), requestId)); - if (sent && !threadId.empty() && - !presentation::isThreadHydrationAction(action) && activityHandler) - activityHandler(threadId); - if (!sent) { - const auto iterator = outstanding.find(requestId); - if (iterator != outstanding.end()) { - ResponseHandler failed = std::move(iterator->second.completion); - const std::string failedAction = std::move(iterator->second.action); - outstanding.erase(iterator); - if (!failed) - return requestId; - try { - failed(presentation::result( - 0, activeGeneration, failedAction, requestId, false, - {{"code", -32020}, - {"message", "CodexUI IPC rejected operation"}})); - } catch (...) { - } + if (workerNotifier) + workerNotifier->setEnabled(false); + if (workerWakeRecoveryTimer) + workerWakeRecoveryTimer->stop(); + + if (started && !workerFinished.load(std::memory_order_acquire)) { + nodegraph::ShutdownRequest request; + while (!workerFinished.load(std::memory_order_acquire)) { + const nodegraph::ChannelSendStatus status = + channels.sendShutdown(request); + if (nodegraph::deliveryGuaranteed(status)) + break; + // The bounded queue preserves FIFO ordering. The worker is its sole + // consumer, so yielding until it admits shutdown cannot duplicate or + // silently discard any already-admitted user action. + std::this_thread::yield(); } } - return requestId; -} - -std::string FrontendSession::listThreads(nlohmann::json options, - ResponseHandler handler) { - return request("threads.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::readThread(std::string threadId, - ResponseHandler handler) { - return request("thread.read", - {{"threadId", std::move(threadId)}, {"includeTurns", true}}, - std::move(handler)); -} - -std::string FrontendSession::createThread(nlohmann::json options, - ResponseHandler handler) { - return request("thread.create", std::move(options), std::move(handler)); -} - -std::string FrontendSession::resumeThread(std::string threadId, - nlohmann::json options, - ResponseHandler handler) { - options["threadId"] = std::move(threadId); - return request("thread.resume", std::move(options), std::move(handler)); -} - -std::string FrontendSession::forkThread(std::string threadId, - nlohmann::json options, - ResponseHandler handler) { - options["threadId"] = std::move(threadId); - return request("thread.fork", std::move(options), std::move(handler)); -} - -std::string FrontendSession::renameThread(std::string threadId, - std::string name, - ResponseHandler handler) { - return request("thread.rename", - {{"threadId", std::move(threadId)}, {"name", std::move(name)}}, - std::move(handler)); -} - -std::string FrontendSession::archiveThread(std::string threadId, - ResponseHandler handler) { - return request("thread.archive", {{"threadId", std::move(threadId)}}, - std::move(handler)); -} - -std::string FrontendSession::unarchiveThread(std::string threadId, - ResponseHandler handler) { - return request("thread.unarchive", {{"threadId", std::move(threadId)}}, - std::move(handler)); -} -std::string FrontendSession::deleteThread(std::string threadId, - ResponseHandler handler) { - return request("thread.delete", {{"threadId", std::move(threadId)}}, - std::move(handler)); -} - -std::string FrontendSession::listModels(nlohmann::json options, - ResponseHandler handler) { - return request("models.list", std::move(options), std::move(handler)); -} - -std::string -FrontendSession::readModelProviderCapabilities(nlohmann::json options, - ResponseHandler handler) { - return request("model-provider-capabilities.read", std::move(options), - std::move(handler)); -} - -std::string FrontendSession::readAccount(nlohmann::json options, - ResponseHandler handler) { - return request("account.read", std::move(options), std::move(handler)); -} - -std::string FrontendSession::readAccountRateLimits(ResponseHandler handler) { - return request("account.rate-limits.read", nlohmann::json::object(), - std::move(handler)); -} - -std::string FrontendSession::readAccountTokenUsage(ResponseHandler handler) { - return request("account.token-usage.read", nlohmann::json::object(), - std::move(handler)); -} - -std::string FrontendSession::readConfig(nlohmann::json options, - ResponseHandler handler) { - return request("config.read", std::move(options), std::move(handler)); -} - -std::string FrontendSession::listPermissionProfiles(nlohmann::json options, - ResponseHandler handler) { - return request("permission-profiles.list", std::move(options), - std::move(handler)); -} - -std::string FrontendSession::listExperimentalFeatures(nlohmann::json options, - ResponseHandler handler) { - return request("experimental-features.list", std::move(options), - std::move(handler)); -} - -std::string FrontendSession::listSkills(nlohmann::json options, - ResponseHandler handler) { - return request("skills.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::listHooks(nlohmann::json options, - ResponseHandler handler) { - return request("hooks.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::listPlugins(nlohmann::json options, - ResponseHandler handler) { - return request("plugins.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::listApps(nlohmann::json options, - ResponseHandler handler) { - return request("apps.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::listMcpServers(nlohmann::json options, - ResponseHandler handler) { - return request("mcp-servers.list", std::move(options), std::move(handler)); -} - -std::string FrontendSession::startTurn(std::string threadId, - nlohmann::json input, - nlohmann::json options, - ResponseHandler handler) { - options["threadId"] = std::move(threadId); - options["input"] = std::move(input); - return request("turn.start", std::move(options), std::move(handler)); -} - -std::string FrontendSession::steerTurn(std::string threadId, - std::string expectedTurnId, - nlohmann::json input, - ResponseHandler handler) { - return request("turn.steer", - {{"threadId", std::move(threadId)}, - {"expectedTurnId", std::move(expectedTurnId)}, - {"input", std::move(input)}}, - std::move(handler)); + wait(); + workerNotifier.reset(); + workerWakeRecoveryTimer.reset(); + channels.close(); } -std::string FrontendSession::interruptTurn(std::string threadId, - std::string turnId, - ResponseHandler handler) { - return request( - "turn.interrupt", - {{"threadId", std::move(threadId)}, {"turnId", std::move(turnId)}}, - std::move(handler)); +void FrontendSession::setRuntimeStoppedHandler(RuntimeStoppedHandler handler) { + runtimeStoppedHandler = std::move(handler); } -bool FrontendSession::respondToServerRequest(nlohmann::json requestId, - nlohmann::json result, - nlohmann::json error) { - nlohmann::json data{{"requestId", std::move(requestId)}}; - if (!error.is_null()) - data["error"] = std::move(error); - else - data["result"] = std::move(result); - return sendMessage( - presentation::command("pending-request.resolve", std::move(data))); +void FrontendSession::setGraphChangedHandler(GraphChangedHandler handler) { + graphChangedHandler = std::move(handler); } -bool FrontendSession::sendRaw(nlohmann::json appServerMessage) { - return sendMessage(presentation::command( - "diagnostic.raw.send", {{"message", std::move(appServerMessage)}})); +void FrontendSession::setGraphUiEffectHandler(GraphUiEffectHandler handler) { + graphUiEffectHandler = std::move(handler); } -bool FrontendSession::reconnect() { - return sendMessage(presentation::command("connection.reconnect")); +const nodegraph::NodeGraph &FrontendSession::nodeGraph() const noexcept { + return graph; } -bool FrontendSession::connectTransport() { - return sendMessage(presentation::command("connection.connect")); +nodegraph::ChannelSendStatus +FrontendSession::sendNodeAction(nodegraph::NodeAction &action) { + if (stopping || (started && workerFinished.load(std::memory_order_acquire))) + return nodegraph::ChannelSendStatus::QueueFull; + const bool assignedCorrelation = action.correlation.empty(); + if (assignedCorrelation) + action.correlation = + "ui-action-" + std::to_string(nextUiActionCorrelation++); + const nodegraph::ChannelSendStatus status = channels.sendNodeAction(action); + if (status == nodegraph::ChannelSendStatus::QueueFull && assignedCorrelation) + action.correlation.clear(); + return status; } -bool FrontendSession::disconnectTransport() { - return sendMessage(presentation::command("connection.disconnect")); +nodegraph::ChannelSendStatus +FrontendSession::sendRuntimeAction(nodegraph::RuntimeAction &action) { + if (stopping || (started && workerFinished.load(std::memory_order_acquire))) + return nodegraph::ChannelSendStatus::QueueFull; + const bool assignedCorrelation = action.correlation.empty(); + if (assignedCorrelation) + action.correlation = + "ui-action-" + std::to_string(nextUiActionCorrelation++); + const nodegraph::ChannelSendStatus status = + channels.sendRuntimeAction(action); + if (status == nodegraph::ChannelSendStatus::QueueFull && assignedCorrelation) + action.correlation.clear(); + return status; } -std::string FrontendSession::configureConnection(nlohmann::json settings, - ResponseHandler handler) { - return request("connection.configure", std::move(settings), - std::move(handler)); -} +void FrontendSession::drainWorkerMessages() { + if (stopping) + return; -bool FrontendSession::claimController() { - return sendMessage(presentation::command("controller.claim")); -} + const nodegraph::EventFd::DrainResult wake = channels.drainWorkerToQtWake(); + if (!wake.accepted()) { + if (workerNotifier) + workerNotifier->setEnabled(false); + if (graphUiEffectHandler) { + try { + graphUiEffectHandler(nodegraph::UiEffect{ + nodegraph::UiEffectKind::ShowNotice, + std::nullopt, + "Worker-to-Qt wake-up failed; CodexUI is shutting down", + {}}); + } catch (...) { + } + } + // The application quit path calls shutdown(), which uses the independently + // owned Qt-to-worker eventfd before joining the worker. + notifyRuntimeStopped(); + return; + } + if (rescanRetirementPending) + collectRescanRetirements(); + + constexpr std::size_t MaximumMessagesPerPass = 128; + constexpr qint64 MaximumDrainMilliseconds = 2; + QElapsedTimer drainBudget; + drainBudget.start(); + std::size_t processed = 0; + nodegraph::WorkerToQtMessage message; + while (processed < MaximumMessagesPerPass && + (processed == 0 || + drainBudget.elapsed() < MaximumDrainMilliseconds) && + channels.tryReceiveForQt(message)) { + ++processed; + std::visit( + [this](auto &payload) { + using Message = std::decay_t; + if constexpr (std::is_same_v) { + if (graphChangedHandler) { + try { + graphChangedHandler(payload); + } catch (...) { + } + } + collectDetachedNodes(payload.removed); + if (payload.rescanRequired) { + requireRescanRetirementCollection(); + collectRescanRetirements(); + } + } else if constexpr (std::is_same_v) { + if (graphUiEffectHandler) { + try { + graphUiEffectHandler(payload); + } catch (...) { + } + } + } else { + notifyRuntimeStopped(); + } + }, + message); + } -bool FrontendSession::releaseController() { - return sendMessage(presentation::command("controller.release")); + // A synthesized rescan is deliberately delivered ahead of older queued + // notifications. Keep retired nodes graph-readable until that entire older + // backlog has passed Qt; queued NodeRefs alone pin lifetime but do not keep + // ReadAccess membership after releaseRetired(). + const bool workerBacklogDrained = + channels.workerToQtSizeApprox() == 0 && !channels.rescanPending(); + if (workerBacklogDrained && !rescanRetirementPending) + flushDetachAcknowledgements(); + if (workerFinished.load(std::memory_order_acquire)) + notifyRuntimeStopped(); + if (!workerBacklogDrained || rescanRetirementPending || + !pendingDetachAcknowledgements.empty()) + scheduleWorkerMessageDrain(); +} + +void FrontendSession::scheduleWorkerMessageDrain() { + if (workerDrainScheduled || stopping) + return; + workerDrainScheduled = true; + // Yield through at least one native event-dispatch turn between backlog + // slices so wheel, key, paint, and socket events cannot be starved by a + // self-replenishing zero-delay drain loop. + QTimer::singleShot(1, Qt::PreciseTimer, workerNotifier.get(), [this] { + workerDrainScheduled = false; + drainWorkerMessages(); + }); } -bool FrontendSession::sendMessage(const nlohmann::json &message) { - if (!endpoint || stopping || !endpoint->isOpen()) - return false; - try { - return endpoint->send(ai::openai::codex::protocol::JsonLineFramer::encode( - message, MaximumFrameBytes)); - } catch (const std::exception &exception) { - reportLocalError(exception.what()); - return false; +void FrontendSession::requireRescanRetirementCollection() { + if (!rescanRetirementPending) { + retirementScanOffset = 0; + retirementScanGenerationKnown = false; + retirementRetryNeeded = false; } + rescanRetirementPending = true; } -void FrontendSession::receiveMessage(nlohmann::json message) { - if (!presentation::isPresentationFrame(message)) { - reportLocalError( - "SNode.C client emitted an incompatible presentation frame"); +void FrontendSession::collectRescanRetirements() { + constexpr std::size_t MaximumRetirementsPerPass = 64; + auto read = graph.tryRead(); + if (!read) { + scheduleWorkerMessageDrain(); return; } - const auto generation = message.find("generation"); - if (generation != message.end()) { - if (!generation->is_number_unsigned()) { - terminalFailure("presentation frame has an invalid generation"); - return; - } - const std::uint64_t incoming = generation->get(); - if (activeGeneration != 0 && incoming < activeGeneration) - return; - if (activeGeneration != 0 && incoming > activeGeneration) { - failAllPending(-32020, "bridge connection generation changed", true); - lastSequenceReceived = 0; - } - activeGeneration = incoming; - } - const auto sequence = message.find("sequence"); - if (sequence != message.end()) { - if (!sequence->is_number_unsigned()) { - terminalFailure("presentation frame has an invalid sequence"); - return; - } - const std::uint64_t incoming = sequence->get(); - if (incoming != 0 && lastSequenceReceived != 0 && - incoming != lastSequenceReceived + 1) { - terminalFailure("presentation frame sequence gap detected"); - return; - } - if (incoming != 0) - lastSequenceReceived = incoming; - } - if (presentation::stringMember(message, "kind") == "result") { - const std::string requestId = - presentation::stringMember(message, "correlationId"); - const auto iterator = outstanding.find(requestId); - if (iterator == outstanding.end()) - return; - if (presentation::stringMember(message, "action") != - iterator->second.action) { - terminalFailure("presentation result action does not match its request"); - return; - } - if (!iterator->second.threadId.empty() && - !presentation::isThreadHydrationAction(iterator->second.action) && - activityHandler) - activityHandler(iterator->second.threadId); - ResponseHandler handler = std::move(iterator->second.completion); - outstanding.erase(iterator); - if (handler) { - try { - handler(message); - } catch (...) { - } - } - } - if (presentation::stringMember(message, "kind") == "event") { - const std::string type = presentation::stringMember(message, "type"); - const nlohmann::json data = presentation::member( - message, "data", nlohmann::json::object()); - if (type == "connection.lifecycle") { - const std::string state = presentation::stringMember(data, "state"); - if (state == "disconnected" || state == "failure") - failAllPending(-32020, "bridge connection was lost", true); - } else if (type == "connection.provider") { - const auto provider = data.find("generation"); - if (provider == data.end() || !provider->is_number_unsigned()) { - terminalFailure("provider lifecycle event has an invalid generation"); - return; - } - const std::uint64_t incoming = provider->get(); - if (incoming < providerGeneration) - return; - if (providerGeneration != 0 && incoming > providerGeneration) - failAllPending(-32002, "app-server provider generation changed", true); - providerGeneration = incoming; - if (presentation::stringMember(data, "state") == "disconnected") - failAllPending(-32002, "app-server provider was restarted", true); - } + + const std::uint64_t orderGeneration = read->retiredOrderGeneration(); + if (!retirementScanGenerationKnown || + orderGeneration != retirementScanOrderGeneration) { + retirementScanOffset = 0; + retirementScanOrderGeneration = orderGeneration; + retirementScanGenerationKnown = true; + retirementRetryNeeded = false; } - if (eventHandler) { + + const std::size_t count = read->retiredCount(); + retirementScanOffset = std::min(retirementScanOffset, count); + const std::size_t end = + std::min(count, retirementScanOffset + MaximumRetirementsPerPass); + std::vector retired; + retired.reserve(end - retirementScanOffset); + for (std::size_t index = retirementScanOffset; index < end; ++index) + retired.emplace_back(read->retiredAt(index)); + const std::uint64_t revision = read->revision(); + const bool complete = end == count; + read.reset(); + + if (graphChangedHandler && !retired.empty()) { try { - eventHandler(message); + graphChangedHandler(nodegraph::GraphChanged{revision, {}, retired, true}); } catch (...) { } } -} + collectDetachedNodes(retired); -void FrontendSession::reportLocalError(std::string message) { - if (eventHandler) { - try { - eventHandler(presentation::event(0, activeGeneration, - "system.local-diagnostic", - {{"source", "qt"}, - {"code", "local-ipc-error"}, - {"message", std::move(message)}})); - } catch (...) { - } + if (!complete) { + retirementScanOffset = end; + return; } -} -void FrontendSession::terminalFailure(std::string message) { - if (terminal || stopping) + retirementScanOffset = 0; + if (retirementRetryNeeded) { + retirementRetryNeeded = false; return; - terminal = true; - failAllPending(-32020, message); - reportLocalError(std::move(message)); - if (endpoint && endpoint->isOpen()) - endpoint->close(); - notifyRuntimeStopped(); + } + rescanRetirementPending = false; + retirementScanGenerationKnown = false; } -void FrontendSession::failAllPending(int code, std::string message, - bool transient) noexcept { - auto failed = std::move(outstanding); - outstanding.clear(); - for (auto &[correlationId, request] : failed) { - ResponseHandler &handler = request.completion; - if (!handler) +void FrontendSession::collectDetachedNodes( + std::span nodes) { + for (const nodegraph::NodeRef &node : nodes) { + // The graph callback above runs on Qt-main and must destroy/clear any + // QWidget attachment before its removal can be acknowledged to worker. + if (!node) continue; - try { - nlohmann::json error{{"code", code}, {"message", message}}; - if (transient) - error["transient"] = true; - handler(presentation::result(0, activeGeneration, request.action, - correlationId, false, std::move(error))); - } catch (...) { + if (node->uiAttachment() != nullptr) { + requireRescanRetirementCollection(); + retirementRetryNeeded = true; + continue; + } + if (pendingDetachAcknowledgementIndex.insert(node.get()).second) + pendingDetachAcknowledgements.emplace_back(node); + } +} + +void FrontendSession::flushDetachAcknowledgements() { + constexpr std::size_t MaximumAcknowledgementsPerPass = 64; + std::size_t processed = 0; + while (!pendingDetachAcknowledgements.empty() && + processed < MaximumAcknowledgementsPerPass) { + if (pendingDetachAcknowledgements.back()->uiAttachment() != nullptr) { + requireRescanRetirementCollection(); + return; } + nodegraph::Node *const target = pendingDetachAcknowledgements.back().get(); + nodegraph::NodeAction action; + action.target = pendingDetachAcknowledgements.back(); + action.kind = nodegraph::NodeActionKind::UiDetached; + const nodegraph::ChannelSendStatus status = channels.sendNodeAction(action); + if (!nodegraph::deliveryGuaranteed(status)) + return; + pendingDetachAcknowledgements.pop_back(); + pendingDetachAcknowledgementIndex.erase(target); + ++processed; } } diff --git a/src/codex/FrontendSession.h b/src/codex/FrontendSession.h index 0c43681..accf371 100644 --- a/src/codex/FrontendSession.h +++ b/src/codex/FrontendSession.h @@ -3,36 +3,35 @@ #ifndef CODEXUI_CODEX_FRONTENDSESSION_H #define CODEXUI_CODEX_FRONTENDSESSION_H -#include "codex/PresentationClient.h" +#include "codex/nodegraph/Messages.h" +#include "codex/nodegraph/NodeGraph.h" +#include "codex/nodegraph/ThreadChannels.h" -#include - -#include +#include #include #include -#include +#include #include -#include - -namespace ai::openai::codex::protocol { -class JsonLineFramer; -} +#include +#include -namespace codexui::codex::ipc { -class QtSocketPairEndpoint; -} +class QSocketNotifier; +class QTimer; namespace codexui::codex { class Configuration; class FrontendSessionTestPeer; +// Owns the one SNode.C worker and the two typed eventfd-backed mailboxes. This +// object lives on Qt-main; only the worker passed to runClientRuntime writes +// the shared graph. class FrontendSession final { public: - using EventHandler = std::function; - using ActivityHandler = std::function; - using ResponseHandler = std::function; using RuntimeStoppedHandler = std::function; + using GraphChangedHandler = + std::function; + using GraphUiEffectHandler = std::function; explicit FrontendSession(Configuration &configuration); ~FrontendSession(); @@ -43,112 +42,53 @@ class FrontendSession final { void start(bool connectBridge = true); void wait(); void shutdown(); - void setEventHandler(EventHandler handler); - void setActivityHandler(ActivityHandler handler); + void setRuntimeStoppedHandler(RuntimeStoppedHandler handler); + void setGraphChangedHandler(GraphChangedHandler handler); + void setGraphUiEffectHandler(GraphUiEffectHandler handler); + + // Qt receives read-only access and must use NodeGraph::tryRead(). + [[nodiscard]] const nodegraph::NodeGraph &nodeGraph() const noexcept; - // Returns the slim, toolkit-neutral command API consumed by UI logic. - // FrontendSession continues to own the current Qt endpoint, socketpair, and - // SNode.C thread exactly as before. - [[nodiscard]] PresentationClient presentationClient(); - - std::string request(std::string operation, nlohmann::json parameters, - ResponseHandler handler = {}); - std::string listThreads(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string readThread(std::string threadId, ResponseHandler handler = {}); - std::string createThread(nlohmann::json options, - ResponseHandler handler = {}); - std::string resumeThread(std::string threadId, - nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string forkThread(std::string threadId, - nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string renameThread(std::string threadId, std::string name, - ResponseHandler handler = {}); - std::string archiveThread(std::string threadId, ResponseHandler handler = {}); - std::string unarchiveThread(std::string threadId, - ResponseHandler handler = {}); - std::string deleteThread(std::string threadId, ResponseHandler handler = {}); - std::string listModels(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string readModelProviderCapabilities( - nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string readAccount(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string readAccountRateLimits(ResponseHandler handler = {}); - std::string readAccountTokenUsage(ResponseHandler handler = {}); - std::string readConfig(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string - listPermissionProfiles(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string - listExperimentalFeatures(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string listSkills(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string listHooks(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string listPlugins(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string listApps(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string listMcpServers(nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string startTurn(std::string threadId, nlohmann::json input, - nlohmann::json options = nlohmann::json::object(), - ResponseHandler handler = {}); - std::string steerTurn(std::string threadId, std::string expectedTurnId, - nlohmann::json input, ResponseHandler handler = {}); - std::string interruptTurn(std::string threadId, std::string turnId, - ResponseHandler handler = {}); - bool respondToServerRequest(nlohmann::json requestId, nlohmann::json result, - nlohmann::json error = nullptr); - bool sendRaw(nlohmann::json appServerMessage); - bool reconnect(); - bool connectTransport(); - bool disconnectTransport(); - std::string configureConnection(nlohmann::json settings, - ResponseHandler handler = {}); - bool claimController(); - bool releaseController(); + // On QueueFull the action is untouched, so newly authored input remains in + // the widget and can be rejected visibly by its caller. + [[nodiscard]] nodegraph::ChannelSendStatus + sendNodeAction(nodegraph::NodeAction &action); + [[nodiscard]] nodegraph::ChannelSendStatus + sendRuntimeAction(nodegraph::RuntimeAction &action); private: friend class FrontendSessionTestPeer; - struct OutstandingRequest { - std::string action; - std::string threadId; - ResponseHandler completion; - }; - - bool sendMessage(const nlohmann::json &message); - void receiveMessage(nlohmann::json message); - void reportLocalError(std::string message); - void terminalFailure(std::string message); - void failAllPending(int code, std::string message, - bool transient = false) noexcept; + void drainWorkerMessages(); + void scheduleWorkerMessageDrain(); + void requireRescanRetirementCollection(); + void collectRescanRetirements(); + void collectDetachedNodes(std::span nodes); + void flushDetachAcknowledgements(); void notifyRuntimeStopped() noexcept; - std::unique_ptr endpoint; - std::unique_ptr framer; + nodegraph::NodeGraph graph; + nodegraph::ThreadChannels channels; + std::unique_ptr workerNotifier; + std::unique_ptr workerWakeRecoveryTimer; std::thread clientThread; - int clientDescriptor = -1; - std::uint64_t nextOperation = 1; - std::unordered_map outstanding; - EventHandler eventHandler; - ActivityHandler activityHandler; RuntimeStoppedHandler runtimeStoppedHandler; + GraphChangedHandler graphChangedHandler; + GraphUiEffectHandler graphUiEffectHandler; + std::vector pendingDetachAcknowledgements; + std::unordered_set pendingDetachAcknowledgementIndex; + std::size_t retirementScanOffset = 0; + std::uint64_t retirementScanOrderGeneration = 0; + std::atomic_bool workerFinished{false}; bool started = false; bool stopping = false; - bool terminal = false; bool runtimeStopReported = false; - std::uint64_t activeGeneration = 0; - std::uint64_t providerGeneration = 0; - std::uint64_t lastSequenceReceived = 0; + bool workerDrainScheduled = false; + bool rescanRetirementPending = false; + bool retirementScanGenerationKnown = false; + bool retirementRetryNeeded = false; + std::uint64_t nextUiActionCorrelation = 1; Configuration &configuration; }; diff --git a/src/codex/NodeGraphJson.cpp b/src/codex/NodeGraphJson.cpp new file mode 100644 index 0000000..396148d --- /dev/null +++ b/src/codex/NodeGraphJson.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/NodeGraphJson.h" + +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +nodegraph::Value::Array arrayFromJson(const nlohmann::json &value) { + nodegraph::Value::Array result; + result.reserve(value.size()); + for (const nlohmann::json &element : value) + result.emplace_back(valueFromJson(element)); + return result; +} + +nodegraph::Value::Object convertObject(const nlohmann::json &value) { + nodegraph::Value::Object result; + for (auto member = value.cbegin(); member != value.cend(); ++member) + result.emplace(member.key(), valueFromJson(member.value())); + return result; +} + +} // namespace + +nodegraph::Value valueFromJson(const nlohmann::json &value) { + if (value.is_null()) + return nullptr; + if (value.is_boolean()) + return value.get(); + if (value.is_number_unsigned()) + return value.get(); + if (value.is_number_integer()) + return value.get(); + if (value.is_number_float()) + return value.get(); + if (value.is_string()) + return value.get(); + if (value.is_array()) + return arrayFromJson(value); + if (value.is_object()) + return convertObject(value); + throw std::invalid_argument("unsupported JSON value for the node graph"); +} + +nodegraph::Value::Object objectFromJson(const nlohmann::json &value) { + if (!value.is_object()) + throw std::invalid_argument("node graph payload must be a JSON object"); + return convertObject(value); +} + +nodegraph::ProtocolRequestId requestIdFromJson(const nlohmann::json &value) { + if (value.is_string()) + return nodegraph::ProtocolRequestId(value.get()); + if (value.is_number_unsigned()) { + const std::uint64_t numeric = + value.get(); + if (numeric > + static_cast(std::numeric_limits::max())) + throw std::out_of_range( + "JSON-RPC request id exceeds signed 64-bit range"); + return nodegraph::ProtocolRequestId(static_cast(numeric)); + } + if (value.is_number_integer()) + return nodegraph::ProtocolRequestId( + value.get()); + throw std::invalid_argument( + "JSON-RPC request id must be a string or integer"); +} + +nlohmann::json jsonFromValue(const nodegraph::Value &value) { + if (value.isNull()) + return nullptr; + if (const bool *boolean = value.asBool()) + return *boolean; + if (const std::int64_t *integer = value.asInt64()) + return *integer; + if (const std::uint64_t *integer = value.asUInt64()) + return *integer; + if (const double *number = value.asDouble()) + return *number; + if (const std::string *string = value.asString()) + return *string; + if (const nodegraph::Value::Array *array = value.asArray()) { + nlohmann::json::array_t result; + result.reserve(array->size()); + for (const nodegraph::Value &element : *array) + result.emplace_back(jsonFromValue(element)); + return nlohmann::json(std::move(result)); + } + if (const nodegraph::Value::Object *object = value.asObject()) { + nlohmann::json::object_t result; + for (const auto &[key, element] : *object) + result.emplace(key, jsonFromValue(element)); + return nlohmann::json(std::move(result)); + } + throw std::logic_error("node graph Value holds no supported alternative"); +} + +nlohmann::json +jsonFromRequestId(const nodegraph::ProtocolRequestId &requestId) { + return std::visit([](const auto &value) -> nlohmann::json { return value; }, + requestId.value); +} + +} // namespace codexui::codex diff --git a/src/codex/NodeGraphJson.h b/src/codex/NodeGraphJson.h new file mode 100644 index 0000000..ffba3c9 --- /dev/null +++ b/src/codex/NodeGraphJson.h @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPHJSON_H +#define CODEXUI_CODEX_NODEGRAPHJSON_H + +#include "codex/nodegraph/ProtocolUpdater.h" +#include "codex/nodegraph/Value.h" + +#include + +namespace codexui::codex { + +// These functions adapt an already-decoded worker-thread DOM. They do not +// parse or encode JSON text and must not be called from the Qt main thread. +[[nodiscard]] nodegraph::Value valueFromJson(const nlohmann::json &value); +[[nodiscard]] nodegraph::Value::Object +objectFromJson(const nlohmann::json &value); +[[nodiscard]] nodegraph::ProtocolRequestId +requestIdFromJson(const nlohmann::json &value); + +// Reverse conversion is for worker-thread CodexBridge calls only. +[[nodiscard]] nlohmann::json jsonFromValue(const nodegraph::Value &value); +[[nodiscard]] nlohmann::json +jsonFromRequestId(const nodegraph::ProtocolRequestId &requestId); + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_NODEGRAPHJSON_H diff --git a/src/codex/PendingRequestDialog.cpp b/src/codex/PendingRequestDialog.cpp index abba42d..8a084a5 100644 --- a/src/codex/PendingRequestDialog.cpp +++ b/src/codex/PendingRequestDialog.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -86,10 +87,9 @@ void addPermissionValue(QVBoxLayout *layout, const nlohmann::json &value, } for (auto iterator = value.begin(); iterator != value.end(); ++iterator) { const QString key = permissionKey(iterator.key()); - addPermissionValue(layout, iterator.value(), - path.isEmpty() - ? key - : QStringLiteral("%1 / %2").arg(path, key)); + addPermissionValue( + layout, iterator.value(), + path.isEmpty() ? key : QStringLiteral("%1 / %2").arg(path, key)); } return; } @@ -105,17 +105,15 @@ void addPermissionValue(QVBoxLayout *layout, const nlohmann::json &value, ++index) { addPermissionValue( layout, value[static_cast(index)], - path.isEmpty() - ? QStringLiteral("Permission %1").arg(index + 1) - : QStringLiteral("%1 / %2").arg(path).arg(index + 1)); + path.isEmpty() ? QStringLiteral("Permission %1").arg(index + 1) + : QStringLiteral("%1 / %2").arg(path).arg(index + 1)); } return; } - layout->addWidget( - wrapped(QStringLiteral("%1: %2") - .arg(path.isEmpty() ? QStringLiteral("Value") : path, - permissionValue(value)), - "meta")); + layout->addWidget(wrapped(QStringLiteral("%1: %2").arg( + path.isEmpty() ? QStringLiteral("Value") : path, + permissionValue(value)), + "meta")); } void addChoice(QComboBox *combo, const QString &label, const char *value) { @@ -123,6 +121,16 @@ void addChoice(QComboBox *combo, const QString &label, const char *value) { combo->addItem(label, QString::fromLatin1(value)); } +void showValidationWarning(QWidget *parent, QString title, QString message) { + QMessageBox warning(QMessageBox::Warning, std::move(title), + std::move(message), QMessageBox::Ok, parent); + // Validation keeps the parent request dialog and its authored controls + // alive. An explicit Qt-owned dialog also avoids platform-native teardown + // reentrancy when the warning is dismissed from the nested modal loop. + warning.setOption(QMessageBox::Option::DontUseNativeDialog, true); + warning.exec(); +} + struct QuestionEditor { std::string id; std::vector> choices; @@ -133,7 +141,8 @@ struct QuestionEditor { std::optional PendingRequestDialog::present(const PendingRequestDescriptor &request, - QWidget *parent) { + QWidget *parent, + const PendingRequestResponse *initialResponse) { QDialog dialog(parent); const QString dialogTitle = text(PendingRequestPolicy::dialogTitle(request.kind)); @@ -162,6 +171,8 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, QPlainTextEdit *structuredContent = nullptr; std::vector questions; const nlohmann::json &raw = request.raw; + const nlohmann::json &initialResult = + initialResponse ? initialResponse->result : nlohmann::json::object(); if (request.kind == "command-approval") { addDetail(contentLayout, QStringLiteral("Command"), @@ -246,6 +257,31 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, editor.other->setEchoMode(QLineEdit::Password); sectionLayout->addWidget(editor.other); } + const auto initialAnswers = initialResult.find("answers"); + if (initialAnswers != initialResult.end() && + initialAnswers->is_object()) { + const auto savedQuestion = initialAnswers->find(editor.id); + if (savedQuestion != initialAnswers->end() && + savedQuestion->is_object()) { + const auto savedValues = savedQuestion->find("answers"); + if (savedValues != savedQuestion->end() && + savedValues->is_array()) { + for (const auto &savedValue : *savedValues) { + if (!savedValue.is_string()) + continue; + const std::string saved = savedValue.get(); + const auto known = std::ranges::find_if( + editor.choices, [&saved](const auto &choice) { + return choice.first == saved; + }); + if (known != editor.choices.end()) + known->second->setChecked(true); + else if (editor.other) + editor.other->setText(text(saved)); + } + } + } + } questions.push_back(std::move(editor)); contentLayout->addWidget(section); } @@ -257,9 +293,8 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, const std::string url = stringValue(raw, "url"); if (!url.empty()) { const QString escapedUrl = text(url).toHtmlEscaped(); - auto *link = wrapped(QStringLiteral("%1") - .arg(escapedUrl), - "body"); + auto *link = wrapped( + QStringLiteral("%1").arg(escapedUrl), "body"); link->setTextFormat(Qt::RichText); contentLayout->addWidget(link); } @@ -276,6 +311,9 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, structuredContent->setLineWrapMode(QPlainTextEdit::WidgetWidth); structuredContent->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); structuredContent->setProperty("kind", "dialogEditor"); + const auto savedContent = initialResult.find("content"); + if (savedContent != initialResult.end() && savedContent->is_object()) + structuredContent->setPlainText(text(savedContent->dump(2))); contentLayout->addWidget(structuredContent); } } else if (request.kind == "permissions-approval") { @@ -283,11 +321,11 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, stringValue(raw, "reason")); addDetail(contentLayout, QStringLiteral("Working directory"), stringValue(raw, "cwd")); - contentLayout->addWidget(wrapped(QStringLiteral("Requested permissions"), - "title")); - addPermissionValue( - contentLayout, - raw.value("permissions", nlohmann::json::object()), QString{}); + contentLayout->addWidget( + wrapped(QStringLiteral("Requested permissions"), "title")); + addPermissionValue(contentLayout, + raw.value("permissions", nlohmann::json::object()), + QString{}); decision = new QComboBox; addChoice(decision, QStringLiteral("Approve for this turn"), "turn"); addChoice(decision, QStringLiteral("Approve for this session"), "session"); @@ -315,6 +353,21 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, "Submitting will return an explicit JSON-RPC " "unsupported error."))); } + if (decision && initialResponse) { + std::string savedDecision; + if (request.kind == "mcp-elicitation") + savedDecision = stringValue(initialResult, "action"); + else if (request.kind == "permissions-approval") + savedDecision = initialResponse->error.is_null() + ? stringValue(initialResult, "scope") + : "decline"; + else + savedDecision = stringValue(initialResult, "decision"); + const int savedIndex = + decision->findData(text(savedDecision), Qt::UserRole, Qt::MatchExactly); + if (savedIndex >= 0) + decision->setCurrentIndex(savedIndex); + } contentLayout->addStretch(); auto *buttons = @@ -334,9 +387,9 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, if (question.other && !question.other->text().trimmed().isEmpty()) values.push_back(question.other->text().toStdString()); if (values.empty()) { - QMessageBox::warning(&dialog, QStringLiteral("Incomplete response"), - QStringLiteral("Answer every question before " - "submitting.")); + showValidationWarning( + &dialog, QStringLiteral("Incomplete response"), + QStringLiteral("Answer every question before submitting.")); return; } answers[question.id] = {{"answers", std::move(values)}}; @@ -347,9 +400,9 @@ PendingRequestDialog::present(const PendingRequestDescriptor &request, nlohmann::json content = nlohmann::json::parse( structuredContent->toPlainText().toStdString(), nullptr, false); if (content.is_discarded() || !content.is_object()) { - QMessageBox::warning(&dialog, QStringLiteral("Invalid response"), - QStringLiteral("The MCP response must be a valid " - "JSON object.")); + showValidationWarning( + &dialog, QStringLiteral("Invalid response"), + QStringLiteral("The MCP response must be a valid JSON object.")); return; } acceptedStructuredContent = std::move(content); diff --git a/src/codex/PendingRequestDialog.h b/src/codex/PendingRequestDialog.h index b4cf333..726d232 100644 --- a/src/codex/PendingRequestDialog.h +++ b/src/codex/PendingRequestDialog.h @@ -14,7 +14,8 @@ namespace codexui::codex { class PendingRequestDialog final { public: [[nodiscard]] static std::optional - present(const PendingRequestDescriptor &request, QWidget *parent); + present(const PendingRequestDescriptor &request, QWidget *parent, + const PendingRequestResponse *initialResponse = nullptr); }; } // namespace codexui::codex diff --git a/src/codex/PresentationClient.h b/src/codex/PresentationClient.h deleted file mode 100644 index 47f2c6e..0000000 --- a/src/codex/PresentationClient.h +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_PRESENTATIONCLIENT_H -#define CODEXUI_CODEX_PRESENTATIONCLIENT_H - -#include - -#include -#include -#include - -namespace codexui::codex { - -// Toolkit-neutral, protocol-complete command side of the presentation -// boundary. It deliberately contains no transport or lifecycle ownership: -// FrontendSession remains the Qt/socketpair adapter and supplies these calls. -// A different renderer can supply the same three functions without inheriting -// from a Qt type or mirroring FrontendSession's convenience methods. -class PresentationClient final { -public: - using Completion = std::function; - using Request = std::function; - using Command = - std::function; - using ServerResponse = std::function; - - PresentationClient() = default; - PresentationClient(Request request, Command command, - ServerResponse serverResponse) - : request_(std::move(request)), command_(std::move(command)), - serverResponse_(std::move(serverResponse)) {} - - [[nodiscard]] explicit operator bool() const noexcept { - return static_cast(request_) && static_cast(command_) && - static_cast(serverResponse_); - } - - std::string execute(std::string action, nlohmann::json data, - Completion completion = {}) const { - return request_ ? request_(std::move(action), std::move(data), - std::move(completion)) - : std::string{}; - } - - bool send(std::string action, - nlohmann::json data = nlohmann::json::object()) const { - return command_ && command_(std::move(action), std::move(data)); - } - - bool respond(nlohmann::json requestId, nlohmann::json result, - nlohmann::json error = nullptr) const { - return serverResponse_ && serverResponse_( - std::move(requestId), std::move(result), - std::move(error)); - } - -private: - Request request_; - Command command_; - ServerResponse serverResponse_; -}; - -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_PRESENTATIONCLIENT_H diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp deleted file mode 100644 index 0926c87..0000000 --- a/src/codex/PresentationModel.cpp +++ /dev/null @@ -1,1475 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/PresentationModel.h" - -#include "codex/PresentationProtocol.h" -#include "codex/PresentationStatus.h" - -#include -#include -#include - -namespace codexui::codex { -namespace { - -constexpr std::size_t MaximumRetainedTelemetry = 256; -constexpr std::size_t MaximumIndexedTextParts = 4096; -constexpr std::size_t MaximumRetainedStreamBytes = 256 * 1024; -constexpr std::size_t RetainedStreamTailBytes = 192 * 1024; - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto iterator = object.find(key); - return iterator != object.end() && iterator->is_string() - ? iterator->get() - : std::string{}; -} - -nlohmann::json memberValue(const nlohmann::json &object, const char *key, - nlohmann::json fallback = nullptr) { - if (!object.is_object()) - return fallback; - const auto iterator = object.find(key); - return iterator == object.end() ? std::move(fallback) : *iterator; -} - -bool boolValue(const nlohmann::json &object, const char *key, - bool fallback = false) { - if (!object.is_object()) - return fallback; - const auto iterator = object.find(key); - return iterator != object.end() && iterator->is_boolean() - ? iterator->get() - : fallback; -} - -void updateTimestamp(const nlohmann::json &object, const char *key, - std::optional &target) { - if (!object.is_object()) - return; - const auto iterator = object.find(key); - if (iterator != object.end() && iterator->is_number_integer()) - target = iterator->get(); -} - -void retainTimestamp(const nlohmann::json &object, const char *key, - std::optional &target) { - if (!object.is_object()) - return; - const auto iterator = object.find(key); - if (iterator != object.end() && iterator->is_number_integer()) { - const std::int64_t timestamp = iterator->get(); - if (!target || timestamp > *target) - target = timestamp; - } -} - -void retainActivity(ThreadPresentation &thread, std::int64_t timestamp) { - if (!thread.lastActivityAt || timestamp > *thread.lastActivityAt) - thread.lastActivityAt = timestamp; -} - -std::string statusValue(const nlohmann::json &value) { - if (value.is_string()) - return value.get(); - if (value.is_object()) - return stringValue(value, "type"); - return {}; -} - -std::string requestKey(const nlohmann::json &value) { - return value.is_null() ? std::string{} : value.dump(); -} - -void appendUnique(std::vector &values, const std::string &value, - std::size_t maximum) { - if (value.empty() || - std::find(values.begin(), values.end(), value) != values.end()) - return; - if (values.size() == maximum) - values.erase(values.begin()); - values.push_back(value); -} - -void retainRepositoryHints(ThreadPresentation &thread, - const nlohmann::json &item) { - const std::string type = stringValue(item, "type"); - if (type == "commandExecution") - appendUnique(thread.commandCwds, stringValue(item, "cwd"), 64); - if (type != "fileChange") - return; - const auto changes = item.find("changes"); - if (changes == item.end() || !changes->is_array()) - return; - for (const auto &change : *changes) - appendUnique(thread.changedPaths, stringValue(change, "path"), 512); -} - -bool isSpawnActivity(const nlohmann::json &activity) { - const std::string type = stringValue(activity, "type"); - if (type == "subAgentActivity") { - const std::string kind = stringValue(activity, "kind"); - return kind.empty() || kind == "started"; - } - if (type != "collabAgentToolCall") - return false; - const std::string tool = stringValue(activity, "tool"); - return tool == "spawn_agent" || tool == "spawnAgent" || - tool == "spawn_agents_on_csv" || tool == "spawnAgentsOnCsv"; -} - -std::string childThreadIdentity(const nlohmann::json &activity) { - const std::string childThreadId = stringValue(activity, "agentThreadId"); - if (!childThreadId.empty()) - return childThreadId; - const nlohmann::json receivers = - memberValue(activity, "receiverThreadIds", nlohmann::json::array()); - if (receivers.is_array() && receivers.size() == 1 && - receivers.front().is_string()) - return receivers.front().get(); - return {}; -} - -std::string agentIdentity(const nlohmann::json &activity, - const nlohmann::json &scope) { - const std::string itemId = stringValue(scope, "itemId"); - if (!itemId.empty()) - return itemId; - const std::string activityId = stringValue(activity, "id"); - if (!activityId.empty()) - return activityId; - return childThreadIdentity(activity); -} - -bool isStaleAgentReplay(const ThreadPresentation &owner, - const nlohmann::json &scope, - const nlohmann::json &activity, bool live) { - if (live) - return false; - const std::string id = agentIdentity(activity, scope); - const std::string childThreadId = childThreadIdentity(activity); - const auto agent = owner.agents.find(id); - return !childThreadId.empty() && agent != owner.agents.end() && - !agent->second.childThreadId.empty() && - agent->second.childThreadId != childThreadId; -} - -void mergePreservingCompleteness(nlohmann::json &target, - const nlohmann::json &update) { - if (!target.is_object() || !update.is_object()) { - if (!update.is_null() || target.is_null()) - target = update; - return; - } - for (const auto &[key, value] : update.items()) { - auto current = target.find(key); - if (current == target.end()) { - target[key] = value; - } else if (current->is_object() && value.is_object()) { - mergePreservingCompleteness(*current, value); - } else if (!value.is_null() || current->is_null()) { - *current = value; - } - } -} - -void mergeExplicitMembers(nlohmann::json &target, - const nlohmann::json &update) { - if (!target.is_object() || !update.is_object()) { - target = update; - return; - } - for (const auto &[key, value] : update.items()) { - auto current = target.find(key); - if (current != target.end() && current->is_object() && value.is_object()) - mergeExplicitMembers(*current, value); - else - target[key] = value; - } -} - -std::size_t utf8TailStart(const std::string &value, - std::size_t retainedBytes) { - if (value.size() <= retainedBytes) - return 0; - std::size_t start = value.size() - retainedBytes; - while (start < value.size() && - (static_cast(value[start]) & 0xc0u) == 0x80u) - ++start; - return start; -} - -void recordDiscardedText(ItemPresentation &item, const std::string &field, - std::size_t bytes) { - if (bytes == 0) - return; - auto value = std::find_if( - item.textRetention.begin(), item.textRetention.end(), - [&field](const TextRetentionPresentation &entry) { - return entry.field == field; - }); - if (value == item.textRetention.end()) { - item.textRetention.push_back({field}); - value = std::prev(item.textRetention.end()); - } - value->discardedBytes += bytes; -} - -TextRetentionPresentation *textRetention(ItemPresentation &item, - const std::string &field) { - const auto value = std::find_if( - item.textRetention.begin(), item.textRetention.end(), - [&field](const TextRetentionPresentation &entry) { - return entry.field == field; - }); - return value == item.textRetention.end() ? nullptr : &*value; -} - -void setRetainedTextBytes(ItemPresentation &item, const std::string &field, - std::size_t bytes) { - TextRetentionPresentation *value = textRetention(item, field); - if (!value) { - item.textRetention.push_back({field}); - value = &item.textRetention.back(); - } - value->retainedBytes = bytes; -} - -void boundScalarText(ItemPresentation &item, const std::string &field, - std::string &value) { - if (value.size() > MaximumRetainedStreamBytes) { - const std::size_t discarded = utf8TailStart(value, RetainedStreamTailBytes); - value.erase(0, discarded); - recordDiscardedText(item, field, discarded); - } - if (textRetention(item, field)) - setRetainedTextBytes(item, field, value.size()); -} - -void boundIndexedText(ItemPresentation &item, const std::string &field, - nlohmann::json &parts) { - std::size_t retained = 0; - for (const nlohmann::json &part : parts) - if (part.is_string()) - retained += part.get_ref().size(); - if (retained > MaximumRetainedStreamBytes) { - std::size_t toDiscard = retained - RetainedStreamTailBytes; - for (nlohmann::json &part : parts) { - if (toDiscard == 0 || !part.is_string()) - continue; - std::string &value = part.get_ref(); - const std::size_t discarded = - value.size() <= toDiscard - ? value.size() - : utf8TailStart(value, value.size() - toDiscard); - value.erase(0, discarded); - toDiscard = discarded >= toDiscard ? 0 : toDiscard - discarded; - retained -= discarded; - recordDiscardedText(item, field, discarded); - } - } - if (textRetention(item, field)) - setRetainedTextBytes(item, field, retained); -} - -void resetIncomingTextBounds(ItemPresentation &item, - const nlohmann::json &incoming) { - for (const char *field : {"text", "output", "aggregatedOutput", "summary", - "content"}) { - if (incoming.contains(field)) { - std::erase_if(item.textRetention, - [field](const TextRetentionPresentation &entry) { - return entry.field == field; - }); - } - } -} - -void boundRetainedItemText(ItemPresentation &item) { - const auto boundScalar = [&item](const char *field) { - auto value = item.raw.find(field); - if (value != item.raw.end() && value->is_string()) - boundScalarText(item, field, value->get_ref()); - }; - const auto boundIndexed = [&item](const char *field) { - auto parts = item.raw.find(field); - if (parts != item.raw.end() && parts->is_array()) - boundIndexedText(item, field, *parts); - }; - - const std::string type = stringValue(item.raw, "type"); - if (type == "commandExecution") { - boundScalar("aggregatedOutput"); - boundScalar("output"); - } else if (type == "agentMessage" || type == "plan") { - boundScalar("text"); - } else if (type == "reasoning") { - boundIndexed("summary"); - boundIndexed("content"); - } else if (type == "fileChange") { - boundScalar("output"); - } else if (type == "userMessage") { - return; - } else { - for (const char *field : {"text", "output", "aggregatedOutput"}) - boundScalar(field); - for (const char *field : {"summary", "content"}) - boundIndexed(field); - } -} - -void appendText(ItemPresentation &item, const char *field, - const nlohmann::json ¶ms) { - const std::string delta = stringValue(params, "delta"); - if (delta.empty()) - return; - nlohmann::json &stored = item.raw[field]; - if (!stored.is_string()) - stored = ""; - std::string &existing = stored.get_ref(); - if (delta.size() > MaximumRetainedStreamBytes) { - const std::size_t start = utf8TailStart(delta, RetainedStreamTailBytes); - recordDiscardedText(item, field, existing.size() + start); - existing.assign(delta, start, std::string::npos); - setRetainedTextBytes(item, field, existing.size()); - return; - } - existing += delta; - boundScalarText(item, field, existing); -} - -void appendIndexedText(ItemPresentation &item, const char *field, - const nlohmann::json ¶ms, const char *indexField) { - const auto index = params.find(indexField); - const bool hasIndex = index != params.end() && index->is_number_integer() && - index->get() >= 0; - const std::size_t position = hasIndex ? index->get() : 0; - if (position >= MaximumIndexedTextParts) - return; - nlohmann::json &parts = item.raw[field]; - if (!parts.is_array()) - parts = nlohmann::json::array(); - while (parts.size() <= position) - parts.push_back(""); - if (!parts[position].is_string()) - parts[position] = ""; - std::string delta = stringValue(params, "delta"); - if (delta.empty()) - delta = stringValue(params, "text"); - std::string &existing = parts[position].get_ref(); - existing += delta; - boundIndexedText(item, field, parts); -} - -void applyDomainAuthority( - std::unordered_map &domains, - const std::string &type, const nlohmann::json &data, - const std::string &authority) { - if (authority == "none") - return; - if (authority == "remove") { - domains.erase(type); - return; - } - if (authority == "replace" || !domains.contains(type)) { - domains[type] = data; - return; - } - if (type == "thread.settings.changed") { - // A null setting explicitly restores the app-server default. Preserve it - // instead of treating it as an incomplete presentation update. - mergeExplicitMembers(domains[type], data); - return; - } - mergePreservingCompleteness(domains[type], data); -} - -} // namespace - -void PresentationModel::applyEvent(const nlohmann::json &event) noexcept { - try { - applyValidatedEvent(event); - } catch (...) { - // Presentation mutation is an untrusted-data boundary. No malformed event - // may escape through Qt dispatch. - } -} - -void PresentationModel::noteThreadActivity(const std::string &threadId, - std::int64_t timestamp) noexcept { - std::string current = threadId; - std::unordered_set visited; - while (!current.empty() && visited.insert(current).second) { - const auto iterator = threads.find(current); - if (iterator == threads.end()) - break; - ThreadPresentation &thread = iterator->second; - retainActivity(thread, timestamp); - const auto ownership = childOwnerships.find(current); - if (ownership == childOwnerships.end()) - break; - current = ownership->second.parentThreadId; - } -} - -void PresentationModel::notePromptActivity(const std::string &threadId, - std::int64_t timestamp) noexcept { - for (const auto &[id, thread] : threads) { - static_cast(id); - if (thread.updatedAt && *thread.updatedAt >= timestamp) - timestamp = *thread.updatedAt + 1; - if (thread.recencyAt && *thread.recencyAt >= timestamp) - timestamp = *thread.recencyAt + 1; - } - std::string current = threadId; - std::unordered_set visited; - while (!current.empty() && visited.insert(current).second) { - const auto iterator = threads.find(current); - if (iterator == threads.end()) - break; - ThreadPresentation &thread = iterator->second; - retainActivity(thread, timestamp); - thread.updatedAt = timestamp; - thread.recencyAt = timestamp; - const auto ownership = childOwnerships.find(current); - if (ownership == childOwnerships.end()) - break; - current = ownership->second.parentThreadId; - } -} - -void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { - if (!presentation::isPresentationFrame(event)) - return; - - const std::string kind = presentation::stringMember(event, "kind"); - const auto generationMember = event.find("generation"); - const std::uint64_t generation = - generationMember != event.end() && generationMember->is_number_unsigned() - ? generationMember->get() - : 0; - if (connectionState.generation != 0 && generation != 0 && - generation < connectionState.generation) - return; - if (generation > connectionState.generation) { - const bool replacesConnection = connectionState.generation != 0; - connectionState.generation = generation; - lastSequence = 0; - pendingRequests.clear(); - if (replacesConnection) { - connectionState.connectionId.clear(); - connectionState.role.clear(); - connectionState.controllerConnectionId.clear(); - connectionState.providerGeneration = 0; - connectionState.providerState.clear(); - connectionState.providerDetail.clear(); - } - } - const auto sequenceMember = event.find("sequence"); - const std::uint64_t sequence = - sequenceMember != event.end() && sequenceMember->is_number_unsigned() - ? sequenceMember->get() - : 0; - if (sequence != 0) { - if (sequence <= lastSequence) - return; - lastSequence = sequence; - } - const nlohmann::json data = - presentation::member(event, "data", nlohmann::json::object()); - const nlohmann::json scope = - presentation::member(event, "scope", nlohmann::json::object()); - if (kind == "result") { - if (!boolValue(event, "ok")) - return; - const std::string action = presentation::stringMember(event, "action"); - if (action == "threads.list") { - const nlohmann::json threads = - memberValue(data, "threads", nlohmann::json::array()); - mergeThreadList(threads); - } else if (action == "thread.read") { - const nlohmann::json thread = - memberValue(data, "thread", nlohmann::json::object()); - upsertThread(thread, stringValue(event, "authority") == "replace"); - } else if (action == "thread.create" || action == "thread.resume" || - action == "thread.fork") { - upsertThread(memberValue(data, "thread", nlohmann::json::object()), - false); - } else if (action == "turn.start") { - const std::string threadId = stringValue(scope, "threadId"); - const auto thread = threads.find(threadId); - if (thread != threads.end()) { - TurnPresentation &turn = upsertTurn( - thread->second, - memberValue(data, "turn", nlohmann::json::object()), false); - if (isActiveStatus(turn.status)) - thread->second.status = "active"; - } - } else if (action == "models.list") { - const nlohmann::json listedModels = - memberValue(data, "models", nlohmann::json::array()); - if (listedModels.is_array()) - models = listedModels; - } else { - retainDomainEvent("operation." + action, data, scope, - presentation::stringMember(event, "authority")); - } - return; - } - - if (kind != "event") - return; - - const std::string type = presentation::stringMember(event, "type"); - const std::string authority = - presentation::stringMember(event, "authority"); - if (authority == "none") { - if (retainedTelemetry.size() == MaximumRetainedTelemetry) - retainedTelemetry.erase(retainedTelemetry.begin()); - retainedTelemetry.push_back(TelemetryPresentation{ - sequence, generation, type, data, scope}); - } - if (type == "connection.lifecycle") { - connectionState.generation = - generation; - const std::string lifecycle = stringValue(data, "state"); - if (lifecycle == "connected") { - connectionState.connected = true; - connectionState.retrying = false; - connectionState.detail.clear(); - } else if (lifecycle == "connecting" || lifecycle == "retrying") { - connectionState.connected = false; - connectionState.retrying = true; - connectionState.connectionId.clear(); - connectionState.role.clear(); - connectionState.controllerConnectionId.clear(); - connectionState.detail = stringValue(data, "detail"); - connectionState.providerState.clear(); - connectionState.providerDetail.clear(); - clearProviderState(); - } else if (lifecycle == "disconnected" || lifecycle == "failure") { - connectionState.connected = false; - connectionState.retrying = false; - connectionState.connectionId.clear(); - connectionState.role.clear(); - connectionState.controllerConnectionId.clear(); - connectionState.detail = stringValue(data, "detail"); - connectionState.providerState.clear(); - connectionState.providerDetail.clear(); - clearProviderState(); - } - return; - } - if (type == "connection.bridge") { - connectionState.connectionId = stringValue(data, "connectionId"); - connectionState.role = stringValue(data, "role"); - return; - } - if (type == "connection.controller") { - connectionState.controllerConnectionId = - stringValue(data, "controllerConnectionId"); - if (!connectionState.connectionId.empty()) - connectionState.role = - connectionState.controllerConnectionId == connectionState.connectionId - ? "controller" - : "observer"; - return; - } - if (type == "connection.provider") { - const auto providerGeneration = data.find("generation"); - if (providerGeneration == data.end() || - !providerGeneration->is_number_unsigned()) - return; - const std::uint64_t incoming = providerGeneration->get(); - if (incoming < connectionState.providerGeneration) - return; - const std::string state = stringValue(data, "state"); - if ((connectionState.providerGeneration != 0 && - incoming > connectionState.providerGeneration) || - state == "disconnected") - clearProviderState(); - connectionState.providerGeneration = incoming; - connectionState.providerState = state; - connectionState.providerDetail = stringValue(data, "reason"); - return; - } - if (type == "connection.settings.changed") { - connectionState.settings = data; - return; - } - if (type == "thread.upsert") { - upsertThread(memberValue(data, "thread", nlohmann::json::object()), false); - return; - } - if (type == "thread.name.changed") { - const auto thread = threads.find(stringValue(scope, "threadId")); - if (thread != threads.end() && data.contains("name") && - data["name"].is_string()) { - thread->second.title = data["name"].get(); - thread->second.raw["name"] = data["name"]; - } - return; - } - if (type == "thread.status.changed") { - const auto thread = threads.find(stringValue(scope, "threadId")); - if (thread != threads.end()) { - thread->second.status = statusValue(memberValue(data, "status")); - thread->second.raw["status"] = memberValue(data, "status"); - updateOwningAgentStatus(thread->first, thread->second.status); - } - return; - } - if (type == "thread.lifecycle") { - const auto thread = threads.find(stringValue(scope, "threadId")); - if (thread != threads.end()) { - thread->second.status = stringValue(data, "state"); - const std::string lifecycle = stringValue(data, "state"); - if (lifecycle == "archived") - thread->second.archived = true; - else if (lifecycle == "unarchived") - thread->second.archived = false; - thread->second.raw["presentationLifecycle"] = lifecycle; - } - return; - } - if (type == "thread.removed") { - removeThread(stringValue(scope, "threadId")); - return; - } - - if (type == "pending-request.upsert") { - const auto id = data.find("requestId"); - if (id == data.end() || id->is_null()) - return; - const std::string key = requestKey(*id); - pendingRequests[key] = PendingRequestPresentation{ - key, stringValue(data, "category"), stringValue(scope, "threadId"), - generation, memberValue(data, "request")}; - return; - } - if (type == "pending-request.removed") { - const auto id = scope.find("requestId"); - if (id != scope.end()) - pendingRequests.erase(requestKey(*id)); - return; - } - - const std::string threadId = stringValue(scope, "threadId"); - if (threadId.empty()) { - retainDomainEvent(type, data, scope, authority); - return; - } - auto threadIterator = threads.find(threadId); - if (threadIterator == threads.end()) { - if (authority == "none" || authority == "remove") - return; - nlohmann::json minimal{{"id", threadId}}; - upsertThread(minimal, false, false); - threadIterator = threads.find(threadId); - if (threadIterator == threads.end()) - return; - } - ThreadPresentation &thread = threadIterator->second; - - retainDomainEvent(type, data, scope, authority); - if (authority == "none" || authority == "remove") - return; - - if (type == "thread.settings.changed" && data.is_object()) { - thread.latestSettingsUpdate = data.value("threadSettings", data); - ++thread.settingsRevision; - } - - if (type == "turn.upsert") { - nlohmann::json turn = - memberValue(data, "turn", nlohmann::json::object()); - const std::string lifecycle = stringValue(data, "lifecycle"); - const std::string embeddedStatus = - statusValue(memberValue(turn, "status")); - if (lifecycle == "completed" && - !isTerminalTurnStatus(embeddedStatus)) - turn["status"] = "completed"; - else if (lifecycle == "started" && embeddedStatus.empty()) - turn["status"] = "inProgress"; - TurnPresentation &updated = upsertTurn(thread, turn, false); - if (lifecycle == "started" && isActiveStatus(updated.status)) - thread.status = "active"; - return; - } - if (type == "plan.replaced") { - const std::string turnId = stringValue(scope, "turnId"); - nlohmann::json minimalTurn{{"id", turnId}}; - TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); - turn.plan = { - {"explanation", memberValue(data, "explanation")}, - {"steps", memberValue(data, "steps", nlohmann::json::array())}}; - return; - } - if (type == "conversation.item.upsert") { - const std::string turnId = stringValue(scope, "turnId"); - if (data.contains("item")) { - nlohmann::json minimalTurn{{"id", turnId}}; - TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); - upsertItem(thread, turn, data["item"], true); - } - return; - } - if (type == "agents.activity.upsert") { - upsertAgentActivity( - thread, scope, - memberValue(data, "activity", nlohmann::json::object())); - return; - } - if (type == "conversation.reasoning.part-added") { - if (ItemPresentation *item = findItem(scope)) { - const auto index = data.find("summaryIndex"); - if (index != data.end() && index->is_number_integer() && - index->get() >= 0 && - index->get() < MaximumIndexedTextParts) { - nlohmann::json &parts = item->raw["summary"]; - if (!parts.is_array()) - parts = nlohmann::json::array(); - while (parts.size() <= index->get()) - parts.push_back(""); - } - } - return; - } - if (type == "conversation.file-change.output-appended") { - if (ItemPresentation *item = findItem(scope)) { - nlohmann::json delta{{"delta", stringValue(data, "delta")}}; - appendText(*item, "output", delta); - } - return; - } - if (type == "conversation.file-change.patch-replaced") { - if (ItemPresentation *item = findItem(scope)) { - item->raw["changes"] = - memberValue(data, "changes", nlohmann::json::array()); - retainRepositoryHints(thread, item->raw); - } - return; - } - if (type == "conversation.mcp.progress") { - if (ItemPresentation *item = findItem(scope)) { - nlohmann::json &progress = item->raw["progress"]; - if (!progress.is_array()) - progress = nlohmann::json::array(); - if (progress.size() < MaximumIndexedTextParts) - progress.push_back(stringValue(data, "message")); - } - return; - } - if (type != "conversation.item.append") - return; - - nlohmann::json identity = scope; - identity["delta"] = stringValue(data, "text"); - ItemPresentation *item = findItem(identity); - if (!item) - return; - const std::string field = stringValue(data, "field"); - if (field == "summary") - appendIndexedText(*item, "summary", data, "summaryIndex"); - else if (field == "content") - appendIndexedText(*item, "content", data, "contentIndex"); - else if (!field.empty()) - appendText(*item, field.c_str(), identity); - if (stringValue(item->raw, "type") == "agentMessage") - updateOwningAgentResult(threadId, stringValue(item->raw, "text")); -} - -const std::vector & -PresentationModel::threadOrder() const noexcept { - return orderedThreads; -} - -const ThreadPresentation * -PresentationModel::thread(const std::string &threadId) const noexcept { - const auto iterator = threads.find(threadId); - return iterator == threads.end() ? nullptr : &iterator->second; -} - -const ChildThreadOwnership *PresentationModel::childOwnership( - const std::string &childThreadId) const noexcept { - const auto iterator = childOwnerships.find(childThreadId); - return iterator == childOwnerships.end() ? nullptr : &iterator->second; -} - -std::optional -PresentationModel::activeTurnId(const std::string &threadId) const { - const ThreadPresentation *value = thread(threadId); - if (!value) - return std::nullopt; - if (!value->status.empty() && !isActiveStatus(value->status)) - return std::nullopt; - for (auto iterator = value->turnOrder.rbegin(); - iterator != value->turnOrder.rend(); ++iterator) { - const auto turn = value->turns.find(*iterator); - if (turn != value->turns.end() && isActiveStatus(turn->second.status)) - return turn->first; - } - return std::nullopt; -} - -std::size_t PresentationModel::pendingRequestCount() const noexcept { - return pendingRequests.size(); -} - -const ConnectionPresentation &PresentationModel::connection() const noexcept { - return connectionState; -} - -const nlohmann::json &PresentationModel::modelCatalog() const noexcept { - return models; -} - -const std::unordered_map & -PresentationModel::globalDomains() const noexcept { - return retainedGlobalDomains; -} - -const std::vector & -PresentationModel::telemetry() const noexcept { - return retainedTelemetry; -} - -const std::unordered_map & -PresentationModel::pendingRequestPresentations() const noexcept { - return pendingRequests; -} - -void PresentationModel::mergeThreadList(const nlohmann::json &listedThreads) { - if (!listedThreads.is_array()) - return; - - std::unordered_set listedIds; - listedIds.reserve(listedThreads.size()); - std::vector nextOrder; - nextOrder.reserve(listedThreads.size() + orderedThreads.size()); - for (const auto &raw : listedThreads) { - const std::string id = stringValue(raw, "id"); - if (id.empty()) - continue; - upsertThread(raw, false, false); - if (!childOwnerships.contains(id) && listedIds.insert(id).second) - nextOrder.push_back(id); - } - - for (const std::string &id : orderedThreads) { - if (!listedIds.contains(id)) - nextOrder.push_back(id); - } - orderedThreads = std::move(nextOrder); -} - -ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, - bool replaceTurns, - bool prependNewThread) { - const std::string id = stringValue(raw, "id"); - if (id.empty()) { - static ThreadPresentation ignored; - return ignored; - } - auto [iterator, inserted] = threads.try_emplace(id); - ThreadPresentation &result = iterator->second; - if (inserted) - result.id = id; - const std::string previousThreadStatus = result.status; - std::unordered_map terminalTurnStatuses; - if (replaceTurns) { - for (const auto &[turnId, turn] : result.turns) { - if (isTerminalTurnStatus(turn.status)) - terminalTurnStatuses.emplace(turnId, turn.status); - } - } - nlohmann::json threadFields = raw; - threadFields.erase("turns"); - if (replaceTurns) - result.raw = std::move(threadFields); - else - mergePreservingCompleteness(result.raw, threadFields); - const std::string name = stringValue(raw, "name"); - const std::string preview = stringValue(raw, "preview"); - if (!name.empty()) - result.title = name; - else if (!preview.empty()) - result.title = preview.substr(0, 80); - else if (result.title.empty()) - result.title = id.empty() ? "Untitled thread" : id.substr(0, 12); - if (!preview.empty()) - result.preview = preview; - const std::string cwd = stringValue(raw, "cwd"); - if (!cwd.empty()) - result.cwd = cwd; - const auto status = raw.find("status"); - if (status != raw.end()) - result.status = statusValue(*status); - updateTimestamp(raw, "createdAt", result.createdAt); - retainTimestamp(raw, "updatedAt", result.updatedAt); - retainTimestamp(raw, "recencyAt", result.recencyAt); - if (result.updatedAt) - retainActivity(result, *result.updatedAt); - if (result.recencyAt) - retainActivity(result, *result.recencyAt); - result.archived = boolValue(raw, "archived", result.archived); - - if (raw.contains("parentThreadId")) { - const std::string parentThreadId = stringValue(raw, "parentThreadId"); - if (!parentThreadId.empty()) - retainStructuralOwnership(id, parentThreadId); - else { - const auto ownership = childOwnerships.find(id); - if (ownership != childOwnerships.end() && - ownership->second.agentId.empty()) - releaseChildOwnership(id, false); - } - } - if (prependNewThread && !childOwnerships.contains(id) && - std::find(orderedThreads.begin(), orderedThreads.end(), id) == - orderedThreads.end()) - orderedThreads.insert(orderedThreads.begin(), id); - - const auto turns = raw.find("turns"); - if (turns != raw.end() && turns->is_array()) { - std::vector previouslyOwnedChildren; - if (replaceTurns) { - for (const std::string &childThreadId : result.childThreadOrder) { - const auto ownership = childOwnerships.find(childThreadId); - if (ownership != childOwnerships.end() && - !ownership->second.agentId.empty()) - previouslyOwnedChildren.push_back(childThreadId); - } - for (const std::string &childThreadId : previouslyOwnedChildren) - releaseChildOwnership(childThreadId, false); - result.turnOrder.clear(); - result.turns.clear(); - result.agentOrder.clear(); - result.agents.clear(); - result.commandCwds.clear(); - result.changedPaths.clear(); - } - for (const auto &turn : *turns) - upsertTurn(result, turn, replaceTurns); - if (replaceTurns) { - for (const std::string &childThreadId : previouslyOwnedChildren) { - if (!childOwnerships.contains(childThreadId) && - threads.contains(childThreadId) && - std::find(orderedThreads.begin(), orderedThreads.end(), - childThreadId) == orderedThreads.end()) - orderedThreads.push_back(childThreadId); - } - for (const auto &[turnId, terminalStatus] : terminalTurnStatuses) { - const auto turn = result.turns.find(turnId); - if (turn != result.turns.end() && isActiveStatus(turn->second.status)) { - turn->second.status = terminalStatus; - turn->second.raw["status"] = terminalStatus; - } - } - } - const bool containsActiveTurn = - std::ranges::any_of(result.turns, [](const auto &entry) { - return isActiveStatus(entry.second.status); - }); - if (!containsActiveTurn && isActiveStatus(result.status) && - classifyStatus(previousThreadStatus).kind == StatusKind::Completed) { - result.status = previousThreadStatus; - result.raw["status"] = previousThreadStatus; - } - } - if (turns != raw.end() && turns->is_array()) - synchronizeOwningAgent(id, replaceTurns); - else if (status != raw.end() && result.status != "notLoaded") - updateOwningAgentStatus(id, result.status); - return result; -} - -TurnPresentation &PresentationModel::upsertTurn(ThreadPresentation &thread, - const nlohmann::json &raw, - bool replaceItems) { - const std::string id = stringValue(raw, "id"); - if (id.empty()) { - static TurnPresentation ignored; - return ignored; - } - auto [iterator, inserted] = thread.turns.try_emplace(id); - TurnPresentation &result = iterator->second; - if (inserted) { - result.id = id; - thread.turnOrder.push_back(id); - } - nlohmann::json turnFields = raw; - turnFields.erase("items"); - if (replaceItems) - result.raw = std::move(turnFields); - else - mergePreservingCompleteness(result.raw, turnFields); - const std::string status = statusValue(memberValue(raw, "status")); - if (!status.empty() && - !(isTerminalTurnStatus(result.status) && isActiveStatus(status))) - result.status = status; - if (isTerminalTurnStatus(result.status) && isActiveStatus(status)) - result.raw["status"] = result.status; - const auto items = raw.find("items"); - if (items != raw.end() && items->is_array()) { - if (replaceItems) { - result.itemOrder.clear(); - result.items.clear(); - } - for (const auto &item : *items) - upsertItem(thread, result, item); - } - updateOwningAgentStatus(thread.id, result.status); - return result; -} - -ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, - TurnPresentation &turn, - const nlohmann::json &raw, - bool live) { - const std::string id = stringValue(raw, "id"); - if (id.empty()) { - static ItemPresentation ignored; - return ignored; - } - const nlohmann::json scope{{"threadId", thread.id}, - {"turnId", turn.id}, - {"itemId", id}}; - const std::string incomingType = stringValue(raw, "type"); - if ((incomingType == "subAgentActivity" || - incomingType == "collabAgentToolCall") && - isStaleAgentReplay(thread, scope, raw, live)) { - const auto existing = turn.items.find(id); - if (existing != turn.items.end()) - return existing->second; - static ItemPresentation ignored; - return ignored; - } - auto [iterator, inserted] = turn.items.try_emplace(id); - ItemPresentation &result = iterator->second; - resetIncomingTextBounds(result, raw); - if (inserted) { - result.id = id; - result.raw = raw; - turn.itemOrder.push_back(id); - } else { - mergePreservingCompleteness(result.raw, raw); - } - boundRetainedItemText(result); - const std::string type = stringValue(result.raw, "type"); - retainRepositoryHints(thread, result.raw); - if (type == "subAgentActivity" || type == "collabAgentToolCall") { - upsertAgentActivity(thread, scope, result.raw, live); - } - if (type == "agentMessage") - updateOwningAgentResult(thread.id, stringValue(result.raw, "text")); - return result; -} - -void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, - const nlohmann::json &scope, - const nlohmann::json &activity, - bool live) { - const std::string type = stringValue(activity, "type"); - if (type == "subAgentActivity" && !isSpawnActivity(activity)) { - const std::string childThreadId = childThreadIdentity(activity); - AgentPresentation *existing = owningAgent(childThreadId); - if (!existing) - return; - const std::string agentPath = stringValue(activity, "agentPath"); - if (!agentPath.empty()) - existing->raw["agentPath"] = agentPath; - if (stringValue(activity, "kind") == "interrupted") - updateOwningAgentStatus(childThreadId, "interrupted"); - return; - } - if (type == "collabAgentToolCall" && !isSpawnActivity(activity)) { - const nlohmann::json states = - memberValue(activity, "agentsStates", nlohmann::json::object()); - if (!states.is_object()) - return; - for (const auto &[childThreadId, state] : states.items()) { - AgentPresentation *existing = owningAgent(childThreadId); - if (!existing || !state.is_object()) - continue; - const std::string status = stringValue(state, "status"); - const std::string message = stringValue(state, "message"); - if (!status.empty()) - updateOwningAgentStatus(childThreadId, status); - if (!message.empty()) - updateOwningAgentResult(childThreadId, message); - existing->raw["agentState"] = state; - } - return; - } - - const std::string childThreadId = childThreadIdentity(activity); - if (type == "collabAgentToolCall" && childThreadId.empty()) - return; - - const std::string id = agentIdentity(activity, scope); - if (id.empty()) - return; - if (isStaleAgentReplay(owner, scope, activity, live)) - return; - - auto [iterator, inserted] = owner.agents.try_emplace(id); - AgentPresentation &agent = iterator->second; - if (inserted) { - agent.id = id; - owner.agentOrder.push_back(id); - } - const bool changesChild = !childThreadId.empty() && - !agent.childThreadId.empty() && - agent.childThreadId != childThreadId; - agent.itemId = stringValue(scope, "itemId"); - agent.ownerTurnId = stringValue(scope, "turnId"); - mergePreservingCompleteness(agent.raw, activity); - - if (changesChild) { - agent.status.clear(); - agent.raw.erase("status"); - if (ItemPresentation *item = agentSourceItem(owner, agent)) - item->raw.erase("status"); - clearAgentResult(owner, agent); - agent.raw.erase("agentState"); - } - - const std::string activityStatus = stringValue(activity, "status"); - const std::string activityKind = stringValue(activity, "kind"); - std::string candidateStatus = activityStatus; - if (candidateStatus.empty() && live && activityKind == "started") - candidateStatus = "inProgress"; - else if (candidateStatus.empty() && !activityKind.empty()) - candidateStatus = activityKind; - if (!candidateStatus.empty()) { - if (isTerminalTurnStatus(agent.status) && - isActiveStatus(candidateStatus)) - setAgentStatus(owner, agent, agent.status); - else - setAgentStatus(owner, agent, candidateStatus); - } - - if (!childThreadId.empty()) - assignChildOwnership(owner, agent, childThreadId, live); -} - -void PresentationModel::assignChildOwnership(ThreadPresentation &parent, - AgentPresentation &agent, - const std::string &childThreadId, - bool live) { - if (childThreadId == parent.id) - return; - std::string ancestorId = parent.id; - std::unordered_set visited; - while (visited.insert(ancestorId).second) { - const auto ancestor = childOwnerships.find(ancestorId); - if (ancestor == childOwnerships.end()) - break; - ancestorId = ancestor->second.parentThreadId; - if (ancestorId == childThreadId) - return; - } - if (!agent.childThreadId.empty() && agent.childThreadId != childThreadId) { - const auto previous = childOwnerships.find(agent.childThreadId); - if (previous != childOwnerships.end() && - previous->second.parentThreadId == parent.id && - previous->second.agentId == agent.id) - releaseChildOwnership(agent.childThreadId, true); - } - - const auto previous = childOwnerships.find(childThreadId); - if (previous != childOwnerships.end() && - (previous->second.parentThreadId != parent.id || - previous->second.agentId != agent.id)) { - const auto previousParent = threads.find(previous->second.parentThreadId); - // A child read contains inherited ancestor and sibling activity. Those - // replayed items are historical context, not a new ownership authority. - // Replace/removal releases a vanished owner before reconstruction, while - // live activity may still authoritatively rebind an existing child. - if (!live && previousParent != threads.end()) { - const auto previousAgent = - previousParent->second.agents.find(previous->second.agentId); - if (previousAgent != previousParent->second.agents.end() && - previousAgent->second.childThreadId == childThreadId) - return; - } - releaseChildOwnership(childThreadId, false); - } - - agent.childThreadId = childThreadId; - agent.raw["childThreadId"] = childThreadId; - childOwnerships[childThreadId] = {parent.id, agent.id}; - if (std::find(parent.childThreadOrder.begin(), parent.childThreadOrder.end(), - childThreadId) == parent.childThreadOrder.end()) - parent.childThreadOrder.push_back(childThreadId); - - auto [child, inserted] = threads.try_emplace(childThreadId); - if (inserted) - child->second.id = childThreadId; - std::erase(orderedThreads, childThreadId); - synchronizeOwningAgent(childThreadId); -} - -void PresentationModel::retainStructuralOwnership( - const std::string &childThreadId, const std::string &parentThreadId) { - if (childThreadId.empty() || parentThreadId.empty() || - childThreadId == parentThreadId) - return; - const auto existing = childOwnerships.find(childThreadId); - if (existing != childOwnerships.end() && - existing->second.parentThreadId == parentThreadId) - return; - if (existing != childOwnerships.end()) - releaseChildOwnership(childThreadId, false); - - auto [parent, parentInserted] = threads.try_emplace(parentThreadId); - if (parentInserted) - parent->second.id = parentThreadId; - auto [child, childInserted] = threads.try_emplace(childThreadId); - if (childInserted) - child->second.id = childThreadId; - childOwnerships[childThreadId] = {parentThreadId, {}}; - if (std::find(parent->second.childThreadOrder.begin(), - parent->second.childThreadOrder.end(), childThreadId) == - parent->second.childThreadOrder.end()) - parent->second.childThreadOrder.push_back(childThreadId); - std::erase(orderedThreads, childThreadId); -} - -void PresentationModel::releaseChildOwnership(const std::string &childThreadId, - bool promoteToRoot) { - const std::string releasedChildId = childThreadId; - const auto ownership = childOwnerships.find(releasedChildId); - if (ownership == childOwnerships.end()) - return; - const ChildThreadOwnership previous = ownership->second; - const auto parent = threads.find(previous.parentThreadId); - if (parent != threads.end()) { - std::erase(parent->second.childThreadOrder, releasedChildId); - const auto agent = parent->second.agents.find(previous.agentId); - if (agent != parent->second.agents.end() && - agent->second.childThreadId == releasedChildId) { - agent->second.childThreadId.clear(); - agent->second.raw.erase("childThreadId"); - } - } - childOwnerships.erase(ownership); - if (promoteToRoot && threads.contains(releasedChildId) && - std::find(orderedThreads.begin(), orderedThreads.end(), releasedChildId) == - orderedThreads.end()) - orderedThreads.push_back(releasedChildId); -} - -AgentPresentation * -PresentationModel::owningAgent(const std::string &childThreadId) { - const auto ownership = childOwnerships.find(childThreadId); - if (ownership == childOwnerships.end()) - return nullptr; - const auto parent = threads.find(ownership->second.parentThreadId); - if (parent == threads.end()) - return nullptr; - const auto agent = parent->second.agents.find(ownership->second.agentId); - return agent == parent->second.agents.end() ? nullptr : &agent->second; -} - -ItemPresentation * -PresentationModel::agentSourceItem(ThreadPresentation &parent, - const AgentPresentation &agent) { - const auto turn = parent.turns.find(agent.ownerTurnId); - if (turn == parent.turns.end()) - return nullptr; - const auto item = turn->second.items.find(agent.itemId); - return item == turn->second.items.end() ? nullptr : &item->second; -} - -void PresentationModel::setAgentStatus(ThreadPresentation &parent, - AgentPresentation &agent, - const std::string &status) { - agent.status = status; - agent.raw["status"] = status; - if (ItemPresentation *item = agentSourceItem(parent, agent)) - item->raw["status"] = status; -} - -void PresentationModel::setAgentResult(ThreadPresentation &parent, - AgentPresentation &agent, - const std::string &resultText) { - agent.raw["resultText"] = resultText; - if (ItemPresentation *item = agentSourceItem(parent, agent)) - item->raw["resultText"] = resultText; -} - -void PresentationModel::clearAgentResult(ThreadPresentation &parent, - AgentPresentation &agent) { - agent.raw.erase("resultText"); - if (ItemPresentation *item = agentSourceItem(parent, agent)) - item->raw.erase("resultText"); -} - -void PresentationModel::updateOwningAgentStatus( - const std::string &childThreadId, const std::string &status) { - if (status.empty()) - return; - const auto ownership = childOwnerships.find(childThreadId); - if (ownership == childOwnerships.end()) - return; - const auto parent = threads.find(ownership->second.parentThreadId); - if (parent == threads.end()) - return; - const auto agent = parent->second.agents.find(ownership->second.agentId); - if (agent == parent->second.agents.end()) - return; - if (isTerminalTurnStatus(agent->second.status) && isActiveStatus(status)) { - setAgentStatus(parent->second, agent->second, agent->second.status); - return; - } - setAgentStatus(parent->second, agent->second, status); -} - -void PresentationModel::updateOwningAgentResult( - const std::string &childThreadId, const std::string &resultText) { - if (resultText.empty()) - return; - const auto ownership = childOwnerships.find(childThreadId); - if (ownership == childOwnerships.end()) - return; - const auto parent = threads.find(ownership->second.parentThreadId); - if (parent == threads.end()) - return; - const auto agent = parent->second.agents.find(ownership->second.agentId); - if (agent != parent->second.agents.end()) - setAgentResult(parent->second, agent->second, resultText); -} - -void PresentationModel::synchronizeOwningAgent( - const std::string &childThreadId, bool clearMissingResult) { - AgentPresentation *agent = owningAgent(childThreadId); - const auto child = threads.find(childThreadId); - if (!agent || child == threads.end()) - return; - - // A thread-level lifecycle is authoritative. Turn status is only a fallback - // for incremental payloads that do not carry the thread lifecycle; an old - // or interrupted turn must not make an idle child appear active. - std::string childStatus = - child->second.status == "notLoaded" ? std::string{} - : child->second.status; - std::string resultText; - for (auto turnId = child->second.turnOrder.rbegin(); - turnId != child->second.turnOrder.rend(); ++turnId) { - const auto turn = child->second.turns.find(*turnId); - if (turn == child->second.turns.end()) - continue; - if (childStatus.empty() && !turn->second.status.empty()) - childStatus = turn->second.status; - for (auto itemId = turn->second.itemOrder.rbegin(); - itemId != turn->second.itemOrder.rend(); ++itemId) { - const auto item = turn->second.items.find(*itemId); - if (item == turn->second.items.end() || - stringValue(item->second.raw, "type") != "agentMessage") - continue; - resultText = stringValue(item->second.raw, "text"); - if (!resultText.empty()) - break; - } - if (!resultText.empty() && !childStatus.empty()) - break; - } - updateOwningAgentStatus(childThreadId, childStatus); - const auto ownership = childOwnerships.find(childThreadId); - const auto parent = ownership == childOwnerships.end() - ? threads.end() - : threads.find(ownership->second.parentThreadId); - if (parent == threads.end()) - return; - if (resultText.empty() && clearMissingResult) - clearAgentResult(parent->second, *agent); - else if (!resultText.empty()) - setAgentResult(parent->second, *agent, resultText); -} - -void PresentationModel::removeThread(const std::string &threadId) { - const auto thread = threads.find(threadId); - if (thread == threads.end()) - return; - const std::vector children = thread->second.childThreadOrder; - const auto root = std::find(orderedThreads.begin(), orderedThreads.end(), - threadId); - const std::size_t rootIndex = - root == orderedThreads.end() - ? orderedThreads.size() - : static_cast(std::distance(orderedThreads.begin(), root)); - for (const std::string &childThreadId : children) - releaseChildOwnership(childThreadId, false); - releaseChildOwnership(threadId, false); - threads.erase(thread); - std::erase(orderedThreads, threadId); - std::size_t insertion = std::min(rootIndex, orderedThreads.size()); - for (const std::string &childThreadId : children) { - if (!threads.contains(childThreadId) || - childOwnerships.contains(childThreadId)) - continue; - orderedThreads.insert(orderedThreads.begin() + - static_cast(insertion), - childThreadId); - ++insertion; - } -} - -void PresentationModel::clearProviderState() { - orderedThreads.clear(); - threads.clear(); - childOwnerships.clear(); - pendingRequests.clear(); - models = nlohmann::json::array(); - retainedGlobalDomains.clear(); -} - -void PresentationModel::retainDomainEvent(const std::string &type, - const nlohmann::json &data, - const nlohmann::json &scope, - const std::string &authority) { - const std::string threadId = stringValue(scope, "threadId"); - const std::string turnId = stringValue(scope, "turnId"); - const std::string itemId = stringValue(scope, "itemId"); - if (!itemId.empty()) { - if (ItemPresentation *item = findItem(scope)) - applyDomainAuthority(item->domains, type, data, authority); - return; - } - if (!turnId.empty()) { - if (TurnPresentation *turn = findTurn(threadId, turnId)) - applyDomainAuthority(turn->domains, type, data, authority); - return; - } - if (!threadId.empty()) { - const auto thread = threads.find(threadId); - if (thread != threads.end()) - applyDomainAuthority(thread->second.domains, type, data, authority); - return; - } - applyDomainAuthority(retainedGlobalDomains, type, data, authority); -} - -TurnPresentation *PresentationModel::findTurn(const std::string &threadId, - const std::string &turnId) { - auto thread = threads.find(threadId); - if (thread == threads.end()) - return nullptr; - auto turn = thread->second.turns.find(turnId); - return turn == thread->second.turns.end() ? nullptr : &turn->second; -} - -ItemPresentation *PresentationModel::findItem(const nlohmann::json ¶ms) { - TurnPresentation *turn = - findTurn(stringValue(params, "threadId"), stringValue(params, "turnId")); - if (!turn) - return nullptr; - const std::string itemId = stringValue(params, "itemId"); - auto item = turn->items.find(itemId); - return item == turn->items.end() ? nullptr : &item->second; -} - -} // namespace codexui::codex diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h deleted file mode 100644 index 402aa77..0000000 --- a/src/codex/PresentationModel.h +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_PRESENTATIONMODEL_H -#define CODEXUI_CODEX_PRESENTATIONMODEL_H - -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui::codex { - -struct TextRetentionPresentation { - std::string field; - std::size_t retainedBytes = 0; - std::uint64_t discardedBytes = 0; -}; - -struct ItemPresentation { - std::string id; - nlohmann::json raw = nlohmann::json::object(); - std::unordered_map domains; - std::vector textRetention; -}; - -struct TurnPresentation { - std::string id; - std::string status; - std::vector itemOrder; - std::unordered_map items; - nlohmann::json plan = nlohmann::json::object(); - nlohmann::json raw = nlohmann::json::object(); - std::unordered_map domains; -}; - -struct AgentPresentation { - std::string id; - std::string itemId; - std::string ownerTurnId; - std::string childThreadId; - std::string status; - nlohmann::json raw = nlohmann::json::object(); -}; - -struct ChildThreadOwnership { - std::string parentThreadId; - std::string agentId; - - bool operator==(const ChildThreadOwnership &) const = default; -}; - -struct ThreadPresentation { - std::string id; - std::string title; - std::string preview; - std::string cwd; - std::string status; - std::optional createdAt; - std::optional updatedAt; - std::optional recencyAt; - std::optional lastActivityAt; - std::vector commandCwds; - std::vector changedPaths; - std::vector turnOrder; - std::unordered_map turns; - nlohmann::json raw = nlohmann::json::object(); - std::unordered_map domains; - nlohmann::json latestSettingsUpdate = nlohmann::json::object(); - std::uint64_t settingsRevision = 0; - std::vector agentOrder; - std::unordered_map agents; - std::vector childThreadOrder; - bool archived = false; -}; - -struct PendingRequestPresentation { - std::string id; - std::string kind; - std::string threadId; - std::uint64_t generation = 0; - nlohmann::json raw; -}; - -struct ConnectionPresentation { - bool connected = false; - bool retrying = false; - std::uint64_t generation = 0; - std::string connectionId; - std::string role; - std::string controllerConnectionId; - std::string detail; - std::uint64_t providerGeneration = 0; - std::string providerState; - std::string providerDetail; - nlohmann::json settings = nlohmann::json::object(); -}; - -struct TelemetryPresentation { - std::uint64_t sequence = 0; - std::uint64_t generation = 0; - std::string type; - nlohmann::json data = nlohmann::json::object(); - nlohmann::json scope = nlohmann::json::object(); -}; - -class PresentationModel final { -public: - void applyEvent(const nlohmann::json &event) noexcept; - void noteThreadActivity(const std::string &threadId, - std::int64_t timestamp) noexcept; - void notePromptActivity(const std::string &threadId, - std::int64_t timestamp) noexcept; - - [[nodiscard]] const std::vector &threadOrder() const noexcept; - [[nodiscard]] const ThreadPresentation * - thread(const std::string &threadId) const noexcept; - [[nodiscard]] const ChildThreadOwnership * - childOwnership(const std::string &childThreadId) const noexcept; - [[nodiscard]] std::optional - activeTurnId(const std::string &threadId) const; - [[nodiscard]] std::size_t pendingRequestCount() const noexcept; - [[nodiscard]] const ConnectionPresentation &connection() const noexcept; - [[nodiscard]] const nlohmann::json &modelCatalog() const noexcept; - [[nodiscard]] const std::unordered_map & - globalDomains() const noexcept; - [[nodiscard]] const std::vector & - telemetry() const noexcept; - [[nodiscard]] const std::unordered_map & - pendingRequestPresentations() const noexcept; - -private: - void applyValidatedEvent(const nlohmann::json &event); - void mergeThreadList(const nlohmann::json &listedThreads); - ThreadPresentation &upsertThread(const nlohmann::json &raw, - bool replaceTurns, - bool prependNewThread = true); - TurnPresentation &upsertTurn(ThreadPresentation &thread, - const nlohmann::json &raw, bool replaceItems); - ItemPresentation &upsertItem(ThreadPresentation &thread, - TurnPresentation &turn, - const nlohmann::json &raw, bool live = false); - void upsertAgentActivity(ThreadPresentation &owner, - const nlohmann::json &scope, - const nlohmann::json &activity, bool live = true); - void assignChildOwnership(ThreadPresentation &parent, - AgentPresentation &agent, - const std::string &childThreadId, bool live); - void retainStructuralOwnership(const std::string &childThreadId, - const std::string &parentThreadId); - void releaseChildOwnership(const std::string &childThreadId, - bool promoteToRoot); - void synchronizeOwningAgent(const std::string &childThreadId, - bool clearMissingResult = false); - AgentPresentation *owningAgent(const std::string &childThreadId); - ItemPresentation *agentSourceItem(ThreadPresentation &parent, - const AgentPresentation &agent); - void setAgentStatus(ThreadPresentation &parent, AgentPresentation &agent, - const std::string &status); - void setAgentResult(ThreadPresentation &parent, AgentPresentation &agent, - const std::string &resultText); - void clearAgentResult(ThreadPresentation &parent, AgentPresentation &agent); - void updateOwningAgentStatus(const std::string &childThreadId, - const std::string &status); - void updateOwningAgentResult(const std::string &childThreadId, - const std::string &resultText); - void removeThread(const std::string &threadId); - void clearProviderState(); - void retainDomainEvent(const std::string &type, const nlohmann::json &data, - const nlohmann::json &scope, - const std::string &authority); - TurnPresentation *findTurn(const std::string &threadId, - const std::string &turnId); - ItemPresentation *findItem(const nlohmann::json ¶ms); - - std::vector orderedThreads; - std::unordered_map threads; - std::unordered_map childOwnerships; - std::unordered_map pendingRequests; - ConnectionPresentation connectionState; - nlohmann::json models = nlohmann::json::array(); - std::unordered_map retainedGlobalDomains; - std::vector retainedTelemetry; - std::uint64_t lastSequence = 0; -}; - -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_PRESENTATIONMODEL_H diff --git a/src/codex/PresentationProtocol.cpp b/src/codex/PresentationProtocol.cpp deleted file mode 100644 index 9e0c21a..0000000 --- a/src/codex/PresentationProtocol.cpp +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/PresentationProtocol.h" - -#include - -namespace codexui::codex::presentation { -namespace { - -nlohmann::json baseFrame(std::string kind) { - return {{"protocol", ProtocolName}, - {"version", ProtocolVersion}, - {"kind", std::move(kind)}}; -} - -void addAuthorityAndScope(nlohmann::json &frame, Authority authority, - nlohmann::json scope) { - frame["authority"] = authorityName(authority); - if (scope.is_object() && !scope.empty()) - frame["scope"] = std::move(scope); -} - -} // namespace - -std::string_view authorityName(Authority authority) noexcept { - switch (authority) { - case Authority::Merge: - return "merge"; - case Authority::Replace: - return "replace"; - case Authority::Remove: - return "remove"; - case Authority::None: - return "none"; - } - return "none"; -} - -nlohmann::json command(std::string action, nlohmann::json data, - std::string correlationId) { - nlohmann::json frame = baseFrame("command"); - frame["action"] = std::move(action); - frame["data"] = std::move(data); - if (!correlationId.empty()) - frame["correlationId"] = std::move(correlationId); - return frame; -} - -nlohmann::json result(std::uint64_t sequence, std::uint64_t generation, - std::string action, std::string correlationId, bool ok, - nlohmann::json data, Authority authority, - nlohmann::json scope) { - nlohmann::json frame = baseFrame("result"); - frame["sequence"] = sequence; - frame["generation"] = generation; - frame["action"] = std::move(action); - frame["correlationId"] = std::move(correlationId); - frame["ok"] = ok; - frame[ok ? "data" : "error"] = std::move(data); - addAuthorityAndScope(frame, authority, std::move(scope)); - return frame; -} - -nlohmann::json event(std::uint64_t sequence, std::uint64_t generation, - std::string type, nlohmann::json data, Authority authority, - nlohmann::json scope) { - nlohmann::json frame = baseFrame("event"); - frame["sequence"] = sequence; - frame["generation"] = generation; - frame["type"] = std::move(type); - frame["data"] = std::move(data); - addAuthorityAndScope(frame, authority, std::move(scope)); - return frame; -} - -bool isPresentationFrame(const nlohmann::json &value) noexcept { - if (!value.is_object()) - return false; - const auto protocol = value.find("protocol"); - if (protocol == value.end() || !protocol->is_string() || - protocol->get_ref() != ProtocolName) - return false; - const auto version = value.find("version"); - if (version == value.end() || !version->is_number_unsigned() || - version->get_ref() != - ProtocolVersion) - return false; - const auto kind = value.find("kind"); - if (kind == value.end() || !kind->is_string()) - return false; - const std::string &kindValue = - kind->get_ref(); - const auto stringField = [&value](const char *name) { - const auto member = value.find(name); - return member != value.end() && member->is_string() && - !member->get_ref().empty(); - }; - if (kindValue == "command") { - const auto data = value.find("data"); - return stringField("action") && data != value.end() && data->is_object(); - } - if (kindValue != "event" && kindValue != "result") - return false; - const auto sequence = value.find("sequence"); - const auto generation = value.find("generation"); - const auto authority = value.find("authority"); - if (sequence == value.end() || !sequence->is_number_unsigned() || - generation == value.end() || !generation->is_number_unsigned() || - authority == value.end() || !authority->is_string()) - return false; - const std::string &authorityValue = - authority->get_ref(); - if (authorityValue != "none" && authorityValue != "merge" && - authorityValue != "replace" && authorityValue != "remove") - return false; - const auto scope = value.find("scope"); - if (scope != value.end() && !scope->is_object()) - return false; - if (kindValue == "event") { - const auto data = value.find("data"); - return stringField("type") && data != value.end() && data->is_object(); - } - const auto ok = value.find("ok"); - if (!stringField("action") || !stringField("correlationId") || - ok == value.end() || !ok->is_boolean()) - return false; - return ok->get_ref() - ? value.contains("data") && !value.contains("error") - : value.contains("error") && !value.contains("data"); -} - -std::string stringMember(const nlohmann::json &value, const char *name) { - if (!value.is_object()) - return {}; - const auto iterator = value.find(name); - return iterator != value.end() && iterator->is_string() - ? iterator->get() - : std::string{}; -} - -nlohmann::json member(const nlohmann::json &value, const char *name, - nlohmann::json fallback) { - if (!value.is_object()) - return fallback; - const auto iterator = value.find(name); - return iterator == value.end() ? std::move(fallback) : *iterator; -} - -} // namespace codexui::codex::presentation diff --git a/src/codex/PresentationProtocol.h b/src/codex/PresentationProtocol.h deleted file mode 100644 index 2fd80cb..0000000 --- a/src/codex/PresentationProtocol.h +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_PRESENTATIONPROTOCOL_H -#define CODEXUI_CODEX_PRESENTATIONPROTOCOL_H - -#include - -#include -#include -#include - -namespace codexui::codex::presentation { - -inline constexpr std::string_view ProtocolName = "codexui.presentation"; -inline constexpr std::uint32_t ProtocolVersion = 1; - -[[nodiscard]] inline constexpr bool -isThreadHydrationAction(std::string_view action) noexcept { - return action == "thread.read" || action == "thread.resume"; -} - -enum class Authority { - None, - Merge, - Replace, - Remove, -}; - -[[nodiscard]] std::string_view authorityName(Authority authority) noexcept; - -[[nodiscard]] nlohmann::json -command(std::string action, nlohmann::json data = nlohmann::json::object(), - std::string correlationId = {}); - -[[nodiscard]] nlohmann::json -result(std::uint64_t sequence, std::uint64_t generation, std::string action, - std::string correlationId, bool ok, nlohmann::json data, - Authority authority = Authority::None, - nlohmann::json scope = nlohmann::json::object()); - -[[nodiscard]] nlohmann::json -event(std::uint64_t sequence, std::uint64_t generation, std::string type, - nlohmann::json data = nlohmann::json::object(), - Authority authority = Authority::None, - nlohmann::json scope = nlohmann::json::object()); - -[[nodiscard]] bool isPresentationFrame(const nlohmann::json &value) noexcept; -[[nodiscard]] std::string stringMember(const nlohmann::json &value, - const char *name); -[[nodiscard]] nlohmann::json member(const nlohmann::json &value, - const char *name, - nlohmann::json fallback = nullptr); - -} // namespace codexui::codex::presentation - -#endif // CODEXUI_CODEX_PRESENTATIONPROTOCOL_H diff --git a/src/codex/ProtocolNormalizer.cpp b/src/codex/ProtocolNormalizer.cpp deleted file mode 100644 index 9e827bc..0000000 --- a/src/codex/ProtocolNormalizer.cpp +++ /dev/null @@ -1,541 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ProtocolNormalizer.h" - -#include -#include - -#include -#include -#include - -namespace codexui::codex { -namespace { - -using ai::openai::codex::protocol::JsonRpcKind; -using presentation::Authority; -using namespace std::string_view_literals; - -nlohmann::json errorValue(const nlohmann::json &response) { - const auto iterator = response.find("error"); - return iterator != response.end() - ? *iterator - : nlohmann::json{{"code", -32000}, - {"message", "operation failed"}}; -} - -nlohmann::json stableScope(const nlohmann::json &value) { - nlohmann::json scope = nlohmann::json::object(); - if (!value.is_object()) - return scope; - constexpr std::array keys{"threadId", "turnId", "itemId", "processId", - "requestId"}; - for (const char *key : keys) { - const auto iterator = value.find(key); - if (iterator != value.end() && !iterator->is_null()) - scope[key] = *iterator; - } - return scope; -} - -std::string requestKind(std::string_view method) { - if (method == "item/commandExecution/requestApproval") - return "command-approval"; - if (method == "item/fileChange/requestApproval") - return "file-change-approval"; - if (method == "item/tool/requestUserInput") - return "user-input"; - if (method == "mcpServer/elicitation/request") - return "mcp-elicitation"; - if (method == "item/permissions/requestApproval") - return "permissions-approval"; - if (method == "item/tool/call") - return "dynamic-tool-call"; - if (method == "account/chatgptAuthTokens/refresh") - return "authentication-refresh"; - if (method == "attestation/generate") - return "attestation"; - if (method == "applyPatchApproval") - return "legacy-patch-approval"; - if (method == "execCommandApproval") - return "legacy-command-approval"; - return "unsupported"; -} - -struct EventDescriptor { - std::string_view type; - Authority authority = Authority::None; -}; - -std::optional remainingNotification(std::string_view method) { - // Every generated notification without a richer reducer above has one stable - // presentation-domain event name. Native app-server method names stop here. - static constexpr std::array descriptors{ - std::pair{"thread/reverted"sv, - EventDescriptor{"thread.reverted", Authority::Merge}}, - std::pair{"skills/changed"sv, - EventDescriptor{"catalog.skills.invalidated", Authority::None}}, - std::pair{"thread/goal/updated"sv, - EventDescriptor{"thread.goal.changed", Authority::Replace}}, - std::pair{"thread/goal/cleared"sv, - EventDescriptor{"thread.goal.removed", Authority::Remove}}, - std::pair{"thread/queue/changed"sv, - EventDescriptor{"thread.queue.changed", Authority::Replace}}, - std::pair{"project/changed"sv, - EventDescriptor{"workspace.project.changed", Authority::Merge}}, - std::pair{"thread/project/updated"sv, - EventDescriptor{"thread.project.changed", Authority::Replace}}, - std::pair{ - "thread/environment/connected"sv, - EventDescriptor{"thread.environment.connected", Authority::Merge}}, - std::pair{ - "thread/environment/disconnected"sv, - EventDescriptor{"thread.environment.disconnected", Authority::Merge}}, - std::pair{"thread/settings/updated"sv, - EventDescriptor{"thread.settings.changed", Authority::Merge}}, - std::pair{"hook/started"sv, - EventDescriptor{"activity.hook.started", Authority::Merge}}, - std::pair{"hook/completed"sv, - EventDescriptor{"activity.hook.completed", Authority::Merge}}, - std::pair{"turn/diff/updated"sv, - EventDescriptor{"turn.diff.changed", Authority::Replace}}, - std::pair{"item/autoApprovalReview/started"sv, - EventDescriptor{"approval.review.started", Authority::Merge}}, - std::pair{"item/autoApprovalReview/completed"sv, - EventDescriptor{"approval.review.completed", Authority::Merge}}, - std::pair{ - "autoApprovalReview/strictReviewRequired"sv, - EventDescriptor{"approval.strict-review.required", Authority::Merge}}, - std::pair{"command/exec/outputDelta"sv, - EventDescriptor{"terminal.command.output-appended", - Authority::Merge}}, - std::pair{"process/outputDelta"sv, - EventDescriptor{"terminal.process.output-appended", - Authority::Merge}}, - std::pair{ - "process/exited"sv, - EventDescriptor{"terminal.process.completed", Authority::Merge}}, - std::pair{"item/commandExecution/terminalInteraction"sv, - EventDescriptor{"conversation.command.interaction", - Authority::Merge}}, - std::pair{"item/fileChange/outputDelta"sv, - EventDescriptor{"conversation.file-change.output-appended", - Authority::Merge}}, - std::pair{"item/fileChange/patchUpdated"sv, - EventDescriptor{"conversation.file-change.patch-replaced", - Authority::Replace}}, - std::pair{"item/mcpToolCall/progress"sv, - EventDescriptor{"conversation.mcp.progress", Authority::Merge}}, - std::pair{ - "mcpServer/oauthLogin/completed"sv, - EventDescriptor{"integration.mcp.login-completed", Authority::Merge}}, - std::pair{ - "mcpServer/startupStatus/updated"sv, - EventDescriptor{"integration.mcp.status-changed", Authority::Merge}}, - std::pair{"mcpServer/event/stream/notification"sv, - EventDescriptor{"integration.mcp.event", Authority::None}}, - std::pair{"app/list/updated"sv, - EventDescriptor{"catalog.apps.changed", Authority::Replace}}, - std::pair{"remoteControl/status/changed"sv, - EventDescriptor{"connection.remote-control.changed", - Authority::Replace}}, - std::pair{"externalAgentConfig/import/progress"sv, - EventDescriptor{"settings.external-agent-import.progress", - Authority::Merge}}, - std::pair{"externalAgentConfig/import/completed"sv, - EventDescriptor{"settings.external-agent-import.completed", - Authority::Merge}}, - std::pair{"fs/changed"sv, - EventDescriptor{"workspace.files.changed", Authority::Merge}}, - std::pair{"item/reasoning/summaryPartAdded"sv, - EventDescriptor{"conversation.reasoning.part-added", - Authority::Merge}}, - std::pair{"thread/compacted"sv, - EventDescriptor{"thread.compacted", Authority::Merge}}, - std::pair{"model/rerouted"sv, - EventDescriptor{"model.rerouted", Authority::Merge}}, - std::pair{ - "model/verification"sv, - EventDescriptor{"model.verification.changed", Authority::Merge}}, - std::pair{"turn/moderationMetadata"sv, - EventDescriptor{"turn.moderation.changed", Authority::Replace}}, - std::pair{"model/safetyBuffering/updated"sv, - EventDescriptor{"model.safety-buffering.changed", - Authority::Replace}}, - std::pair{"fuzzyFileSearch/sessionUpdated"sv, - EventDescriptor{"workspace.search.changed", Authority::Merge}}, - std::pair{ - "fuzzyFileSearch/sessionCompleted"sv, - EventDescriptor{"workspace.search.completed", Authority::Merge}}, - std::pair{"thread/realtime/started"sv, - EventDescriptor{"realtime.session.started", Authority::Merge}}, - std::pair{"thread/realtime/itemAdded"sv, - EventDescriptor{"realtime.item.added", Authority::Merge}}, - std::pair{ - "thread/realtime/transcript/delta"sv, - EventDescriptor{"realtime.transcript.appended", Authority::Merge}}, - std::pair{ - "thread/realtime/transcript/done"sv, - EventDescriptor{"realtime.transcript.completed", Authority::Merge}}, - std::pair{"thread/realtime/outputAudio/delta"sv, - EventDescriptor{"realtime.audio.appended", Authority::Merge}}, - std::pair{"thread/realtime/sdp"sv, - EventDescriptor{"realtime.session-description.changed", - Authority::Replace}}, - std::pair{"thread/realtime/error"sv, - EventDescriptor{"realtime.session.failed", Authority::Merge}}, - std::pair{"thread/realtime/closed"sv, - EventDescriptor{"realtime.session.closed", Authority::Merge}}, - std::pair{"windows/worldWritableWarning"sv, - EventDescriptor{"system.windows-permission.warning", - Authority::None}}, - std::pair{"windowsSandbox/setupCompleted"sv, - EventDescriptor{"system.windows-sandbox.completed", - Authority::Merge}}, - std::pair{"account/login/completed"sv, - EventDescriptor{"account.login.completed", Authority::Merge}}, - }; - for (const auto &[name, descriptor] : descriptors) { - if (method == name) - return descriptor; - } - return std::nullopt; -} - -} // namespace - -ProtocolNormalizer::ProtocolNormalizer(Sink sink) : sink(std::move(sink)) {} - -void ProtocolNormalizer::setDeliveryFailureHandler( - std::function handler) { - deliveryFailureHandler = std::move(handler); -} - -void ProtocolNormalizer::transportEvent(std::string_view eventName, - std::string detail) { - if (eventName == "connected") - ++connectionGeneration; - nlohmann::json data{{"state", eventName}}; - if (!detail.empty()) - data["detail"] = std::move(detail); - emitEvent("connection.lifecycle", std::move(data)); -} - -void ProtocolNormalizer::connectionSettings(nlohmann::json settings) { - emitEvent("connection.settings.changed", std::move(settings), - Authority::Replace); -} - -void ProtocolNormalizer::localOperationResult(std::string action, - std::string correlationId, - bool ok, nlohmann::json data) { - emit(presentation::result(nextSequence++, connectionGeneration, - std::move(action), std::move(correlationId), ok, - std::move(data))); -} - -void ProtocolNormalizer::bridgeEvent(const nlohmann::json &value) { - const std::string kind = presentation::stringMember(value, "kind"); - if (kind == "bridge.connection") { - emitEvent("connection.bridge", - {{"state", presentation::stringMember(value, "event")}, - {"connectionId", presentation::stringMember(value, "connectionId")}, - {"role", presentation::stringMember(value, "role")}}); - return; - } - if (kind == "bridge.controller") { - emitEvent("connection.controller", - {{"controllerConnectionId", - presentation::member(value, "controllerConnectionId")}}, - Authority::Replace); - return; - } - if (kind == "bridge.provider") { - const auto generation = value.find("providerGeneration"); - if (generation == value.end() || !generation->is_number_unsigned()) { - diagnostic("bridge", "invalid-provider-event", - "provider event has no unsigned generation", value); - return; - } - nlohmann::json data{{"state", presentation::stringMember(value, "state")}, - {"generation", generation->get()}}; - const std::string reason = presentation::stringMember(value, "reason"); - if (!reason.empty()) - data["reason"] = reason; - emitEvent("connection.provider", std::move(data), Authority::Replace); - return; - } - if (kind == "bridge.diagnostic") { - diagnostic("bridge", presentation::stringMember(value, "code"), - presentation::stringMember(value, "message"), - presentation::member(value, "details", nlohmann::json::object())); - return; - } - diagnostic("bridge", "unknown-event", kind, value); -} - -void ProtocolNormalizer::serverNotification(std::string_view method, - const nlohmann::json ¶ms) { - const nlohmann::json scope = stableScope(params); - if (method == "thread/started") { - emitEvent("thread.upsert", - {{"thread", presentation::member( - params, "thread", nlohmann::json::object())}}, - Authority::Merge); - } else if (method == "thread/status/changed") { - emitEvent("thread.status.changed", - {{"status", presentation::member(params, "status")}}, - Authority::Merge, scope); - } else if (method == "thread/name/updated") { - emitEvent("thread.name.changed", - {{"name", presentation::member(params, "threadName")}}, - Authority::Replace, scope); - } else if (method == "thread/deleted") { - emitEvent("thread.removed", nlohmann::json::object(), Authority::Remove, - scope); - } else if (method == "thread/archived" || method == "thread/unarchived" || - method == "thread/closed") { - const std::string state = method == "thread/archived" ? "archived" - : method == "thread/unarchived" ? "unarchived" - : "closed"; - emitEvent("thread.lifecycle", {{"state", state}}, Authority::Merge, scope); - } else if (method == "turn/started" || method == "turn/completed") { - emitEvent( - "turn.upsert", - {{"lifecycle", method == "turn/started" ? "started" : "completed"}, - {"turn", presentation::member(params, "turn", - nlohmann::json::object())}}, - Authority::Merge, scope); - } else if (method == "turn/plan/updated") { - emitEvent("plan.replaced", - {{"explanation", presentation::member(params, "explanation")}, - {"steps", presentation::member( - params, "plan", nlohmann::json::array())}}, - Authority::Replace, scope); - } else if (method == "item/started" || method == "item/completed") { - const nlohmann::json item = - presentation::member(params, "item", nlohmann::json::object()); - nlohmann::json itemScope = scope; - if (!itemScope.contains("itemId") && item.contains("id") && - !item["id"].is_null()) - itemScope["itemId"] = item["id"]; - emitEvent( - "conversation.item.upsert", - {{"lifecycle", method == "item/started" ? "started" : "completed"}, - {"item", item}}, - Authority::Merge, itemScope); - const std::string itemType = presentation::stringMember(item, "type"); - if (itemType == "collabAgentToolCall" || itemType == "subAgentActivity") { - emitEvent( - "agents.activity.upsert", - {{"lifecycle", method == "item/started" ? "started" : "completed"}, - {"activity", item}}, - Authority::Merge, itemScope); - } - } else if (method == "item/agentMessage/delta" || - method == "item/plan/delta" || - method == "item/reasoning/summaryTextDelta" || - method == "item/reasoning/textDelta" || - method == "item/commandExecution/outputDelta") { - std::string field = "text"; - if (method == "item/commandExecution/outputDelta") - field = "aggregatedOutput"; - else if (method == "item/reasoning/summaryTextDelta") - field = "summary"; - else if (method == "item/reasoning/textDelta") - field = "content"; - nlohmann::json data{{"field", std::move(field)}, - {"text", presentation::stringMember(params, "delta")}}; - if (params.contains("summaryIndex")) - data["summaryIndex"] = params["summaryIndex"]; - if (params.contains("contentIndex")) - data["contentIndex"] = params["contentIndex"]; - emitEvent("conversation.item.append", std::move(data), Authority::Merge, - scope); - } else if (method == "serverRequest/resolved") { - emitEvent("pending-request.removed", nlohmann::json::object(), - Authority::Remove, scope); - } else if (method == "error" || method == "warning" || - method == "guardianWarning" || method == "configWarning" || - method == "deprecationNotice") { - emitEvent("notice.added", - {{"severity", method == "error" ? "error" : "warning"}, - {"notice", params}}, - Authority::None, scope); - } else if (method == "thread/tokenUsage/updated") { - emitEvent( - "thread.token-usage.changed", - {{"tokenUsage", presentation::member( - params, "tokenUsage", nlohmann::json::object())}}, - Authority::Replace, scope); - } else if (method == "account/updated") { - emitEvent("account.changed", {{"account", params}}, Authority::Replace); - } else if (method == "account/rateLimits/updated") { - emitEvent("account.rate-limits.changed", {{"rateLimits", params}}, - Authority::Replace); - } else if (const auto descriptor = remainingNotification(method)) { - emitEvent(std::string(descriptor->type), params, descriptor->authority, - scope); - } else { - diagnostic("appserver", "unmapped-notification", std::string(method)); - } -} - -void ProtocolNormalizer::serverRequest(std::string_view method, - const nlohmann::json &requestId, - const nlohmann::json ¶ms) { - nlohmann::json scope = stableScope(params); - scope["requestId"] = requestId; - emitEvent("pending-request.upsert", - {{"requestId", requestId}, - {"category", requestKind(method)}, - {"request", params}}, - Authority::Merge, std::move(scope)); -} - -void ProtocolNormalizer::observeRawInbound(const nlohmann::json &message) { - const auto method = ai::openai::codex::protocol::jsonRpcMethod(message); - const JsonRpcKind kind = - ai::openai::codex::protocol::classifyJsonRpc(message); - if ((kind == JsonRpcKind::Request || kind == JsonRpcKind::Notification) && - method && !knownServerMethod(*method)) - diagnostic("appserver", "unknown-method", *method); -} - -void ProtocolNormalizer::operationResult(std::string action, - std::string correlationId, - nlohmann::json context, - const nlohmann::json &response, - std::optional - startedAtSequence) { - const bool ok = response.is_object() && response.contains("result"); - nlohmann::json data; - Authority authority = Authority::None; - nlohmann::json scope = stableScope(context); - if (ok) { - const nlohmann::json &value = response["result"]; - if (action == "threads.list") { - data = { - {"threads", presentation::member(value, "data", - nlohmann::json::array())}, - {"nextCursor", presentation::member(value, "nextCursor")}, - {"backwardsCursor", presentation::member(value, "backwardsCursor")}}; - authority = Authority::Merge; - } else if (action == "thread.read") { - const nlohmann::json thread = - presentation::member(value, "thread", nlohmann::json::object()); - data = {{"thread", thread}}; - authority = startedAtSequence && *startedAtSequence == nextSequence - ? Authority::Replace - : Authority::Merge; - const std::string threadId = presentation::stringMember(thread, "id"); - if (!threadId.empty()) - scope["threadId"] = threadId; - } else if (action == "thread.create" || action == "thread.resume" || - action == "thread.fork") { - nlohmann::json thread = - presentation::member(value, "thread", nlohmann::json::object()); - for (const char *field : {"activePermissionProfile", "approvalPolicy", - "approvalsReviewer", "cwd", "model", - "modelProvider", "reasoningEffort", "sandbox", - "serviceTier"}) { - const auto setting = value.find(field); - if (setting != value.end()) - thread[field] = *setting; - } - data = {{"thread", std::move(thread)}}; - authority = Authority::Merge; - } else if (action == "models.list") { - data = {{"models", presentation::member(value, "data", - nlohmann::json::array())}, - {"nextCursor", presentation::member(value, "nextCursor")}}; - authority = Authority::Replace; - } else if (action == "model-provider-capabilities.read" || - action == "account.read" || - action == "account.rate-limits.read" || - action == "account.token-usage.read" || - action == "config.read" || - action == "permission-profiles.list" || - action == "experimental-features.list" || - action == "skills.list" || action == "hooks.list" || - action == "plugins.list" || action == "apps.list" || - action == "mcp-servers.list") { - data = value; - authority = Authority::Replace; - } else if (action.ends_with(".list") || action.ends_with(".read") || - action.ends_with(".get") || action == "plugins.installed" || - action == "apps.installed" || - action == "windows-sandbox.readiness") { - data = value; - authority = Authority::Replace; - } else if (action == "turn.start") { - data = {{"turn", presentation::member( - value, "turn", nlohmann::json::object())}}; - authority = Authority::Merge; - } else { - data = value; - } - } else { - data = errorValue(response); - } - emit(presentation::result(nextSequence++, connectionGeneration, - std::move(action), std::move(correlationId), ok, - std::move(data), authority, std::move(scope))); -} - -void ProtocolNormalizer::operationRejected(std::string action, - std::string correlationId, int code, - std::string message) { - emit(presentation::result(nextSequence++, connectionGeneration, - std::move(action), std::move(correlationId), false, - {{"code", code}, {"message", std::move(message)}})); -} - -bool ProtocolNormalizer::emit(nlohmann::json frame) { - if (deliveryFailed) - return false; - if (sink && sink(frame)) - return true; - deliveryFailed = true; - if (deliveryFailureHandler) - deliveryFailureHandler(); - return false; -} - -bool ProtocolNormalizer::emitEvent(std::string type, nlohmann::json data, - Authority authority, nlohmann::json scope) { - return emit(presentation::event(nextSequence++, connectionGeneration, - std::move(type), std::move(data), authority, - std::move(scope))); -} - -void ProtocolNormalizer::diagnostic(std::string source, std::string code, - std::string message, - nlohmann::json details) { - emitEvent("system.diagnostic", {{"source", std::move(source)}, - {"code", std::move(code)}, - {"message", std::move(message)}, - {"details", std::move(details)}}); -} - -bool ProtocolNormalizer::knownServerMethod(std::string_view method) const { -#define CODEXUI_MATCH_SERVER_REQUEST(OperationName, methodName) \ - if (method == \ - ai::openai::codex::generated::server_requests::OperationName::method) \ - return true; - AI_OPENAI_CODEX_SERVER_REQUESTS(CODEXUI_MATCH_SERVER_REQUEST) -#undef CODEXUI_MATCH_SERVER_REQUEST -#define CODEXUI_MATCH_SERVER_NOTIFICATION(OperationName, methodName) \ - if (method == ai::openai::codex::generated::server_notifications:: \ - OperationName::method) \ - return true; - AI_OPENAI_CODEX_SERVER_NOTIFICATIONS(CODEXUI_MATCH_SERVER_NOTIFICATION) -#undef CODEXUI_MATCH_SERVER_NOTIFICATION - return false; -} - -std::uint64_t ProtocolNormalizer::sequence() const noexcept { - return nextSequence; -} - -} // namespace codexui::codex diff --git a/src/codex/ProtocolNormalizer.h b/src/codex/ProtocolNormalizer.h deleted file mode 100644 index 12c0fc1..0000000 --- a/src/codex/ProtocolNormalizer.h +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_PROTOCOLNORMALIZER_H -#define CODEXUI_CODEX_PROTOCOLNORMALIZER_H - -#include - -#include "codex/PresentationProtocol.h" - -#include -#include -#include -#include - -namespace codexui::codex { - -class ProtocolNormalizer final { -public: - using Sink = std::function; - - explicit ProtocolNormalizer(Sink sink); - void setDeliveryFailureHandler(std::function handler); - - void transportEvent(std::string_view event, std::string detail = {}); - void connectionSettings(nlohmann::json settings); - void localOperationResult(std::string action, std::string correlationId, - bool ok, nlohmann::json data); - void bridgeEvent(const nlohmann::json &event); - void serverNotification(std::string_view method, - const nlohmann::json ¶ms); - void serverRequest(std::string_view method, const nlohmann::json &requestId, - const nlohmann::json ¶ms); - void observeRawInbound(const nlohmann::json &message); - - void operationResult(std::string action, std::string correlationId, - nlohmann::json context, const nlohmann::json &response, - std::optional startedAtSequence = - std::nullopt); - void operationRejected(std::string action, std::string correlationId, - int code, std::string message); - [[nodiscard]] std::uint64_t sequence() const noexcept; - -private: - bool emit(nlohmann::json frame); - bool - emitEvent(std::string type, nlohmann::json data = nlohmann::json::object(), - presentation::Authority authority = presentation::Authority::None, - nlohmann::json scope = nlohmann::json::object()); - void diagnostic(std::string source, std::string code, std::string message, - nlohmann::json details = nlohmann::json::object()); - bool knownServerMethod(std::string_view method) const; - - Sink sink; - std::function deliveryFailureHandler; - bool deliveryFailed = false; - std::uint64_t connectionGeneration = 0; - std::uint64_t nextSequence = 1; -}; - -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_PROTOCOLNORMALIZER_H diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index ed1cb28..66e38a3 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -9,7 +9,6 @@ #include "codex/PendingRequestDialog.h" #include "codex/PendingRequestPolicy.h" #include "codex/TurnSettingsWidget.h" -#include "codex/UiSession.h" #include "codex/middle/ComposerPane.h" #include "codex/middle/ConversationView.h" #include "codex/middle/InspectorPane.h" @@ -17,6 +16,7 @@ #include "codex/middle/ThreadPane.h" #include "codex/ui/BrandMark.h" #include "codex/ui/ExpandingPromptEditor.h" +#include "codex/ui/NodeGraphUiAdapter.h" #include "codex/ui/UiStyle.h" #include @@ -29,68 +29,860 @@ #include #include #include +#include #include #include #include #include -#include +#include #include +#include #include #include #include #include +#include +#include #include #include +#include #include +#include #include #include +#include +#include #include #include +#include #include +#include namespace codexui::codex { namespace { constexpr auto DraftThreadId = "draft:new-thread"; +constexpr int GraphRetryDelayMilliseconds = 8; + +bool containsKind(const nodegraph::GraphChanged &change, + std::initializer_list kinds) { + const auto matches = [kinds](const nodegraph::NodeRef &node) { + return node && std::ranges::find(kinds, node->id().kind) != kinds.end(); + }; + return std::ranges::any_of(change.affected, matches) || + std::ranges::any_of(change.removed, matches); +} QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } -std::string utf8(const QString &value) { - return value.toUtf8().toStdString(); -} +std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } QString lastActivityText(std::int64_t timestamp) { const QDateTime activity = QDateTime::fromSecsSinceEpoch(timestamp).toLocalTime(); const QDateTime now = QDateTime::currentDateTime(); - const QString formatted = activity.date() == now.date() - ? activity.toString(QStringLiteral("HH:mm:ss")) - : activity.toString( - QStringLiteral("yyyy-MM-dd HH:mm:ss")); + const QString formatted = + activity.date() == now.date() + ? activity.toString(QStringLiteral("HH:mm:ss")) + : activity.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); return QStringLiteral("Last activity: %1").arg(formatted); } -const ui::ThreadListRow *findThread(const ui::ThreadListRow &row, - std::string_view id) { - if (row.id == id) - return &row; - for (const ui::ThreadListRow &child : row.children) { - if (const ui::ThreadListRow *found = findThread(child, id)) - return found; +const nodegraph::Value *graphField(const nodegraph::NodeState &state, + std::string_view name) { + const auto found = state.fields.find(name); + return found == state.fields.end() ? nullptr : &found->second; +} + +const nodegraph::Value *graphField(const nodegraph::Value::Object &object, + std::string_view name) { + const auto found = object.find(name); + return found == object.end() ? nullptr : &found->second; +} + +std::string graphString(const nodegraph::Value *value) { + return value && value->asString() ? *value->asString() : std::string{}; +} + +bool fieldChanged(const nodegraph::NodeGraph::ReadAccess &read, + const nodegraph::NodeRef &node, std::string_view field, + std::uint64_t revision) { + return node && read.contains(node) && + read.fieldChangedRevision(node, field) == revision; +} + +struct ThreadPaneRoute { + bool affected = false; + bool structural = false; + std::vector rows; +}; + +ThreadPaneRoute threadPaneRoute( + const nodegraph::GraphChanged &change, const nodegraph::NodeGraph &graph, + middle::ThreadPane::SortCriterion sortCriterion) { + if (change.rescanRequired || + containsKind(change, {nodegraph::NodeKind::Interaction}) || + std::ranges::any_of(change.removed, [](const auto &node) { + return node && (node->id().kind == nodegraph::NodeKind::Runtime || + node->id().kind == nodegraph::NodeKind::Thread); + })) + return {true, true, {}}; + const auto read = graph.tryRead(); + if (!read) + return containsKind(change, {nodegraph::NodeKind::Runtime, + nodegraph::NodeKind::Thread}) + ? ThreadPaneRoute{true, true, {}} + : ThreadPaneRoute{}; + constexpr std::array Fields{ + "name", "title", "cwd", + "workspace", "status", "createdAt", + "updatedAt", "recencyAt", "lastActivityAt", + "localActivityAt", "localPromptActivityAt", + "pendingInteractionCount", "hydrationState", "archived"}; + ThreadPaneRoute route; + for (const nodegraph::NodeRef &node : change.affected) { + if (!node || !read->contains(node)) + continue; + if (node->id().kind == nodegraph::NodeKind::Runtime) { + if (read->structureChangedRevision(node) == change.revision) + return {true, true, {}}; + continue; + } + if (node->id().kind != nodegraph::NodeKind::Thread) + continue; + const bool presentationChanged = + read->statusChangedRevision(node) == change.revision || + std::ranges::any_of(Fields, [&](std::string_view field) { + return fieldChanged(*read, node, field, change.revision); + }); + if (!presentationChanged && + read->structureChangedRevision(node) != change.revision) + continue; + const bool sortChanged = + (sortCriterion == middle::ThreadPane::SortCriterion::Alphanumeric && + (fieldChanged(*read, node, "name", change.revision) || + fieldChanged(*read, node, "title", change.revision))) || + (sortCriterion == middle::ThreadPane::SortCriterion::Created && + fieldChanged(*read, node, "createdAt", change.revision)) || + (sortCriterion == middle::ThreadPane::SortCriterion::LastChanged && + fieldChanged(*read, node, "updatedAt", change.revision)) || + (sortCriterion == middle::ThreadPane::SortCriterion::Recency && + fieldChanged(*read, node, "recencyAt", change.revision)); + if (read->structureChangedRevision(node) == change.revision || sortChanged || + fieldChanged(*read, node, "archived", change.revision)) + return {true, true, {}}; + route.affected = true; + if (std::ranges::find(route.rows, node) == route.rows.end()) + route.rows.push_back(node); + } + return route; +} + +std::vector +graphAttachmentDrafts(const nodegraph::NodeState &state) { + std::vector result; + const nodegraph::Value *attachments = graphField(state, "attachments"); + const nodegraph::Value::Array *array = + attachments ? attachments->asArray() : nullptr; + if (!array) + return result; + result.reserve(array->size()); + for (const nodegraph::Value &entry : *array) { + const nodegraph::Value::Object *object = entry.asObject(); + if (!object) + continue; + AttachmentDraft attachment; + attachment.path = graphString(graphField(*object, "path")); + attachment.name = graphString(graphField(*object, "displayName")); + attachment.mimeType = graphString(graphField(*object, "mimeType")); + if (!attachment.path.empty()) + result.emplace_back(std::move(attachment)); } - return nullptr; + return result; } -const ui::ThreadListRow *findThread(const ui::ThreadListSnapshot &snapshot, - std::string_view id) { - for (const ui::ThreadListRow &root : snapshot.roots) { - if (const ui::ThreadListRow *found = findThread(root, id)) - return found; +struct ConversationRoute { + bool affected = false; + bool structural = false; + std::vector items; +}; + +ConversationRoute conversationRoute(const nodegraph::GraphChanged &change, + const nodegraph::NodeGraph &graph, + const nodegraph::NodeRef &selectedThread) { + if (change.rescanRequired) + return {true, true, {}}; + if (!selectedThread) + return {}; + // Removal intentionally erases ancestry and addressing fields before Qt is + // notified. Conservatively reconcile the selected conversation so no + // retired card reference can survive acknowledgement. + if (std::ranges::any_of(change.removed, [](const auto &node) { + return node && (node->id().kind == nodegraph::NodeKind::Turn || + node->id().kind == nodegraph::NodeKind::Item); + })) + return {true, true, {}}; + constexpr std::size_t MaximumFilteredNodes = 64; + if (change.affected.size() + change.removed.size() > MaximumFilteredNodes) + return {true, true, {}}; + + const std::optional read = graph.tryRead(); + if (!read) + return {true, true, {}}; + + const std::string &selectedId = selectedThread->id().canonical; + ConversationRoute route; + const auto routeNode = [&](const nodegraph::NodeRef &node) { + if (!node) + return; + if (node == selectedThread) { + if (!read->contains(node)) { + route = {true, true, {}}; + return; + } + constexpr std::array Fields{ + "historyLoadedItemCount", "historyTotalItemCount", + "historyHasMore", "hasMore", "hydrationState"}; + if (read->structureChangedRevision(node) == change.revision || + std::ranges::any_of(Fields, [&](std::string_view field) { + return fieldChanged(*read, node, field, change.revision); + })) + route = {true, true, {}}; + return; + } + if (node->id().kind == nodegraph::NodeKind::Thread) + return; + if (node->id().kind != nodegraph::NodeKind::Turn && + node->id().kind != nodegraph::NodeKind::Item) + return; + + try { + bool belongs = false; + nodegraph::NodeRef ancestor = node; + while ((ancestor = read->parent(ancestor))) { + if (ancestor == selectedThread) { + belongs = true; + break; + } + if (ancestor->id().kind == nodegraph::NodeKind::Thread) + break; + } + + if (!belongs) { + const std::shared_ptr state = + read->state(node); + for (const std::string_view field : { + std::string_view("protocolThreadId"), + std::string_view("threadId")}) { + if (graphString(graphField(*state, field)) == selectedId) { + belongs = true; + break; + } + } + } + if (!belongs) + return; + route.affected = true; + if (node->id().kind == nodegraph::NodeKind::Turn || + read->structureChangedRevision(node) == change.revision) { + route.structural = true; + route.items.clear(); + return; + } + if (!route.structural && + std::ranges::find(route.items, node) == route.items.end()) + route.items.push_back(node); + } catch (const std::invalid_argument &) { + // A queued NodeRef may have been retired by a later graph transaction. + // Conservatively refresh rather than risk missing a selected update. + route = {true, true, {}}; + } + }; + + for (const nodegraph::NodeRef &node : change.affected) + routeNode(node); + for (const nodegraph::NodeRef &node : change.removed) + routeNode(node); + return route; +} + +enum class InspectorDependency { + None, + Plan, + Agents, + Changes, + Requests, + State, + Protocol, +}; + +bool inspectorAffected(const nodegraph::GraphChanged &change, + const nodegraph::NodeGraph &graph, + const nodegraph::NodeRef &selectedThread, + InspectorDependency dependency) { + if (dependency == InspectorDependency::None || + dependency == InspectorDependency::Protocol) + return false; + if (change.rescanRequired) + return true; + if (dependency == InspectorDependency::Requests) + return containsKind(change, {nodegraph::NodeKind::Interaction}); + // State explicitly presents a bounded cross-domain graph summary. It is the + // sole Inspector page whose visible data can depend on any graph node. + if (dependency == InspectorDependency::State) + return !change.affected.empty() || !change.removed.empty(); + if (!selectedThread) + return false; + if (change.affected.size() + change.removed.size() > 64) + return true; + const auto read = graph.tryRead(); + if (!read) + return true; + if (!read->contains(selectedThread) || read->removed(selectedThread)) + return true; + const std::vector agentChildren = + read->related(selectedThread, + nodegraph::RelationKind::AgentChildThread); + const auto relevant = [&](const nodegraph::NodeRef &node) { + if (!node) + return false; + if (!read->contains(node)) + return node->id().kind == nodegraph::NodeKind::Thread || + node->id().kind == nodegraph::NodeKind::Turn || + node->id().kind == nodegraph::NodeKind::Item; + if (node == selectedThread) { + if (dependency == InspectorDependency::Changes) + return fieldChanged(*read, node, "cwd", change.revision) || + fieldChanged(*read, node, "workspace", change.revision); + return read->structureChangedRevision(node) == change.revision || + fieldChanged(*read, node, "hydrationState", change.revision); + } + if (node->id().kind == nodegraph::NodeKind::Thread) + return dependency == InspectorDependency::Agents && + std::ranges::find(agentChildren, node) != agentChildren.end(); + if (node->id().kind != nodegraph::NodeKind::Turn && + node->id().kind != nodegraph::NodeKind::Item) + return false; + + nodegraph::NodeRef containingThread = node; + while (containingThread && + containingThread->id().kind != nodegraph::NodeKind::Thread) + containingThread = read->parent(containingThread); + if (containingThread != selectedThread && + std::ranges::find(agentChildren, containingThread) == + agentChildren.end()) + return false; + if (node->id().kind == nodegraph::NodeKind::Turn) + return dependency == InspectorDependency::Plan && + (read->structureChangedRevision(node) == change.revision || + fieldChanged(*read, node, "plan", change.revision) || + fieldChanged(*read, node, "planExplanation", change.revision)); + const auto state = read->state(node); + const std::string type = graphString(graphField(*state, "type")); + if (dependency == InspectorDependency::Plan) + return type == "plan"; + if (dependency == InspectorDependency::Agents) + return type == "subAgentActivity" || type == "collabAgentToolCall" || + (type == "agentMessage" && containingThread != selectedThread); + if (dependency == InspectorDependency::Changes) { + if (type == "fileChange") + return true; + return type == "commandExecution" && + fieldChanged(*read, node, "cwd", change.revision); + } + return false; + }; + return std::ranges::any_of(change.affected, relevant) || + std::ranges::any_of(change.removed, relevant); +} + +bool shellChromeAffected(const nodegraph::GraphChanged &change, + const nodegraph::NodeGraph &graph, + const nodegraph::NodeRef &selectedThread) { + if (change.rescanRequired) + return true; + constexpr std::size_t MaximumFilteredNodes = 64; + if (change.affected.size() + change.removed.size() > MaximumFilteredNodes) + return true; + + const auto read = graph.tryRead(); + if (!read) + return true; + const auto directlyAffectsChrome = [&](const auto &node) { + if (!node) + return false; + if (!read->contains(node)) + return true; + switch (node->id().kind) { + case nodegraph::NodeKind::Connection: + return true; + case nodegraph::NodeKind::Runtime: + return read->structureChangedRevision(node) == change.revision || + fieldChanged(*read, node, "controller", change.revision); + case nodegraph::NodeKind::Catalog: + return node->id().canonical == "model" || + node->id().canonical == "permissionProfile"; + case nodegraph::NodeKind::Interaction: + return true; + case nodegraph::NodeKind::Thread: { + if (!selectedThread || node != selectedThread) + return false; + constexpr std::array Fields{ + "name", "title", "cwd", "workspace", "status", + "hydrationState", "recoveryOnly", "lastActivityAt", + "recencyAt", "updatedAt", "localActivityAt", + "localPromptActivityAt", "settingsRevision", "settings", + "latestSettingsUpdate"}; + return read->statusChangedRevision(node) == change.revision || + read->structureChangedRevision(node) == change.revision || + std::ranges::any_of(Fields, [&](std::string_view field) { + return fieldChanged(*read, node, field, change.revision); + }); + } + default: + return false; + } + }; + if (std::ranges::any_of(change.affected, directlyAffectsChrome) || + std::ranges::any_of(change.removed, directlyAffectsChrome)) + return true; + if (!selectedThread) + return false; + + const auto turnBelongsToSelection = [&](const auto &node) { + if (!node || node->id().kind != nodegraph::NodeKind::Turn) + return false; + try { + if (!read->contains(node)) + return true; + if (read->statusChangedRevision(node) != change.revision && + read->structureChangedRevision(node) != change.revision) + return false; + if (read->parent(node) == selectedThread) + return true; + return graphString(graphField(*read->state(node), "protocolThreadId")) == + selectedThread->id().canonical; + } catch (const std::invalid_argument &) { + return true; + } + }; + return std::ranges::any_of(change.affected, turnBelongsToSelection) || + std::ranges::any_of(change.removed, turnBelongsToSelection); +} + +bool graphUiFallbackAffected(const nodegraph::GraphChanged &change, + const nodegraph::NodeGraph &graph) { + if (change.rescanRequired || + containsKind(change, {nodegraph::NodeKind::Notice})) + return true; + const auto read = graph.tryRead(); + if (!read) + return containsKind(change, {nodegraph::NodeKind::Runtime}); + return std::ranges::any_of(change.affected, [&](const auto &node) { + return node && read->contains(node) && + node->id().kind == nodegraph::NodeKind::Runtime && + (read->structureChangedRevision(node) == change.revision || + fieldChanged(*read, node, "uiSelectionSerial", change.revision)); + }); +} + +bool graphBool(const nodegraph::Value *value, bool fallback = false) { + return value && value->asBool() ? *value->asBool() : fallback; +} + +std::optional graphInteger(const nodegraph::Value *value) { + if (!value) + return std::nullopt; + if (const auto *number = value->asInt64()) + return *number; + if (const auto *number = value->asUInt64()) { + if (*number <= + static_cast(std::numeric_limits::max())) + return static_cast(*number); + } + return std::nullopt; +} + +std::string graphStatus(const nodegraph::NodeState &state) { + if (const nodegraph::Value *value = graphField(state, "status")) { + if (const nodegraph::Value::Object *object = value->asObject()) + return graphString(graphField(*object, "type")); + if (std::string status = graphString(value); !status.empty()) + return status; + } + switch (state.status) { + case nodegraph::NodeStatus::Pending: + return "pending"; + case nodegraph::NodeStatus::Running: + return "running"; + case nodegraph::NodeStatus::Completed: + return "completed"; + case nodegraph::NodeStatus::Failed: + return "failed"; + case nodegraph::NodeStatus::Interrupted: + return "interrupted"; + case nodegraph::NodeStatus::NotLoaded: + return "notLoaded"; + case nodegraph::NodeStatus::Connected: + return "connected"; + case nodegraph::NodeStatus::Disconnected: + return "disconnected"; + case nodegraph::NodeStatus::Unknown: + return {}; + } + return {}; +} + +bool activeStatus(const nodegraph::NodeState &state) { + const std::string status = graphStatus(state); + return state.status == nodegraph::NodeStatus::Running || status == "active" || + status == "inProgress" || status == "running"; +} + +// These conversions never parse or encode app-server JSON. They adapt the +// graph's already-decoded values to existing local-only dialog/widget APIs. +nlohmann::json widgetJson(const nodegraph::Value &value) { + if (value.isNull()) + return nullptr; + if (const auto *boolean = value.asBool()) + return *boolean; + if (const auto *number = value.asInt64()) + return *number; + if (const auto *number = value.asUInt64()) + return *number; + if (const auto *number = value.asDouble()) + return *number; + if (const auto *string = value.asString()) + return *string; + if (const auto *array = value.asArray()) { + nlohmann::json result = nlohmann::json::array(); + for (const nodegraph::Value &entry : *array) + result.push_back(widgetJson(entry)); + return result; + } + nlohmann::json result = nlohmann::json::object(); + if (const auto *object = value.asObject()) + for (const auto &[key, entry] : *object) + result[key] = widgetJson(entry); + return result; +} + +nlohmann::json widgetSettingsJson(const nodegraph::NodeState &state) { + nlohmann::json result = nlohmann::json::object(); + for (const std::string_view key : { + std::string_view("model"), + std::string_view("effort"), + std::string_view("reasoningEffort"), + std::string_view("personality"), + std::string_view("sandbox"), + std::string_view("sandboxPolicy"), + std::string_view("approvalPolicy"), + std::string_view("approvalsReviewer"), + std::string_view("cwd"), + std::string_view("activePermissionProfile"), + std::string_view("serviceTier"), + std::string_view("summary"), + std::string_view("collaborationMode"), + }) { + if (const nodegraph::Value *value = graphField(state, key)) + result[std::string(key)] = widgetJson(*value); + } + return result; +} + +nlohmann::json +catalogArray(const std::shared_ptr &state, + std::initializer_list keys) { + if (!state) + return nlohmann::json::array(); + for (const char *key : keys) { + const nodegraph::Value *value = graphField(*state, key); + if (value && value->asArray()) + return widgetJson(*value); + } + return nlohmann::json::array(); +} + +nodegraph::Value actionValue(const nlohmann::json &value) { + if (value.is_null()) + return nullptr; + if (value.is_boolean()) + return value.get(); + if (value.is_number_unsigned()) + return value.get(); + if (value.is_number_integer()) + return value.get(); + if (value.is_number_float()) + return value.get(); + if (value.is_string()) + return value.get(); + if (value.is_array()) { + nodegraph::Value::Array result; + result.reserve(value.size()); + for (const auto &entry : value) + result.emplace_back(actionValue(entry)); + return result; + } + nodegraph::Value::Object result; + if (value.is_object()) + for (auto entry = value.cbegin(); entry != value.cend(); ++entry) + result.emplace(entry.key(), actionValue(entry.value())); + return result; +} + +nodegraph::Value::Object actionObject(const nlohmann::json &value) { + nodegraph::Value converted = actionValue(value); + return converted.asObject() ? std::move(*converted.asObject()) + : nodegraph::Value::Object{}; +} + +bool connectionSettingsContainSelection(const nlohmann::json &settings, + const nlohmann::json &selection) { + if (!settings.is_object() || !selection.is_object()) + return false; + const std::string transport = selection.value("transport", std::string{}); + if (transport.empty() || + settings.value("selected", std::string{}) != transport) + return false; + const nlohmann::json available = + settings.value("available", nlohmann::json::array()); + for (const auto &entry : available) { + if (!entry.is_object() || entry.value("key", std::string{}) != transport) + continue; + for (auto field = selection.cbegin(); field != selection.cend(); ++field) { + if (field.key() != "transport" && + (!entry.contains(field.key()) || entry[field.key()] != field.value())) + return false; + } + return true; } - return nullptr; + return false; +} + +void applyConnectionSelection(nlohmann::json &settings, + const nlohmann::json &selection) { + if (!settings.is_object() || !selection.is_object()) + return; + const std::string transport = selection.value("transport", std::string{}); + if (transport.empty()) + return; + settings["selected"] = transport; + auto available = settings.find("available"); + if (available == settings.end() || !available->is_array()) + return; + for (auto &entry : *available) { + if (!entry.is_object() || entry.value("key", std::string{}) != transport) + continue; + for (auto field = selection.cbegin(); field != selection.cend(); ++field) + if (field.key() != "transport") + entry[field.key()] = field.value(); + return; + } +} + +std::string requestKind(std::string_view method) { + if (method == "item/commandExecution/requestApproval") + return "command-approval"; + if (method == "item/fileChange/requestApproval") + return "file-change-approval"; + if (method == "item/tool/requestUserInput") + return "user-input"; + if (method == "mcpServer/elicitation/request") + return "mcp-elicitation"; + if (method == "item/permissions/requestApproval") + return "permissions-approval"; + if (method == "item/tool/call") + return "dynamic-tool-call"; + if (method == "account/chatgptAuthTokens/refresh") + return "authentication-refresh"; + if (method == "attestation/generate") + return "attestation"; + if (method == "applyPatchApproval") + return "legacy-patch-approval"; + if (method == "execCommandApproval") + return "legacy-command-approval"; + return "unsupported"; +} + +struct PendingGraphRequest final { + nodegraph::NodeRef node; + std::string id; + std::string displayId; + std::string kind; + std::string threadId; + nodegraph::Value::Object payload; + std::optional retainedResponsePayload; + bool recoveryOnly = false; + bool recoverable = false; + bool actionable = false; + + bool operator==(const PendingGraphRequest &) const = default; +}; + +struct PendingGraphRequestSnapshot final { + nodegraph::NodeRef node; + std::shared_ptr state; + std::string threadId; +}; + +std::optional +readPendingRequest(nodegraph::NodeGraph::ReadAccess &read, + const nodegraph::NodeRef &selectedThread, + const std::string &requestKey) { + nodegraph::NodeRef candidate; + if (!requestKey.empty()) { + candidate = read.find({nodegraph::NodeKind::Interaction, requestKey}); + } else if (selectedThread && !read.removed(selectedThread)) { + candidate = read.relatedAt(selectedThread, + nodegraph::RelationKind::PendingInteraction, 0); + } + if (!candidate && requestKey.empty()) { + const nodegraph::NodeRef runtime = + read.find({nodegraph::NodeKind::Runtime, "runtime"}); + candidate = + read.relatedAt(runtime, nodegraph::RelationKind::PendingInteraction, 0); + } + if (!candidate || candidate->id().kind != nodegraph::NodeKind::Interaction) + return std::nullopt; + + std::shared_ptr state = read.state(candidate); + if (state->status != nodegraph::NodeStatus::Pending && + state->status != nodegraph::NodeStatus::Failed) + return std::nullopt; + + nodegraph::NodeRef target = + read.relatedAt(candidate, nodegraph::RelationKind::InteractionTarget, 0); + while (target && target->id().kind != nodegraph::NodeKind::Thread) + target = read.parent(target); + return PendingGraphRequestSnapshot{std::move(candidate), std::move(state), + target ? target->id().canonical + : std::string{}}; +} + +PendingGraphRequest +materializePendingRequest(PendingGraphRequestSnapshot snapshot, + bool canControl) { + PendingGraphRequest request; + request.node = std::move(snapshot.node); + request.id = request.node->id().canonical; + request.displayId = graphString(graphField(*snapshot.state, "requestId")); + if (request.displayId.empty()) + request.displayId = request.id; + request.kind = + requestKind(graphString(graphField(*snapshot.state, "method"))); + request.recoveryOnly = graphBool(graphField(*snapshot.state, "recoveryOnly")); + request.actionable = canControl && !request.recoveryOnly; + if (const nodegraph::Value *payload = graphField(*snapshot.state, "payload"); + payload && payload->asObject()) + request.payload = *payload->asObject(); + if (const nodegraph::Value *retained = + graphField(*snapshot.state, "retainedResponsePayload"); + retained && retained->asObject()) + request.retainedResponsePayload = *retained->asObject(); + request.recoverable = + request.recoveryOnly && request.retainedResponsePayload.has_value(); + request.threadId = snapshot.threadId.empty() + ? graphString(graphField(request.payload, "threadId")) + : std::move(snapshot.threadId); + return request; +} + +struct ShellChromeValues final { + bool connected = false; + bool retrying = false; + bool canControl = false; + bool activeTurn = false; + bool threadAdmissionReady = true; + bool conversationReadyForDisplay = true; + bool hydrationFailed = false; + bool recoveryOnly = false; + std::string role; + std::string providerState; + std::string selectedTransport; + std::string workspace; + std::string title; + std::string status; + std::optional lastActivityAt; + std::size_t totalPending = 0; + nlohmann::json canonicalSettings = nlohmann::json::object(); + nlohmann::json settingsUpdate = nlohmann::json::object(); + std::uint64_t settingsRevision = 0; + std::optional attention; + + bool operator==(const ShellChromeValues &) const = default; +}; + +nodegraph::Value::Object +authoredResponsePayload(std::string_view kind, + const PendingRequestResponse &response) { + nodegraph::Value::Object result; + if (kind == "permissions-approval") { + if (response.error.is_null()) { + const auto scope = response.result.find("scope"); + if (scope != response.result.end()) + result.emplace("scope", actionValue(*scope)); + } else { + result.emplace("scope", "decline"); + } + return result; + } + if (kind == "user-input") { + const auto answers = response.result.find("answers"); + if (answers != response.result.end()) + result.emplace("answers", actionValue(*answers)); + return result; + } + if (kind == "mcp-elicitation") { + for (const char *field : {"action", "content", "_meta"}) { + const auto found = response.result.find(field); + if (found != response.result.end()) + result.emplace(field, actionValue(*found)); + } + return result; + } + if (kind == "dynamic-tool-call") { + for (const char *field : {"success", "contentItems", "message"}) { + const auto found = response.result.find(field); + if (found != response.result.end()) + result.emplace(field, actionValue(*found)); + } + return result; + } + const auto decision = response.result.find("decision"); + if (decision != response.result.end()) + result.emplace("decision", actionValue(*decision)); + return result; +} + +PendingRequestResponse +retainedResponse(std::string_view kind, + const nodegraph::Value::Object &payload) { + PendingRequestResponse response; + nlohmann::json result = nlohmann::json::object(); + const auto copy = [&](std::string_view key) { + if (const nodegraph::Value *value = graphField(payload, key)) + result[std::string(key)] = widgetJson(*value); + }; + if (kind == "user-input") { + copy("answers"); + } else if (kind == "mcp-elicitation") { + copy("action"); + copy("content"); + copy("_meta"); + } else if (kind == "permissions-approval") { + copy("scope"); + } else if (kind == "dynamic-tool-call") { + copy("success"); + copy("contentItems"); + copy("message"); + } else { + copy("decision"); + } + response.result = std::move(result); + return response; } QLabel *makeLabel(QString value, const char *kind = "body") { @@ -118,9 +910,12 @@ QLabel *makeStatusLabel(QString value, QString objectName, int maximumWidth, } void setStatusLabelText(QLabel *label, QString value) { - label->setToolTip(value); - label->setText(label->fontMetrics().elidedText(value, Qt::ElideMiddle, - label->maximumWidth())); + const QString displayed = label->fontMetrics().elidedText( + value, Qt::ElideMiddle, label->maximumWidth()); + if (label->toolTip() != value) + label->setToolTip(value); + if (label->text() != displayed) + label->setText(displayed); } QString statusToneColor(QStringView tone) { @@ -136,8 +931,11 @@ QString statusToneColor(QStringView tone) { } void setStatusTone(QFrame *dot, QLabel *label, const QString &tone) { - dot->setStyleSheet(QStringLiteral("background:%1;border-radius:5px;") - .arg(statusToneColor(tone))); + if (dot->property("tone").toString() != tone) { + dot->setProperty("tone", tone); + dot->setStyleSheet(QStringLiteral("background:%1;border-radius:5px;") + .arg(statusToneColor(tone))); + } if (label->property("tone").toString() == tone) return; label->setProperty("tone", tone); @@ -156,81 +954,137 @@ QFrame *statusDot() { } // namespace struct ShellWidget::Impl final { + struct ConversationHistoryWindow { + std::size_t requested = middle::AuthoritativeHistoryPageSize; + std::size_t effective = middle::AuthoritativeHistoryPageSize; + std::size_t lastAuthoritativeCount = 0; + }; + Impl(ShellWidget *owner, FrontendSession &session) - : owner(owner), session(session), - uiSession(session.presentationClient(), utf8(QDir::currentPath())), + : owner(owner), session(session), uiAdapter(session.nodeGraph()), alive(std::make_shared(true)) { buildUi(); connectUi(); const auto token = alive; - uiSession.setChangedHandler([this, token] { - if (*token) - scheduleRender(); - }); - uiSession.setWakeupHandler([this, token](std::int64_t atMilliseconds) { - if (*token) - scheduleLogicWakeup(atMilliseconds); - }); - uiSession.setProtocolFrameObserver( - [this, token](const nlohmann::json &frame) { + session.setGraphChangedHandler( + [this, token](const nodegraph::GraphChanged &change) { if (*token) - middleRegion->inspector().appendProtocolFrame(frame); + handleGraphChanged(change); }); - session.setEventHandler([this, token](const nlohmann::json &frame) { - if (*token) - uiSession.onPresentationFrame(frame); - }); - session.setActivityHandler([this, token](const std::string &threadId) { - if (*token) - uiSession.noteThreadActivity(threadId); + session.setGraphUiEffectHandler( + [this, token](const nodegraph::UiEffect &effect) { + if (*token) + handleUiEffect(effect); + }); + session.setRuntimeStoppedHandler([this, token] { + if (*token) { + showNotice(QStringLiteral("Codex worker stopped.")); + scheduleRender(); + } }); + bindGraphPanes({}); render(); } ~Impl() { *alive = false; - uiSession.setChangedHandler({}); - uiSession.setWakeupHandler({}); - uiSession.setProtocolFrameObserver({}); - session.setEventHandler({}); - session.setActivityHandler({}); + session.setGraphChangedHandler({}); + session.setGraphUiEffectHandler({}); + session.setRuntimeStoppedHandler({}); if (qApp) qApp->removeEventFilter(owner); } void buildUi(); void connectUi(); - void scheduleLogicWakeup(std::int64_t atMilliseconds); void scheduleRender(); + void scheduleGraphBinding(bool immediate = true); + void runGraphBinding(); + void bindGraphPanes(nodegraph::NodeRef selectedThread); + [[nodiscard]] bool refreshConversation(); + [[nodiscard]] bool refreshInspector(); + void schedulePaneCommit(bool immediate = false); + void commitPendingPanes(); + void handleGraphChanged(const nodegraph::GraphChanged &change); + void handleUiEffect(const nodegraph::UiEffect &effect); + void reconcileOptimisticCreation(const nodegraph::GraphChanged &change); + void reconcileGraphUiFallback(); + void selectGraphThread(nodegraph::NodeRef thread); + void scheduleDraftSelection(bool replenishRetry = true); void render(); - void renderStatus(const UiSessionView &view); - void synchronizeOptimisticThread( - const std::optional &optimistic); + void renderStatus(const ShellChromeValues &values, + bool updateWorkspace = true); void showNotice(QString message, bool error = true); + [[nodiscard]] bool sendNodeAction(nodegraph::NodeAction action, + QString rejection); + [[nodiscard]] bool sendRuntimeAction(nodegraph::RuntimeAction action, + QString rejection); + [[nodiscard]] nodegraph::NodeRef activeTurn() const; + [[nodiscard]] nodegraph::NodeRef + threadById(const std::string &id, bool *busy = nullptr) const; + [[nodiscard]] std::optional + pendingRequest(const std::string &requestKey = {}, bool *busy = nullptr); + void hydrateSelectedThreadIfNeeded(nodegraph::NodeRef thread); void beginNewThreadDialog(); - void renameThreadDialog(const std::string &threadId); - void confirmDeleteThread(const std::string &threadId); + void renameThreadDialog(const nodegraph::NodeRef &thread); + void confirmDeleteThread(const nodegraph::NodeRef &thread); [[nodiscard]] bool submitPrompt(QString prompt, std::vector attachments); void chooseAttachments(); + void recoverPrompt(const nodegraph::NodeRef &prompt); void reviewPending(const std::string &requestKey); void acceptPending(const std::string &requestKey); void rejectPending(const std::string &requestKey); void respondToFirstPending(bool approve); - [[nodiscard]] const UiPendingRequestView * - pendingRequest(const std::string &requestKey) const; ShellWidget *owner = nullptr; FrontendSession &session; - UiSession uiSession; + ui::NodeGraphUiAdapter uiAdapter; std::shared_ptr alive; - const UiSessionView *renderedView = nullptr; - std::optional settingsSnapshot; - std::optional statusSnapshot; - std::optional attentionSnapshot; - std::optional optimisticSnapshot; - std::optional scheduledLogicWakeup; + std::optional newThreadDraft; + std::string creationDraftCorrelation; + std::uint64_t nextCreationDraftSerial = 1; + std::string optimisticCreationThreadId; + bool creationInFlight = false; + std::string selectedGraphThreadId; + nodegraph::NodeRef boundGraphThread; + // The established view stages a newly selected hydration behind the last + // complete conversation. This is UI coordination state, not another model: + // the actual cards and their snapshot remain owned by ConversationView. + nodegraph::NodeRef presentedGraphThread; + std::unordered_map + conversationHistory; + nodegraph::NodeRef attentionInteraction; + std::map> + retainedRenames; + std::map retainedInteractionResponses; + std::optional retainedConnectionSelection; bool renderScheduled = false; + bool graphPanesBound = false; + bool graphBindingScheduled = false; + bool paneCommitScheduled = false; + bool pendingThreadPane = false; + std::vector pendingThreadRows; + bool pendingConversation = false; + std::vector pendingConversationItems; + bool pendingInspector = false; + bool pendingChrome = false; + bool draftSelectionScheduled = false; + bool graphFallbackScheduled = false; + unsigned draftSelectionRetriesRemaining = 0; + std::uint64_t lastNoticeSerial = 0; + std::uint64_t lastSelectionSerial = 0; + std::uint64_t modelCatalogRevision = 0; + std::uint64_t permissionProfileCatalogRevision = 0; + bool modelCatalogInitialized = false; + bool modelCatalogPresent = false; + bool permissionProfileCatalogInitialized = false; + bool permissionProfileCatalogPresent = false; + std::optional renderedChrome; + std::uint64_t shellRenderCommits = 0; + std::uint64_t threadPaneRoutes = 0; + std::uint64_t conversationRoutes = 0; + std::uint64_t inspectorRoutes = 0; middle::MiddleRegionWidget *middleRegion = nullptr; QPushButton *restoreSidebarButton = nullptr; @@ -303,27 +1157,62 @@ void ShellWidget::Impl::buildUi() { connectionButton->setFixedHeight(32); auto *connectionMenu = new QMenu(connectionButton); connectionMenu->addAction(QStringLiteral("Configure..."), owner, [this] { - if (!renderedView || - !renderedView->status.connectionSettings.is_object() || - renderedView->status.connectionSettings.empty()) { + nlohmann::json settings = nlohmann::json::object(); + { + auto read = session.nodeGraph().tryRead(); + if (!read) { + scheduleRender(); + showNotice(QStringLiteral("Connection state is busy; try again.")); + return; + } + const nodegraph::NodeRef connection = + read->find({nodegraph::NodeKind::Connection, "connection"}); + if (connection) { + const auto state = read->state(connection); + if (const nodegraph::Value *value = graphField(*state, "settings")) + settings = widgetJson(*value); + } + } + if (!settings.is_object() || settings.empty()) { showNotice(QStringLiteral("Connection settings are not available yet.")); return; } - ConnectionDialog dialog(renderedView->status.connectionSettings, owner); + if (retainedConnectionSelection && + connectionSettingsContainSelection(settings, + *retainedConnectionSelection)) + retainedConnectionSelection.reset(); + if (retainedConnectionSelection) + applyConnectionSelection(settings, *retainedConnectionSelection); + ConnectionDialog dialog(std::move(settings), owner); if (dialog.exec() != QDialog::Accepted) return; - uiSession.configureConnection(dialog.selection()); + retainedConnectionSelection = dialog.selection(); + nodegraph::RuntimeAction action; + action.kind = nodegraph::RuntimeActionKind::ConfigureConnection; + action.payload = actionObject(*retainedConnectionSelection); + static_cast(sendRuntimeAction( + std::move(action), + QStringLiteral("Connection settings were not sent; try again."))); }); connectionMenu->addSeparator(); - connectAction = connectionMenu->addAction( - QStringLiteral("Connect"), owner, - [this] { uiSession.connectTransport(); }); + connectAction = + connectionMenu->addAction(QStringLiteral("Connect"), owner, [this] { + static_cast(sendRuntimeAction( + {nodegraph::RuntimeActionKind::Connect}, + QStringLiteral("Connect request was not admitted; try again."))); + }); disconnectAction = - connectionMenu->addAction(QStringLiteral("Disconnect"), owner, - [this] { uiSession.disconnectTransport(); }); - reconnectAction = connectionMenu->addAction( - QStringLiteral("Reconnect"), owner, - [this] { uiSession.reconnectTransport(); }); + connectionMenu->addAction(QStringLiteral("Disconnect"), owner, [this] { + static_cast(sendRuntimeAction( + {nodegraph::RuntimeActionKind::Disconnect}, + QStringLiteral("Disconnect request was not admitted; try again."))); + }); + reconnectAction = + connectionMenu->addAction(QStringLiteral("Reconnect"), owner, [this] { + static_cast(sendRuntimeAction( + {nodegraph::RuntimeActionKind::Reconnect}, + QStringLiteral("Reconnect request was not admitted; try again."))); + }); connectionButton->setMenu(connectionMenu); auto *connectionControl = new QWidget; auto *connectionLayout = new QHBoxLayout(connectionControl); @@ -381,27 +1270,77 @@ void ShellWidget::Impl::buildUi() { void ShellWidget::Impl::connectUi() { middle::ThreadPane::Actions threadActions; threadActions.newThread = [this] { beginNewThreadDialog(); }; - threadActions.refresh = [this] { uiSession.refreshThreads(); }; + threadActions.refresh = [this] { + static_cast(sendRuntimeAction( + {nodegraph::RuntimeActionKind::RefreshThreads}, + QStringLiteral("Thread refresh was not admitted; try again."))); + }; threadActions.hide = [this] { middleRegion->showSidebar(false); }; threadActions.select = [this](const std::string &id) { - if (id == DraftThreadId && renderedView && - renderedView->newThreadIntent) { + if (id == DraftThreadId && newThreadDraft) { render(); return; } - uiSession.selectThread(id); + const nodegraph::NodeRef thread = threadById(id); + if (!thread) + return; + if (!creationInFlight && !optimisticCreationThreadId.empty()) { + middleRegion->threads().confirmOptimisticThread( + optimisticCreationThreadId); + optimisticCreationThreadId.clear(); + creationDraftCorrelation.clear(); + } + selectedGraphThreadId = thread->id().canonical; + newThreadDraft.reset(); + bindGraphPanes(thread); + hydrateSelectedThreadIfNeeded(thread); + render(); + }; + threadActions.reload = [this](const std::string &id) { + const nodegraph::NodeRef thread = threadById(id); + if (!thread) + return; + nodegraph::NodeAction action{thread, nodegraph::NodeActionKind::Reload}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("Thread reload was not admitted; try again."))); + }; + threadActions.rename = [this](const std::string &id) { + if (const nodegraph::NodeRef thread = threadById(id)) + renameThreadDialog(thread); + }; + threadActions.fork = [this](const std::string &id) { + const nodegraph::NodeRef thread = threadById(id); + if (!thread) + return; + nodegraph::NodeAction action{thread, nodegraph::NodeActionKind::Fork}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("Thread fork was not admitted; try again."))); }; - threadActions.reload = - [this](const std::string &id) { uiSession.reloadThread(id); }; - threadActions.rename = - [this](const std::string &id) { renameThreadDialog(id); }; - threadActions.fork = - [this](const std::string &id) { uiSession.forkThread(id); }; threadActions.toggleArchive = [this](const std::string &id) { - uiSession.toggleThreadArchive(id); + const nodegraph::NodeRef thread = threadById(id); + if (!thread) + return; + bool archived = false; + if (auto read = session.nodeGraph().tryRead()) + archived = graphBool(graphField(*read->state(thread), "archived")); + else { + showNotice(QStringLiteral( + "Thread state is busy; no archive action was sent.")); + return; + } + nodegraph::NodeAction action{ + thread, archived ? nodegraph::NodeActionKind::Unarchive + : nodegraph::NodeActionKind::Archive}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("Archive request was not admitted; try again."))); + }; + threadActions.remove = [this](const std::string &id) { + if (const nodegraph::NodeRef thread = threadById(id)) + confirmDeleteThread(thread); }; - threadActions.remove = - [this](const std::string &id) { confirmDeleteThread(id); }; middleRegion->threads().setActions(std::move(threadActions)); middle::ComposerPane::Actions composerActions; @@ -409,19 +1348,67 @@ void ShellWidget::Impl::connectUi() { std::vector attachments) { return submitPrompt(std::move(prompt), std::move(attachments)); }; - composerActions.stop = [this] { uiSession.interruptTurn(); }; + composerActions.stop = [this] { + nodegraph::NodeRef turn = activeTurn(); + if (!turn) { + showNotice(QStringLiteral("No active turn is available to stop.")); + return; + } + nodegraph::NodeAction action{turn, + nodegraph::NodeActionKind::InterruptTurn}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("Stop request was not admitted; try again."))); + }; composerActions.attach = [this] { chooseAttachments(); }; composerActions.accept = [this] { respondToFirstPending(true); }; composerActions.review = [this] { respondToFirstPending(true); }; composerActions.deny = [this] { respondToFirstPending(false); }; middleRegion->composer().setActions(std::move(composerActions)); - middleRegion->conversation().setLoadMoreAction( - [this] { uiSession.loadEarlierConversation(); }); + middleRegion->conversation().setLoadMoreAction([this] { + if (!boundGraphThread) + return; + const auto info = uiAdapter.conversationInfo(boundGraphThread); + if (!info) { + showNotice(QStringLiteral( + "Conversation state is busy; no history request was sent.")); + return; + } + ConversationHistoryWindow &history = + conversationHistory[boundGraphThread->id().canonical]; + const bool retainedHistoryAvailable = + history.effective < info->authoritativeItemCount; + history.requested += middle::AuthoritativeHistoryPageSize; + history.effective += middle::AuthoritativeHistoryPageSize; + pendingConversation = true; + pendingConversationItems.clear(); + commitPendingPanes(); + if (retainedHistoryAvailable || !info->providerHasMore) + return; + nodegraph::NodeAction action{boundGraphThread, + nodegraph::NodeActionKind::LoadHistory}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral("History request was not admitted; try again."))); + }); + middleRegion->conversation().setPromptMaterializedAction( + [this](nodegraph::NodeRef localPrompt) { + nodegraph::NodeAction action{ + std::move(localPrompt), + nodegraph::NodeActionKind::PromptMaterialized}; + return sendNodeAction(std::move(action), {}); + }); + middleRegion->conversation().setPromptRecoveryAction( + [this](nodegraph::NodeRef prompt) { recoverPrompt(prompt); }); middleRegion->inspector().setRequestActions( [this](const std::string &id) { reviewPending(id); }, [this](const std::string &id) { acceptPending(id); }, [this](const std::string &id) { rejectPending(id); }); + middleRegion->inspector().setRefreshRequestedAction([this] { + pendingInspector = true; + schedulePaneCommit(); + }); middleRegion->setPaneVisibilityAction( [this](bool sidebarVisible, bool inspectorVisible) { restoreSidebarButton->setVisible(!sidebarVisible); @@ -431,13 +1418,36 @@ void ShellWidget::Impl::connectUi() { connect(restoreSidebarButton, &QPushButton::clicked, owner, [this] { middleRegion->showSidebar(true); }); connect(restoreInspectorButton, &QPushButton::clicked, owner, - [this] { middleRegion->showInspector(true); }); + [this] { + middleRegion->showInspector(true); + pendingInspector = true; + schedulePaneCommit(); + }); connect(requestButton, &QPushButton::clicked, owner, [this] { middleRegion->showInspector(true); middleRegion->inspector().tabs()->setCurrentIndex(3); + pendingInspector = true; + schedulePaneCommit(); + }); + connect(controllerButton, &QPushButton::clicked, owner, [this] { + bool controller = false; + auto read = session.nodeGraph().tryRead(); + if (!read) { + showNotice(QStringLiteral( + "Connection state is busy; no controller request was sent.")); + return; + } + const nodegraph::NodeRef connection = + read->find({nodegraph::NodeKind::Connection, "connection"}); + if (connection) + controller = graphString(graphField(*read->state(connection), "role")) == + "controller"; + read.reset(); + static_cast(sendRuntimeAction( + {controller ? nodegraph::RuntimeActionKind::ReleaseController + : nodegraph::RuntimeActionKind::ClaimController}, + QStringLiteral("Controller request was not admitted; try again."))); }); - connect(controllerButton, &QPushButton::clicked, owner, - [this] { uiSession.toggleController(); }); qApp->installEventFilter(owner); } @@ -445,34 +1455,13 @@ void ShellWidget::Impl::showNotice(QString message, bool error) { middleRegion->showNotice(std::move(message), error); } -void ShellWidget::Impl::scheduleLogicWakeup(std::int64_t atMilliseconds) { - if (scheduledLogicWakeup && *scheduledLogicWakeup <= atMilliseconds) - return; - scheduledLogicWakeup = atMilliseconds; - const std::int64_t now = QDateTime::currentMSecsSinceEpoch(); - const std::int64_t requestedDelay = - atMilliseconds > now ? atMilliseconds - now : 0; - const int delay = - requestedDelay > std::numeric_limits::max() - ? std::numeric_limits::max() - : static_cast(requestedDelay); - const auto token = alive; - QTimer::singleShot(delay, Qt::PreciseTimer, owner, - [this, token, atMilliseconds] { - if (!*token || scheduledLogicWakeup != atMilliseconds) - return; - scheduledLogicWakeup.reset(); - uiSession.tick(); - }); -} - void ShellWidget::Impl::scheduleRender() { if (renderScheduled) return; renderScheduled = true; const auto token = alive; - // Stream deltas can arrive in bursts. Reconcile the toolkit once per display - // interval while UiSession still observes every presentation frame. + // Stream deltas can arrive in bursts. Reconcile cheap shell chrome once per + // display interval while graph-bound panes schedule their own bounded work. QTimer::singleShot(16, Qt::PreciseTimer, owner, [this, token] { if (!*token) return; @@ -481,119 +1470,1076 @@ void ShellWidget::Impl::scheduleRender() { }); } -void ShellWidget::Impl::synchronizeOptimisticThread( - const std::optional &optimistic) { - if (optimisticSnapshot == optimistic) +void ShellWidget::Impl::scheduleGraphBinding(bool immediate) { + if (graphBindingScheduled) return; + graphBindingScheduled = true; + const auto token = alive; + const int delay = immediate ? 0 : GraphRetryDelayMilliseconds; + QTimer::singleShot(delay, owner, [this, token] { + if (!*token) + return; + graphBindingScheduled = false; + runGraphBinding(); + }); +} - if (!optimistic) { - if (optimisticSnapshot) { - const std::string id = optimisticSnapshot->threadId.empty() - ? optimisticSnapshot->key - : optimisticSnapshot->threadId; - middleRegion->threads().confirmOptimisticThread(id); +void ShellWidget::Impl::runGraphBinding() { + nodegraph::NodeRef selectedThread; + if (!selectedGraphThreadId.empty()) { + { + auto graphRead = session.nodeGraph().tryRead(); + if (!graphRead) { + scheduleGraphBinding(false); + return; + } + selectedThread = + graphRead->find({nodegraph::NodeKind::Thread, selectedGraphThreadId}); } - optimisticSnapshot.reset(); + } + + const bool reboundSelectedThread = + selectedThread && selectedThread != boundGraphThread; + bindGraphPanes(std::move(selectedThread)); + if (reboundSelectedThread) { + nodegraph::NodeAction action{boundGraphThread, + nodegraph::NodeActionKind::Hydrate}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral( + "Thread loading was not admitted; select Reload to retry."))); + } + + if (newThreadDraft) + scheduleDraftSelection(); +} + +void ShellWidget::Impl::bindGraphPanes(nodegraph::NodeRef selectedThread) { + if (graphPanesBound && boundGraphThread == selectedThread) return; + boundGraphThread = std::move(selectedThread); + graphPanesBound = true; + if (auto threads = uiAdapter.threads(boundGraphThread)) + middleRegion->threads().refresh(*threads); + const bool conversationReady = refreshConversation(); + const bool inspectorReady = refreshInspector(); + // The immediate atomic bind already represents the newest graph state. + // Any frame-coalesced work queued for the previous selection is obsolete. + pendingThreadPane = false; + pendingThreadRows.clear(); + pendingConversation = !conversationReady; + pendingConversationItems.clear(); + pendingInspector = !inspectorReady; + if (pendingConversation || pendingInspector) + schedulePaneCommit(); +} + +bool ShellWidget::Impl::refreshConversation() { + if (!boundGraphThread) { + static_cast(middleRegion->conversation().reconcile({})); + presentedGraphThread.reset(); + return true; } - if (!optimisticSnapshot || optimisticSnapshot->key != optimistic->key) { - if (optimisticSnapshot) { - const std::string previousId = optimisticSnapshot->threadId.empty() - ? optimisticSnapshot->key - : optimisticSnapshot->threadId; - middleRegion->threads().confirmOptimisticThread(previousId); + const auto info = uiAdapter.conversationInfo(boundGraphThread); + if (!info) + return false; + const std::string &threadId = boundGraphThread->id().canonical; + ConversationHistoryWindow &history = conversationHistory[threadId]; + const bool following = + middleRegion->conversation().modeForThread(threadId) == + middle::ConversationView::Mode::Following; + if (!following && + info->authoritativeItemCount > history.lastAuthoritativeCount) { + history.effective += + info->authoritativeItemCount - history.lastAuthoritativeCount; + } else if (following) { + history.effective = history.requested; + } + history.lastAuthoritativeCount = info->authoritativeItemCount; + + if (!info->readyForDisplay) { + middleRegion->conversation().setEmptyMessage( + info->hydrationFailed + ? QStringLiteral("Thread loading failed. Select Reload to retry.") + : QStringLiteral("Loading conversation…")); + // A cold selection may show one stable loading surface. When a complete + // conversation is already painted, retain it until the replacement is + // ready so the user never sees an empty intermediate layout. A terminal + // hydration failure is itself the final selected-thread presentation. + if (!presentedGraphThread || presentedGraphThread == boundGraphThread || + info->hydrationFailed) { + middle::ConversationSnapshot loading; + loading.threadId = threadId; + static_cast(middleRegion->conversation().reconcile(loading)); + if (presentedGraphThread != boundGraphThread) { + presentedGraphThread = boundGraphThread; + renderedChrome.reset(); + } } - middleRegion->threads().beginOptimisticThread( - optimistic->key, optimistic->title, optimistic->workspace); + return true; } - std::string renderedId = optimistic->key; - if (!optimistic->threadId.empty()) { - middleRegion->threads().promoteOptimisticThread(optimistic->key, - optimistic->threadId); - renderedId = optimistic->threadId; + auto snapshot = uiAdapter.conversation( + boundGraphThread, history.effective, + {middleRegion->conversation().presentationOptions().showReasoning, + middleRegion->conversation().presentationOptions().showCodexUpdates}); + if (!snapshot) + return false; + middleRegion->conversation().setEmptyMessage( + QStringLiteral("No materialized activity.")); + middleRegion->conversation().reconcileStaged(std::move(*snapshot)); + if (presentedGraphThread != boundGraphThread) { + presentedGraphThread = boundGraphThread; + renderedChrome.reset(); + scheduleRender(); } - if (optimistic->phase == UiOptimisticThreadPhase::Failed) - middleRegion->threads().failOptimisticThread(renderedId); - else if (optimistic->phase == UiOptimisticThreadPhase::Confirmed) - middleRegion->threads().confirmOptimisticThread(renderedId); - optimisticSnapshot = optimistic; + return true; } -void ShellWidget::Impl::render() { - bool focusComposer = false; - for (const UiEffect effect : uiSession.takeEffects()) { - switch (effect) { - case UiEffect::ClearComposerDraft: - middleRegion->composer().clearDraft(); +bool ShellWidget::Impl::refreshInspector() { + nodegraph::NodeRef inspectorThread = boundGraphThread; + if (inspectorThread) { + const auto info = uiAdapter.conversationInfo(inspectorThread); + if (!info) + return false; + if (!info->readyForDisplay && presentedGraphThread && + presentedGraphThread != inspectorThread && + !info->hydrationFailed) + return true; + if (!info->readyForDisplay) + inspectorThread.reset(); + } + middle::InspectorPane &pane = middleRegion->inspector(); + std::optional projection; + switch (pane.tabs()->currentIndex()) { + case 0: + projection = ui::InspectorProjection::Plan; + break; + case 1: + projection = ui::InspectorProjection::Agents; + break; + case 2: + projection = ui::InspectorProjection::Changes; + break; + case 3: + projection = ui::InspectorProjection::Requests; + break; + case 4: + if (auto *infoStack = pane.findChild( + QStringLiteral("infoStack")); + infoStack && infoStack->currentIndex() != 0) + projection = ui::InspectorProjection::State; + break; + default: + break; + } + if (!projection) + return true; + if (auto snapshot = uiAdapter.inspector(inspectorThread, *projection)) { + pane.refresh(*snapshot, *projection); + return true; + } + return false; +} + +void ShellWidget::Impl::schedulePaneCommit(bool immediate) { + if (paneCommitScheduled) + return; + paneCommitScheduled = true; + const auto token = alive; + QTimer::singleShot(immediate ? 0 : 16, Qt::PreciseTimer, owner, + [this, token] { + if (!*token) + return; + paneCommitScheduled = false; + commitPendingPanes(); + }); +} + +void ShellWidget::Impl::commitPendingPanes() { + bool retry = false; + if (!pendingThreadPane && !pendingThreadRows.empty()) { + std::vector rows = std::move(pendingThreadRows); + pendingThreadRows.clear(); + bool structuralFallback = false; + for (const nodegraph::NodeRef &thread : rows) { + const auto row = uiAdapter.threadRow(thread); + if (!row || !middleRegion->threads().applyRowPresentation(*row)) { + structuralFallback = true; + break; + } + } + if (structuralFallback) { + pendingThreadPane = true; + } else { + ++threadPaneRoutes; + owner->setProperty("threadPaneRoutes", + static_cast(threadPaneRoutes)); + owner->setProperty( + "targetedThreadPaneRoutes", + owner->property("targetedThreadPaneRoutes").toULongLong() + 1); + } + } + if (pendingThreadPane) { + if (auto threads = uiAdapter.threads(boundGraphThread)) { + pendingThreadPane = false; + pendingThreadRows.clear(); + ++threadPaneRoutes; + owner->setProperty("threadPaneRoutes", + static_cast(threadPaneRoutes)); + middleRegion->threads().refresh(*threads); + if (newThreadDraft) + scheduleDraftSelection(); + } else { + retry = true; + } + } + if (!pendingConversation && !pendingConversationItems.empty()) { + std::vector items = + std::move(pendingConversationItems); + pendingConversationItems.clear(); + bool requiresStructuralReconcile = false; + const auto options = middleRegion->conversation().presentationOptions(); + for (const nodegraph::NodeRef &item : items) { + const auto card = uiAdapter.card( + boundGraphThread, item, + {options.showReasoning, options.showCodexUpdates}); + if (!card || + !middleRegion->conversation().applyCardPresentation(*card)) { + requiresStructuralReconcile = true; + break; + } + } + if (requiresStructuralReconcile) { + pendingConversation = true; + } else { + ++conversationRoutes; + owner->setProperty("conversationRoutes", + static_cast(conversationRoutes)); + owner->setProperty( + "targetedConversationRoutes", + owner->property("targetedConversationRoutes").toULongLong() + 1); + } + } + if (pendingConversation) { + if (refreshConversation()) { + pendingConversation = false; + pendingConversationItems.clear(); + ++conversationRoutes; + owner->setProperty("conversationRoutes", + static_cast(conversationRoutes)); + } else { + retry = true; + } + } + if (pendingInspector) { + if (refreshInspector()) { + pendingInspector = false; + ++inspectorRoutes; + owner->setProperty("inspectorRoutes", + static_cast(inspectorRoutes)); + } else { + retry = true; + } + } + if (pendingChrome) { + pendingChrome = false; + render(); + } + if (retry || pendingThreadPane || !pendingThreadRows.empty() || + pendingConversation || + !pendingConversationItems.empty() || pendingInspector || pendingChrome) + schedulePaneCommit(); +} + +void ShellWidget::Impl::handleGraphChanged( + const nodegraph::GraphChanged &change) { + const bool updateChrome = + shellChromeAffected(change, session.nodeGraph(), boundGraphThread); + const bool optimisticCreationWasActive = !optimisticCreationThreadId.empty(); + for (const nodegraph::NodeRef &removed : change.removed) { + if (!removed) + continue; + if (removed->id().kind == nodegraph::NodeKind::Thread) + conversationHistory.erase(removed->id().canonical); + if (removed->id().kind == nodegraph::NodeKind::Interaction) + retainedInteractionResponses.erase(removed->id().canonical); + retainedRenames.erase(removed.get()); + } + const nodegraph::NodeRef removedBoundThread = + boundGraphThread && std::ranges::find(change.removed, boundGraphThread) != + change.removed.end() + ? boundGraphThread + : nodegraph::NodeRef{}; + const bool stagedPresentationInvalidated = + presentedGraphThread && presentedGraphThread != boundGraphThread && + !change.removed.empty(); + const bool providerReset = + removedBoundThread && + std::ranges::any_of(change.affected, [](const auto &node) { + return node && node->id().kind == nodegraph::NodeKind::Connection; + }); + const ConversationRoute conversation = + conversationRoute(change, session.nodeGraph(), boundGraphThread); + InspectorDependency inspectorDependency = InspectorDependency::None; + middle::InspectorPane &inspectorPane = middleRegion->inspector(); + if (inspectorPane.isVisible()) { + switch (inspectorPane.tabs()->currentIndex()) { + case 0: + inspectorDependency = InspectorDependency::Plan; break; - case UiEffect::FocusComposer: - focusComposer = true; + case 1: + inspectorDependency = InspectorDependency::Agents; break; - case UiEffect::PrepareLocalPromptAdmission: - middleRegion->conversation().prepareForLocalPromptAdmission(); + case 2: + inspectorDependency = InspectorDependency::Changes; break; + case 3: + inspectorDependency = InspectorDependency::Requests; + break; + case 4: + if (auto *infoStack = inspectorPane.findChild( + QStringLiteral("infoStack"))) { + if (infoStack->currentIndex() == 1) + inspectorDependency = InspectorDependency::State; + else if (infoStack->currentIndex() == 2) + inspectorDependency = InspectorDependency::Protocol; + } + break; + default: + break; + } + } + const ThreadPaneRoute threads = threadPaneRoute( + change, session.nodeGraph(), middleRegion->threads().currentSortCriterion()); + if (threads.structural) { + pendingThreadPane = true; + pendingThreadRows.clear(); + } else if (threads.affected && !pendingThreadPane) { + for (const nodegraph::NodeRef &thread : threads.rows) + if (std::ranges::find(pendingThreadRows, thread) == + pendingThreadRows.end()) + pendingThreadRows.push_back(thread); + } + if (stagedPresentationInvalidated || conversation.structural) { + pendingConversation = true; + pendingConversationItems.clear(); + } else if (conversation.affected && !pendingConversation) { + for (const nodegraph::NodeRef &item : conversation.items) + if (std::ranges::find(pendingConversationItems, item) == + pendingConversationItems.end()) + pendingConversationItems.push_back(item); + } + pendingInspector = + pendingInspector || + inspectorAffected(change, session.nodeGraph(), boundGraphThread, + inspectorDependency) || + stagedPresentationInvalidated; + pendingChrome = pendingChrome || updateChrome; + if (change.rescanRequired || + containsKind(change, {nodegraph::NodeKind::Thread})) + reconcileOptimisticCreation(change); + if (graphUiFallbackAffected(change, session.nodeGraph())) + reconcileGraphUiFallback(); + + // Promotion may already have rebound a removed optimistic thread. Otherwise + // clear all pane-held references synchronously before FrontendSession can + // acknowledge retirement. Preserve the canonical selection only across a + // provider generation reset so recreation can trigger one fresh hydration. + if (removedBoundThread && boundGraphThread == removedBoundThread) { + if (!providerReset) + selectedGraphThreadId.clear(); + bindGraphPanes({}); + } else if (stagedPresentationInvalidated) { + // Removal notifications must release every card-held NodeRef before the + // worker retirement acknowledgement. Fall back to the selected thread's + // stable loading surface rather than retaining the outgoing snapshot. + presentedGraphThread.reset(); + renderedChrome.reset(); + pendingChrome = true; + } + + // Retirement must not outlive presentation references. Ordinary state + // traffic is merged to one old-UI reconciliation per display interval. + if (!change.removed.empty()) + commitPendingPanes(); + else if (pendingThreadPane || !pendingThreadRows.empty() || + pendingConversation || + !pendingConversationItems.empty() || pendingInspector || + pendingChrome) + schedulePaneCommit(); + + const bool selectedChanged = !graphPanesBound || change.rescanRequired || + (!boundGraphThread && !selectedGraphThreadId.empty() && + std::ranges::any_of(change.affected, [this](const auto &node) { + return node && node->id().kind == nodegraph::NodeKind::Thread && + node->id().canonical == selectedGraphThreadId; + })) || + std::ranges::any_of(change.removed, [this](const auto &node) { + return node && node->id().kind == nodegraph::NodeKind::Thread && + node->id().canonical == selectedGraphThreadId; + }); + if (selectedChanged) + scheduleGraphBinding(); + if (removedBoundThread || optimisticCreationWasActive || + !optimisticCreationThreadId.empty()) + scheduleRender(); +} + +void ShellWidget::Impl::reconcileGraphUiFallback() { + std::string notice; + bool noticeError = true; + std::uint64_t noticeSerial = lastNoticeSerial; + nodegraph::NodeRef selection; + std::uint64_t selectionSerial = lastSelectionSerial; + if (auto read = session.nodeGraph().tryRead()) { + const auto considerNotice = [&](std::string_view id) { + if (const nodegraph::NodeRef node = + read->find({nodegraph::NodeKind::Notice, std::string(id)})) { + const auto state = read->state(node); + const std::int64_t rawSerial = + graphInteger(graphField(*state, "noticeSerial")).value_or(0); + if (rawSerial > 0 && + static_cast(rawSerial) > noticeSerial) { + noticeSerial = static_cast(rawSerial); + notice = graphString(graphField(*state, "noticeText")); + if (notice.empty()) + notice = graphString(graphField(*state, "message")); + noticeError = + graphString(graphField(*state, "severity")) != "warning"; + } + } + }; + considerNotice("local-worker-notice"); + considerNotice("provider-notice"); + if (const nodegraph::NodeRef runtime = + read->find({nodegraph::NodeKind::Runtime, "runtime"})) { + const auto state = read->state(runtime); + const std::int64_t rawSerial = + graphInteger(graphField(*state, "uiSelectionSerial")).value_or(0); + if (rawSerial > 0 && + static_cast(rawSerial) > lastSelectionSerial) { + const std::vector targets = + read->related(runtime, nodegraph::RelationKind::UiSelectionTarget); + if (!targets.empty() && !read->removed(targets.front())) { + selectionSerial = static_cast(rawSerial); + selection = targets.front(); + } + } } + } else { + scheduleRender(); + if (!graphFallbackScheduled) { + graphFallbackScheduled = true; + const auto token = alive; + QTimer::singleShot(GraphRetryDelayMilliseconds, owner, [this, token] { + if (!*token) + return; + graphFallbackScheduled = false; + reconcileGraphUiFallback(); + }); + } + return; } - for (UiNotice ¬ice : uiSession.takeNotices()) - showNotice(text(notice.message), notice.error); - const std::string fallbackWorkspace = utf8(QDir::currentPath()); - const std::string draftWorkspace = - middleRegion->composer().turnSettings()->workspace(fallbackWorkspace); - const std::string conversationKey = uiSession.conversationKey(); - const bool following = - middleRegion->conversation().modeForThread(conversationKey) == - middle::ConversationView::Mode::Following; - const UiSessionView &view = - uiSession.refreshView(following, draftWorkspace); - renderedView = &view; + if (noticeSerial > lastNoticeSerial) { + lastNoticeSerial = noticeSerial; + if (!notice.empty()) + showNotice(text(notice), noticeError); + } + if (selection && selectionSerial > lastSelectionSerial) { + lastSelectionSerial = selectionSerial; + selectGraphThread(std::move(selection)); + } +} + +void ShellWidget::Impl::selectGraphThread(nodegraph::NodeRef thread) { + if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) + return; + { + auto read = session.nodeGraph().tryRead(); + if (!read) { + QTimer::singleShot(GraphRetryDelayMilliseconds, owner, + [this, thread = std::move(thread)]() mutable { + selectGraphThread(std::move(thread)); + }); + return; + } + if (read->find(thread->id()) != thread || read->removed(thread)) + return; + } + const std::string targetId = thread->id().canonical; + if (!optimisticCreationThreadId.empty() && + middleRegion->threads().isOptimisticThread(optimisticCreationThreadId)) { + bool creationTarget = optimisticCreationThreadId == targetId; + if (!creationTarget) { + auto read = session.nodeGraph().tryRead(); + if (!read) { + QTimer::singleShot(GraphRetryDelayMilliseconds, owner, + [this, thread = std::move(thread)]() mutable { + selectGraphThread(std::move(thread)); + }); + return; + } + if (read->find(thread->id()) != thread || read->removed(thread)) + return; + for (const nodegraph::NodeRef &prompt : + read->related(thread, nodegraph::RelationKind::PendingPrompt)) { + if (prompt && !read->removed(prompt) && + graphString( + graphField(*read->state(prompt), "creationCorrelation")) == + creationDraftCorrelation) { + creationTarget = true; + break; + } + } + } + if (!creationTarget) + return; + if (creationTarget) { + const bool draftStillSelected = + middleRegion->threads().visiblySelectedThreadId() == + optimisticCreationThreadId; + if (optimisticCreationThreadId != targetId) { + middleRegion->threads().promoteOptimisticThread( + optimisticCreationThreadId, targetId); + optimisticCreationThreadId = targetId; + } + newThreadDraft.reset(); + if (!draftStillSelected) + return; + } + } + newThreadDraft.reset(); + selectedGraphThreadId = targetId; + bindGraphPanes(std::move(thread)); +} - synchronizeOptimisticThread(view.optimisticThread); - middleRegion->threads().refresh(view.threads); +void ShellWidget::Impl::reconcileOptimisticCreation( + const nodegraph::GraphChanged &change) { + if (optimisticCreationThreadId.empty()) + return; - middleRegion->conversation().setEmptyMessage( - text(view.conversation.emptyMessage)); - middleRegion->conversation().reconcile(view.conversation.snapshot); - if (view.conversation.mode == UiConversationMode::Thread) { - const QString activity = view.conversation.lastActivityAt - ? lastActivityText( - *view.conversation.lastActivityAt) - : QString{}; - middleRegion->setThreadHeading( - text(view.conversation.title), - text(view.conversation.workspace), activity, - text(view.conversation.status), text(view.conversation.statusTone)); - } else if (view.conversation.mode == UiConversationMode::NewThread) { - middleRegion->setThreadHeading(text(view.conversation.title), - text(view.conversation.workspace)); + nodegraph::NodeRef prompt; + nodegraph::NodeRef thread; + std::string dispatchState; + std::uint64_t newestSubmission = 0; + if (auto read = session.nodeGraph().tryRead()) { + const auto consider = [&](const nodegraph::NodeRef &candidate) { + if (!candidate || candidate->id().kind != nodegraph::NodeKind::Item || + read->find(candidate->id()) != candidate) + return; + const auto state = read->state(candidate); + if (graphString(graphField(*state, "type")) != "localPrompt" || + graphString(graphField(*state, "creationCorrelation")) != + creationDraftCorrelation) + return; + const std::int64_t rawSubmission = + graphInteger(graphField(*state, "submissionId")).value_or(0); + const std::uint64_t submission = + rawSubmission < 0 ? 0 : static_cast(rawSubmission); + nodegraph::NodeRef candidateTurn = read->parent(candidate); + nodegraph::NodeRef candidateThread = + candidateTurn ? read->parent(candidateTurn) : nodegraph::NodeRef{}; + if (!candidateThread || + candidateThread->id().kind != nodegraph::NodeKind::Thread || + (prompt && submission < newestSubmission)) + return; + prompt = candidate; + thread = std::move(candidateThread); + newestSubmission = submission; + dispatchState = graphString(graphField(*state, "dispatchState")); + }; + if (change.rescanRequired) { + const nodegraph::NodeRef runtime = + read->find({nodegraph::NodeKind::Runtime, "runtime"}); + for (const nodegraph::NodeRef &candidate : + read->related(runtime, nodegraph::RelationKind::PendingPrompt)) + consider(candidate); + } else { + for (const nodegraph::NodeRef &candidate : change.affected) + consider(candidate); + } } else { - middleRegion->setThreadHeading(text(view.conversation.title), {}); + QTimer::singleShot(GraphRetryDelayMilliseconds, owner, + [this, change] { reconcileOptimisticCreation(change); }); + return; + } + if (!prompt || !thread) + return; + + const std::string targetId = thread->id().canonical; + const bool selected = middleRegion->threads().visiblySelectedThreadId() == + optimisticCreationThreadId; + if (optimisticCreationThreadId != targetId && + middleRegion->threads().isOptimisticThread(optimisticCreationThreadId)) { + middleRegion->threads().promoteOptimisticThread(optimisticCreationThreadId, + targetId); + optimisticCreationThreadId = targetId; + } + if (selected) { + newThreadDraft.reset(); + selectedGraphThreadId = targetId; + bindGraphPanes(thread); + } + + if (dispatchState == "awaitingMaterialization") { + middleRegion->threads().confirmOptimisticThread(targetId); + optimisticCreationThreadId.clear(); + creationDraftCorrelation.clear(); + creationInFlight = false; + } else if (dispatchState == "failed" || dispatchState == "uncertain") { + middleRegion->threads().failOptimisticThread(targetId); + optimisticCreationThreadId.clear(); + creationDraftCorrelation.clear(); + creationInFlight = false; + } +} + +void ShellWidget::Impl::handleUiEffect(const nodegraph::UiEffect &effect) { + switch (effect.kind) { + case nodegraph::UiEffectKind::ShowNotice: { + const std::int64_t rawSerial = + graphInteger(graphField(effect.details, "serial")).value_or(0); + if (rawSerial > 0) { + const auto serial = static_cast(rawSerial); + if (serial <= lastNoticeSerial) + break; + lastNoticeSerial = serial; + } + showNotice(text(effect.text), + graphString(graphField(effect.details, "severity")) != + "warning"); + break; + } + case nodegraph::UiEffectKind::SelectThread: { + const std::int64_t rawSerial = + graphInteger(graphField(effect.details, "serial")).value_or(0); + if (rawSerial > 0) { + const auto serial = static_cast(rawSerial); + if (serial <= lastSelectionSerial) + break; + lastSelectionSerial = serial; + } + if (effect.target && + (*effect.target)->id().kind == nodegraph::NodeKind::Thread) + selectGraphThread(*effect.target); + break; + } + case nodegraph::UiEffectKind::ProtocolDiagnostic: + middleRegion->inspector().appendProtocolDiagnostic(effect); + return; + } + scheduleRender(); +} + +void ShellWidget::Impl::scheduleDraftSelection(bool replenishRetry) { + if (replenishRetry) + draftSelectionRetriesRemaining = 1; + if (draftSelectionScheduled || !graphPanesBound || !newThreadDraft) + return; + draftSelectionScheduled = true; + const auto token = alive; + const int delay = replenishRetry ? 0 : GraphRetryDelayMilliseconds; + QTimer::singleShot(delay, owner, [this, token] { + if (!*token) + return; + draftSelectionScheduled = false; + if (!graphPanesBound || !newThreadDraft) + return; + + auto *threadList = middleRegion->threads().findChild( + QStringLiteral("threadList")); + if (!threadList) + return; + for (int row = 0; row < threadList->count(); ++row) { + QListWidgetItem *item = threadList->item(row); + if (item->data(Qt::UserRole).toString() != + QString::fromUtf8(DraftThreadId)) + continue; + if (threadList->currentItem() != item) + threadList->setCurrentItem(item); + draftSelectionRetriesRemaining = 0; + return; + } + if (draftSelectionRetriesRemaining != 0) { + --draftSelectionRetriesRemaining; + scheduleDraftSelection(false); + } + }); +} + +bool ShellWidget::Impl::sendNodeAction(nodegraph::NodeAction action, + QString rejection) { + const nodegraph::ChannelSendStatus status = session.sendNodeAction(action); + if (!nodegraph::deliveryGuaranteed(status)) { + if (!rejection.isEmpty()) + showNotice(std::move(rejection)); + return false; + } + if (nodegraph::wakeFailed(status)) + showNotice(QStringLiteral( + "The action was admitted after a worker wake-up failure; bounded " + "fallback delivery is active.")); + return true; +} + +bool ShellWidget::Impl::sendRuntimeAction(nodegraph::RuntimeAction action, + QString rejection) { + const nodegraph::ChannelSendStatus status = session.sendRuntimeAction(action); + if (!nodegraph::deliveryGuaranteed(status)) { + if (!rejection.isEmpty()) + showNotice(std::move(rejection)); + return false; + } + if (nodegraph::wakeFailed(status)) + showNotice(QStringLiteral( + "The action was admitted after a worker wake-up failure; bounded " + "fallback delivery is active.")); + return true; +} + +void ShellWidget::Impl::hydrateSelectedThreadIfNeeded( + nodegraph::NodeRef thread) { + if (!thread || boundGraphThread != thread) + return; + auto read = session.nodeGraph().tryRead(); + if (!read) { + QTimer::singleShot(GraphRetryDelayMilliseconds, owner, + [this, thread = std::move(thread)]() mutable { + hydrateSelectedThreadIfNeeded(std::move(thread)); + }); + return; } + if (read->find(thread->id()) != thread || read->removed(thread)) + return; + const bool recoveryOnly = + graphBool(graphField(*read->state(thread), "recoveryOnly")); + read.reset(); + if (recoveryOnly) + return; + nodegraph::NodeAction action{std::move(thread), + nodegraph::NodeActionKind::Hydrate}; + static_cast(sendNodeAction( + std::move(action), + QStringLiteral( + "Thread loading was not admitted; select Reload to retry."))); +} + +nodegraph::NodeRef ShellWidget::Impl::activeTurn() const { + if (!boundGraphThread) + return {}; + auto read = session.nodeGraph().tryRead(); + if (!read || read->removed(boundGraphThread)) + return {}; + nodegraph::NodeRef indexed = + read->relatedAt(boundGraphThread, nodegraph::RelationKind::ActiveTurn, 0); + if (indexed && indexed->id().kind == nodegraph::NodeKind::Turn && + activeStatus(*read->state(indexed))) + return indexed; + return {}; +} - middleRegion->inspector().refresh(view.inspector); - if (!settingsSnapshot || *settingsSnapshot != view.settings) { - settingsSnapshot = view.settings; - middleRegion->composer().turnSettings()->setContext( - view.settings.identity, view.settings.canonical, - view.settings.modelCatalog, view.settings.permissionProfiles, - view.settings.settingsRevision, view.settings.settingsUpdate); +nodegraph::NodeRef +ShellWidget::Impl::threadById(const std::string &id, bool *busy) const { + if (busy) + *busy = false; + if (id.empty() || id == DraftThreadId) + return {}; + auto read = session.nodeGraph().tryRead(); + if (!read) { + if (busy) + *busy = true; + return {}; } - renderStatus(view); + const nodegraph::NodeRef thread = + read->find({nodegraph::NodeKind::Thread, id}); + return thread && !read->removed(thread) ? thread : nodegraph::NodeRef{}; +} + +std::optional +ShellWidget::Impl::pendingRequest(const std::string &requestKey, bool *busy) { + if (busy) + *busy = false; + bool canControl = false; + std::optional snapshot; + { + auto read = session.nodeGraph().tryRead(); + if (!read) { + if (busy) + *busy = true; + scheduleRender(); + return std::nullopt; + } - if (focusComposer) - middleRegion->composer().promptEditor()->setFocus(); + if (const nodegraph::NodeRef connection = + read->find({nodegraph::NodeKind::Connection, "connection"})) { + const auto state = read->state(connection); + canControl = + state->status == nodegraph::NodeStatus::Connected && + graphString(graphField(*state, "providerState")) == "ready" && + graphString(graphField(*state, "role")) == "controller"; + } + snapshot = readPendingRequest(*read, boundGraphThread, requestKey); + } + if (!snapshot) + return std::nullopt; + // A rejected bridge write remains Failed and actionable while the worker + // still owns the exact server request. Nothing retries it automatically. + return materializePendingRequest(std::move(*snapshot), canControl); } -void ShellWidget::Impl::renderStatus(const UiSessionView &view) { - if (statusSnapshot && *statusSnapshot == view.status && - attentionSnapshot == view.selectedPendingRequest) +void ShellWidget::Impl::render() { + ShellChromeValues values; + values.title = "Select a thread"; + values.workspace = "No workspace"; + std::shared_ptr selectedSettingsState; + std::shared_ptr modelCatalogState; + std::shared_ptr permissionProfileCatalogState; + bool modelCatalogChanged = false; + bool nextModelCatalogPresent = false; + std::uint64_t nextModelCatalogRevision = 0; + bool permissionProfileCatalogChanged = false; + bool nextPermissionProfileCatalogPresent = false; + std::uint64_t nextPermissionProfileCatalogRevision = 0; + std::optional attentionSnapshot; + + { + auto read = session.nodeGraph().tryRead(); + if (!read) { + scheduleRender(); + return; + } + if (const nodegraph::NodeRef connection = + read->find({nodegraph::NodeKind::Connection, "connection"})) { + const auto state = read->state(connection); + const std::string transport = + graphString(graphField(*state, "transportState")); + values.connected = state->status == nodegraph::NodeStatus::Connected || + transport == "connected"; + values.retrying = transport == "retrying" || transport == "connecting"; + values.role = graphString(graphField(*state, "role")); + values.providerState = graphString(graphField(*state, "providerState")); + values.canControl = values.connected && values.providerState == "ready" && + values.role == "controller"; + if (const nodegraph::Value *settings = graphField(*state, "settings")) { + if (const auto *object = settings->asObject()) { + const std::string selected = + graphString(graphField(*object, "selected")); + const nodegraph::Value *available = graphField(*object, "available"); + if (available && available->asArray()) { + for (const nodegraph::Value &entry : *available->asArray()) { + const auto *transportEntry = entry.asObject(); + if (transportEntry && + graphString(graphField(*transportEntry, "key")) == selected) { + values.selectedTransport = + graphString(graphField(*transportEntry, "label")); + break; + } + } + } + } + } + } + + if (const nodegraph::NodeRef runtime = + read->find({nodegraph::NodeKind::Runtime, "runtime"})) { + values.totalPending = read->relatedCount( + runtime, nodegraph::RelationKind::PendingInteraction); + } + + nodegraph::NodeRef selected = boundGraphThread; + if (selected && read->removed(selected)) + selected.reset(); + if (selected) { + const auto state = read->state(selected); + values.title = graphString(graphField(*state, "name")); + if (values.title.empty()) + values.title = graphString(graphField(*state, "title")); + if (values.title.empty()) + values.title = "Untitled thread"; + values.workspace = graphString(graphField(*state, "cwd")); + if (values.workspace.empty()) + values.workspace = graphString(graphField(*state, "workspace")); + if (values.workspace.empty()) + values.workspace = "No workspace"; + values.status = graphStatus(*state); + values.recoveryOnly = graphBool(graphField(*state, "recoveryOnly")); + const std::string hydrationState = + graphString(graphField(*state, "hydrationState")); + const bool local = graphBool(graphField(*state, "local")); + values.conversationReadyForDisplay = + hydrationState == "ready" || local || values.recoveryOnly; + values.hydrationFailed = hydrationState == "failed"; + values.threadAdmissionReady = + !values.recoveryOnly && hydrationState != "loading" && + hydrationState != "failed" && + (state->status != nodegraph::NodeStatus::NotLoaded || + hydrationState == "ready"); + values.lastActivityAt = + graphInteger(graphField(*state, "lastActivityAt")); + for (const std::string_view field : + {std::string_view("recencyAt"), std::string_view("updatedAt"), + std::string_view("localActivityAt"), + std::string_view("localPromptActivityAt")}) { + const std::optional timestamp = + graphInteger(graphField(*state, field)); + if (timestamp && + (!values.lastActivityAt || *timestamp > *values.lastActivityAt)) + values.lastActivityAt = timestamp; + } + selectedSettingsState = state; + const std::optional settingsRevision = + graphInteger(graphField(*state, "settingsRevision")); + if (settingsRevision && *settingsRevision >= 0) { + values.settingsRevision = + static_cast(*settingsRevision); + } else { + for (const std::string_view field : { + std::string_view("model"), std::string_view("effort"), + std::string_view("reasoningEffort"), + std::string_view("personality"), std::string_view("sandbox"), + std::string_view("sandboxPolicy"), + std::string_view("approvalPolicy"), + std::string_view("approvalsReviewer"), std::string_view("cwd"), + std::string_view("activePermissionProfile"), + std::string_view("serviceTier"), std::string_view("summary"), + std::string_view("collaborationMode")}) + values.settingsRevision = std::max( + values.settingsRevision, + read->fieldChangedRevision(selected, field)); + } + nodegraph::NodeRef turn = + read->relatedAt(selected, nodegraph::RelationKind::ActiveTurn, 0); + values.activeTurn = turn && + turn->id().kind == nodegraph::NodeKind::Turn && + activeStatus(*read->state(turn)); + } else if (newThreadDraft) { + values.title = newThreadDraft->name.trimmed().isEmpty() + ? "New thread" + : utf8(newThreadDraft->name.trimmed()); + values.workspace = utf8(newThreadDraft->workspace); + values.canonicalSettings = nlohmann::json{{"cwd", values.workspace}}; + } + + const nodegraph::NodeRef modelCatalog = + read->find({nodegraph::NodeKind::Catalog, "model"}); + nextModelCatalogPresent = modelCatalog != nullptr; + if (modelCatalog) + nextModelCatalogRevision = read->changedRevision(modelCatalog); + modelCatalogChanged = + !modelCatalogInitialized || + modelCatalogPresent != nextModelCatalogPresent || + (modelCatalog && modelCatalogRevision != nextModelCatalogRevision); + if (modelCatalogChanged && modelCatalog) + modelCatalogState = read->state(modelCatalog); + + const nodegraph::NodeRef permissionProfileCatalog = + read->find({nodegraph::NodeKind::Catalog, "permissionProfile"}); + nextPermissionProfileCatalogPresent = permissionProfileCatalog != nullptr; + if (permissionProfileCatalog) { + nextPermissionProfileCatalogRevision = + read->changedRevision(permissionProfileCatalog); + } + permissionProfileCatalogChanged = + !permissionProfileCatalogInitialized || + permissionProfileCatalogPresent != + nextPermissionProfileCatalogPresent || + (permissionProfileCatalog && permissionProfileCatalogRevision != + nextPermissionProfileCatalogRevision); + if (permissionProfileCatalogChanged && permissionProfileCatalog) + permissionProfileCatalogState = read->state(permissionProfileCatalog); + + attentionSnapshot = + readPendingRequest(*read, boundGraphThread, std::string{}); + } + + if (selectedSettingsState) { + values.canonicalSettings = widgetSettingsJson(*selectedSettingsState); + if (const nodegraph::Value *update = + graphField(*selectedSettingsState, "latestSettingsUpdate"); + update && update->asObject()) + values.settingsUpdate = widgetJson(*update); + } + + if (attentionSnapshot) + values.attention = materializePendingRequest(std::move(*attentionSnapshot), + values.canControl); + const bool chromeChanged = !renderedChrome || *renderedChrome != values; + + TurnSettingsWidget *turnSettings = middleRegion->composer().turnSettings(); + if (modelCatalogChanged) { + turnSettings->setModelCatalog( + catalogArray(modelCatalogState, {"models", "data"})); + modelCatalogInitialized = true; + modelCatalogPresent = nextModelCatalogPresent; + modelCatalogRevision = nextModelCatalogRevision; + } + if (permissionProfileCatalogChanged) { + turnSettings->setPermissionProfileCatalog(catalogArray( + permissionProfileCatalogState, {"permissionProfiles", "data"})); + permissionProfileCatalogInitialized = true; + permissionProfileCatalogPresent = nextPermissionProfileCatalogPresent; + permissionProfileCatalogRevision = nextPermissionProfileCatalogRevision; + } + if (!chromeChanged) return; - statusSnapshot = view.status; - attentionSnapshot = view.selectedPendingRequest; - const UiStatusView &status = view.status; + const bool replacementHydrating = + boundGraphThread && presentedGraphThread && + boundGraphThread != presentedGraphThread && + !values.conversationReadyForDisplay && !values.hydrationFailed; + + attentionInteraction = + values.attention ? values.attention->node : nodegraph::NodeRef{}; + if (!replacementHydrating) { + middleRegion->conversation().setEmptyMessage( + values.recoveryOnly + ? QStringLiteral( + "This thread preserves an unsent prompt. Restore it from " + "the failed prompt card before continuing.") + : boundGraphThread && values.hydrationFailed + ? QStringLiteral( + "Thread loading failed. Select Reload to retry.") + : boundGraphThread && !values.conversationReadyForDisplay + ? QStringLiteral("Loading conversation…") + : boundGraphThread + ? QStringLiteral("Conversation activity appears here.") + : (newThreadDraft + ? QStringLiteral("Send a message to create this thread.") + : QStringLiteral("Conversation activity appears here."))); + if (boundGraphThread) { + const QString activity = values.lastActivityAt + ? lastActivityText(*values.lastActivityAt) + : QString{}; + const QString status = text(values.status); + const QString tone = values.activeTurn ? QStringLiteral("active") + : QStringLiteral("neutral"); + middleRegion->setThreadHeading(text(values.title), + text(values.workspace), activity, status, + tone); + } else { + middleRegion->setThreadHeading(text(values.title), + text(values.workspace)); + } + turnSettings->setCanonicalContext( + boundGraphThread ? boundGraphThread->id().canonical + : std::string(DraftThreadId), + values.canonicalSettings, values.settingsRevision, + values.settingsUpdate); + } + renderStatus(values, !replacementHydrating); + renderedChrome = values; + ++shellRenderCommits; + owner->setProperty("shellRenderCommits", + static_cast(shellRenderCommits)); + if (newThreadDraft) + scheduleDraftSelection(); +} + +void ShellWidget::Impl::renderStatus(const ShellChromeValues &status, + bool updateWorkspace) { QString dotStyle; QString dotTip; if (status.connected) { @@ -606,36 +2552,50 @@ void ShellWidget::Impl::renderStatus(const UiSessionView &view) { dotStyle = QStringLiteral("background:#c43d4d;border-radius:5px;"); dotTip = QStringLiteral("Disconnected"); } - connectionStatusDot->setStyleSheet(dotStyle); - connectionStatusDot->setToolTip(dotTip); - connectionButton->setText( - status.selectedTransport.empty() ? QStringLiteral("Connection") - : text(status.selectedTransport)); - connectionButton->setToolTip( + if (connectionStatusDot->styleSheet() != dotStyle) + connectionStatusDot->setStyleSheet(dotStyle); + if (connectionStatusDot->toolTip() != dotTip) + connectionStatusDot->setToolTip(dotTip); + const QString connectionText = status.selectedTransport.empty() + ? QStringLiteral("Connection") + : text(status.selectedTransport); + if (connectionButton->text() != connectionText) + connectionButton->setText(connectionText); + const QString connectionTip = status.connected ? QStringLiteral("Connected bridge transport") - : QStringLiteral("Disconnected bridge transport")); + : QStringLiteral("Disconnected bridge transport"); + if (connectionButton->toolTip() != connectionTip) + connectionButton->setToolTip(connectionTip); connectAction->setEnabled(!status.connected); disconnectAction->setEnabled(status.connected); reconnectAction->setEnabled(status.connected); - controllerButton->setText(status.role == "controller" - ? QStringLiteral("Release control") - : QStringLiteral("Claim control")); + const QString controllerText = + status.role == "controller" ? QStringLiteral("Release control") + : QStringLiteral("Claim control"); + if (controllerButton->text() != controllerText) + controllerButton->setText(controllerText); controllerButton->setEnabled(status.connected); - requestButton->setText( - QStringLiteral("Requests (%1)") - .arg(static_cast(status.totalPending))); + const QString requestText = QStringLiteral("Requests (%1)") + .arg(static_cast( + status.totalPending)); + if (requestButton->text() != requestText) + requestButton->setText(requestText); requestButton->setVisible(status.totalPending != 0); - if (view.selectedPendingRequest) { - const UiPendingRequestView &request = *view.selectedPendingRequest; + if (status.attention) { + const PendingGraphRequest &request = *status.attention; + const nlohmann::json raw = widgetJson(nodegraph::Value(request.payload)); middleRegion->composer().setAttentionRequest( - text(request.title), text(request.detail), request.supportsDirectAccept, - text(request.directAcceptLabel)); + text(PendingRequestPolicy::title(request.kind)), + text(PendingRequestPolicy::detail(request.displayId, request.threadId, + raw)), + PendingRequestPolicy::supportsDirectAccept(request.kind), + text(PendingRequestPolicy::directAcceptLabel(request.kind))); } - middleRegion->composer().setAttentionVisible( - view.selectedPendingRequest.has_value()); - middleRegion->composer().setAttentionEnabled( - view.selectedPendingRequest && view.selectedPendingRequest->actionable); + middleRegion->composer().setAttentionVisible(status.attention.has_value()); + middleRegion->composer().setAttentionActionEnabled( + status.attention && status.attention->actionable, + status.attention && status.attention->recoverable); QString globalStatus = QStringLiteral("Ready"); QString globalTone = QStringLiteral("success"); @@ -658,82 +2618,220 @@ void ShellWidget::Impl::renderStatus(const UiSessionView &view) { setStatusTone(globalStatusDot, globalStatusLabel, globalTone); setStatusLabelText(globalStatusLabel, globalStatus); - const QString workspace = text(status.workspace); - workspaceBreadcrumb->setToolTip(workspace); - workspaceBreadcrumb->setText( - workspaceBreadcrumb->fontMetrics().elidedText( - workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth())); + if (updateWorkspace) { + const QString workspace = text(status.workspace); + if (workspaceBreadcrumb->toolTip() != workspace) + workspaceBreadcrumb->setToolTip(workspace); + const QString displayedWorkspace = + workspaceBreadcrumb->fontMetrics().elidedText( + workspace, Qt::ElideMiddle, workspaceBreadcrumb->maximumWidth()); + if (workspaceBreadcrumb->text() != displayedWorkspace) + workspaceBreadcrumb->setText(displayedWorkspace); + } middleRegion->composer().setActiveTurn(status.activeTurn); - middleRegion->composer().setCanSubmit(status.canSubmit); - middleRegion->composer().setSettingsEnabled(status.canEditSettings); + middleRegion->composer().setCanSubmit( + status.canControl && (boundGraphThread || newThreadDraft) && + (status.activeTurn || status.threadAdmissionReady)); + middleRegion->composer().setSettingsEnabled( + status.canControl && status.threadAdmissionReady && + (boundGraphThread || newThreadDraft) && !status.activeTurn); } void ShellWidget::Impl::beginNewThreadDialog() { + if (creationInFlight) { + showNotice(QStringLiteral("A new thread is already being created. Wait for " + "it to finish before " + "starting another."), + false); + return; + } const QString fallback = QDir::currentPath(); const QString initial = text(middleRegion->composer().turnSettings()->workspace(utf8(fallback))); NewThreadDialog dialog(initial, owner); if (dialog.exec() != QDialog::Accepted) return; - const NewThreadDraft draft = dialog.draft(); + NewThreadDraft draft = dialog.draft(); middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); - uiSession.beginNewThread( - {utf8(draft.workspace), utf8(draft.name), - utf8(draft.baseInstructions), utf8(draft.developerInstructions), - draft.ephemeral}); + newThreadDraft = draft; + creationDraftCorrelation = + "qt-draft:" + std::to_string(nextCreationDraftSerial++); + optimisticCreationThreadId = DraftThreadId; + creationInFlight = false; + selectedGraphThreadId.clear(); + bindGraphPanes({}); + middleRegion->threads().beginOptimisticThread( + DraftThreadId, + draft.name.trimmed().isEmpty() ? "New thread" + : utf8(draft.name.trimmed()), + utf8(draft.workspace)); + if (auto threads = uiAdapter.threads({})) + middleRegion->threads().refresh(*threads); + scheduleDraftSelection(); + middleRegion->composer().clearDraft(); + middleRegion->composer().promptEditor()->setFocus(); + render(); } -void ShellWidget::Impl::renameThreadDialog( - const std::string &threadId) { - if (!renderedView || !renderedView->threads.canControl) +void ShellWidget::Impl::renameThreadDialog(const nodegraph::NodeRef &thread) { + if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) return; - const ui::ThreadListRow *thread = - findThread(renderedView->threads, threadId); - if (!thread) + std::string currentName; + if (auto read = session.nodeGraph().tryRead()) { + if (read->removed(thread)) + return; + const auto state = read->state(thread); + currentName = graphString(graphField(*state, "name")); + if (currentName.empty()) + currentName = graphString(graphField(*state, "title")); + } else { + showNotice(QStringLiteral("Thread state is busy; try again.")); return; + } + const auto retained = retainedRenames.find(thread.get()); + const QString initialName = retained == retainedRenames.end() + ? text(currentName) + : retained->second.second; bool accepted = false; const QString name = QInputDialog::getText(owner, QStringLiteral("Rename thread"), QStringLiteral("Name"), QLineEdit::Normal, - text(thread->title), &accepted) + initialName, &accepted) .trimmed(); - if (accepted && !name.isEmpty()) - uiSession.renameThread(threadId, utf8(name)); + if (!accepted || name.isEmpty()) + return; + nodegraph::NodeAction action{thread, nodegraph::NodeActionKind::Rename}; + action.payload.emplace("name", utf8(name)); + const nodegraph::ChannelSendStatus status = session.sendNodeAction(action); + if (!nodegraph::deliveryGuaranteed(status)) { + retainedRenames.insert_or_assign(thread.get(), std::pair{thread, name}); + showNotice(QStringLiteral( + "Rename request was not admitted. Reopen Rename to recover the " + "name you entered.")); + return; + } + retainedRenames.erase(thread.get()); + if (nodegraph::wakeFailed(status)) + showNotice(QStringLiteral( + "The rename was admitted after a worker wake-up failure; bounded " + "fallback delivery is active.")); } -void ShellWidget::Impl::confirmDeleteThread( - const std::string &threadId) { - if (threadId.empty() || !renderedView || - !renderedView->threads.canControl) +void ShellWidget::Impl::confirmDeleteThread(const nodegraph::NodeRef &thread) { + if (!thread || thread->id().kind != nodegraph::NodeKind::Thread) return; if (QMessageBox::question(owner, QStringLiteral("Delete thread"), QStringLiteral("Delete the selected thread?"), QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) == QMessageBox::Yes) - uiSession.deleteThread(threadId); + static_cast(sendNodeAction( + {thread, nodegraph::NodeActionKind::Delete}, + QStringLiteral("Delete request was not admitted; try again."))); } -bool ShellWidget::Impl::submitPrompt( - QString prompt, std::vector attachments) { +bool ShellWidget::Impl::submitPrompt(QString prompt, + std::vector attachments) { prompt = prompt.trimmed(); if (prompt.isEmpty()) return false; - TurnSettingsWidget *settings = - middleRegion->composer().turnSettings(); + TurnSettingsWidget *settings = middleRegion->composer().turnSettings(); + std::vector ownedAttachments; + ownedAttachments.reserve(attachments.size()); + for (AttachmentDraft &attachment : attachments) { + ownedAttachments.push_back({std::move(attachment.path), + std::move(attachment.name), + std::move(attachment.mimeType), std::nullopt}); + } + + bool admitted = false; const std::string visibleThreadId = middleRegion->threads().visiblySelectedThreadId(); - UiPromptDraft draft; - draft.text = utf8(prompt); - draft.attachments = std::move(attachments); - draft.turnStartOptions = settings->turnStartOptions(); - draft.threadStartOptions = settings->threadStartOptions(); - draft.workspace = settings->workspace(utf8(QDir::currentPath())); - draft.visiblySelectedThreadId = visibleThreadId; - const bool admitted = uiSession.submitPrompt(std::move(draft)); - if (admitted && !visibleThreadId.empty() && - visibleThreadId != DraftThreadId) - uiSession.notePromptActivity(visibleThreadId); + if (nodegraph::NodeRef target = threadById(visibleThreadId)) { + QString graphRejection; + if (auto read = session.nodeGraph().tryRead()) { + if (read->removed(target)) { + graphRejection = + QStringLiteral("The selected thread is no longer available. Your " + "message was not sent."); + } else { + const auto state = read->state(target); + const std::string hydration = + graphString(graphField(*state, "hydrationState")); + const nodegraph::NodeRef turn = read->relatedAt( + target, nodegraph::RelationKind::ActiveTurn, 0); + const bool steeringKnownActiveTurn = + turn && turn->id().kind == nodegraph::NodeKind::Turn && + activeStatus(*read->state(turn)); + if (!steeringKnownActiveTurn && + (hydration == "loading" || hydration == "failed" || + (state->status == nodegraph::NodeStatus::NotLoaded && + hydration != "ready"))) { + const std::string detail = + graphString(graphField(*state, "hydrationError")); + graphRejection = text( + detail.empty() + ? "This thread must finish loading before sending. Your " + "draft is still available." + : detail + + ". Reload the thread; your draft is still available."); + } + } + } else { + graphRejection = QStringLiteral( + "Thread state is busy. Your draft is still available; try again."); + } + // The graph read guard above is gone before any QWidget is touched. + if (!graphRejection.isEmpty()) { + showNotice(std::move(graphRejection)); + return false; + } + nodegraph::NodeAction action{target, + nodegraph::NodeActionKind::SubmitPrompt}; + action.promptText = utf8(prompt); + action.attachments = std::move(ownedAttachments); + action.payload = actionObject(settings->turnStartOptions()); + admitted = sendNodeAction( + std::move(action), + QStringLiteral("Your message was not sent; the worker queue is full.")); + } else if (visibleThreadId == DraftThreadId && + newThreadDraft) { + nodegraph::RuntimeAction action; + action.kind = nodegraph::RuntimeActionKind::CreateThread; + action.correlation = creationDraftCorrelation; + action.promptText = utf8(prompt); + action.attachments = std::move(ownedAttachments); + nodegraph::Value::Object threadStart = + actionObject(settings->threadStartOptions()); + threadStart.insert_or_assign( + "cwd", settings->workspace(utf8(QDir::currentPath()))); + if (!newThreadDraft->name.trimmed().isEmpty()) + action.payload.insert_or_assign("requestedName", + utf8(newThreadDraft->name)); + if (!newThreadDraft->baseInstructions.isEmpty()) + threadStart.insert_or_assign("baseInstructions", + utf8(newThreadDraft->baseInstructions)); + if (!newThreadDraft->developerInstructions.isEmpty()) + threadStart.insert_or_assign("developerInstructions", + utf8(newThreadDraft->developerInstructions)); + if (newThreadDraft->ephemeral) + threadStart.insert_or_assign("ephemeral", true); + action.payload.insert_or_assign("threadStart", + nodegraph::Value(std::move(threadStart))); + action.payload.insert_or_assign( + "turnStart", + nodegraph::Value(actionObject(settings->turnStartOptions()))); + admitted = sendRuntimeAction( + std::move(action), + QStringLiteral("Your message was not sent; the worker queue is full.")); + if (admitted) + creationInFlight = true; + } else { + showNotice(QStringLiteral( + "No destination thread is selected. Your message was not sent.")); + } + if (admitted) + middleRegion->conversation().prepareForLocalPromptAdmission(); return admitted; } @@ -747,63 +2845,209 @@ void ShellWidget::Impl::chooseAttachments() { middleRegion->composer().setAttachments(dialog.selectedAttachments()); } -const UiPendingRequestView *ShellWidget::Impl::pendingRequest( - const std::string &requestKey) const { - if (!renderedView) - return nullptr; - for (const UiPendingRequestView &request : renderedView->pendingRequests) { - if (request.id == requestKey) - return &request; +void ShellWidget::Impl::recoverPrompt(const nodegraph::NodeRef &prompt) { + if (!prompt || prompt->id().kind != nodegraph::NodeKind::Item) + return; + + std::shared_ptr state; + if (auto read = session.nodeGraph().tryRead()) { + if (read->removed(prompt)) + return; + state = read->state(prompt); + if (!graphBool(graphField(*state, "requiresExplicitRecovery"))) + return; + } else { + showNotice(QStringLiteral( + "Prompt state is busy; the preserved prompt was not changed.")); + return; + } + std::string authoredText = graphString(graphField(*state, "authoredText")); + if (authoredText.empty()) + authoredText = graphString(graphField(*state, "text")); + std::vector attachments = graphAttachmentDrafts(*state); + + if (!middleRegion->composer().promptEditor()->toPlainText().isEmpty() || + !middleRegion->composer().attachments().empty()) { + showNotice(QStringLiteral( + "Clear or send the current draft before restoring this prompt. The " + "preserved prompt was not changed.")); + return; + } + + NewThreadDraft draft; + const nodegraph::Value::Object *threadStart = nullptr; + if (const nodegraph::Value *value = graphField(*state, "threadStartOptions")) + threadStart = value->asObject(); + const std::string recoveredWorkspace = + threadStart ? graphString(graphField(*threadStart, "cwd")) + : std::string{}; + draft.workspace = + recoveredWorkspace.empty() + ? text(middleRegion->composer().turnSettings()->workspace( + utf8(QDir::currentPath()))) + : text(recoveredWorkspace); + const std::string requestedName = + graphString(graphField(*state, "requestedName")); + draft.name = requestedName.empty() ? QStringLiteral("Recovered prompt") + : text(requestedName); + if (threadStart) { + draft.baseInstructions = + text(graphString(graphField(*threadStart, "baseInstructions"))); + draft.developerInstructions = + text(graphString(graphField(*threadStart, "developerInstructions"))); + draft.ephemeral = graphBool(graphField(*threadStart, "ephemeral")); } - return nullptr; + middleRegion->composer().turnSettings()->setWorkspace(draft.workspace); + newThreadDraft = draft; + creationDraftCorrelation = + "qt-draft:" + std::to_string(nextCreationDraftSerial++); + optimisticCreationThreadId = DraftThreadId; + creationInFlight = false; + selectedGraphThreadId.clear(); + bindGraphPanes({}); + middleRegion->threads().beginOptimisticThread(DraftThreadId, utf8(draft.name), + utf8(draft.workspace)); + scheduleDraftSelection(); + middleRegion->composer().promptEditor()->setPlainText(text(authoredText)); + middleRegion->composer().setAttachments(std::move(attachments)); + middleRegion->composer().promptEditor()->setFocus(); + showNotice( + QStringLiteral("The unsent prompt was restored to a new-thread draft."), + false); + render(); } void ShellWidget::Impl::respondToFirstPending(bool approve) { - if (!renderedView || !renderedView->selectedPendingRequest) + bool busy = false; + const auto request = pendingRequest({}, &busy); + if (!request) { + showNotice( + busy ? QStringLiteral("Request state is busy; no response was sent.") + : QStringLiteral("The pending request is no longer actionable.")); + return; + } + if (!request->actionable) { + showNotice(QStringLiteral( + "Controller access is unavailable; no response was sent.")); return; - const std::string id = renderedView->selectedPendingRequest->id; + } if (approve) - acceptPending(id); + acceptPending(request->id); else - rejectPending(id); + rejectPending(request->id); } void ShellWidget::Impl::reviewPending(const std::string &requestKey) { - const UiPendingRequestView *current = pendingRequest(requestKey); - if (!current || !current->actionable) + bool busy = false; + const auto request = pendingRequest(requestKey, &busy); + if (!request) { + showNotice( + busy ? QStringLiteral("Request state is busy; no response was sent.") + : QStringLiteral("The pending request is no longer actionable.")); + return; + } + if (!request->actionable && + !(request->recoveryOnly && request->retainedResponsePayload)) { + showNotice(QStringLiteral( + "Controller access is unavailable; no response was sent.")); return; - const UiPendingRequestView request = *current; + } const PendingRequestDescriptor presented{ - request.id, request.kind, request.threadId, request.generation, - request.raw}; - const auto response = PendingRequestDialog::present(presented, owner); - if (response) - static_cast( - uiSession.resolvePending(request, std::move(*response))); + request->displayId, request->kind, request->threadId, 0, + widgetJson(nodegraph::Value(request->payload))}; + const auto retained = retainedInteractionResponses.find(request->id); + std::optional graphRetained; + if (retained == retainedInteractionResponses.end() && + request->retainedResponsePayload) + graphRetained = + retainedResponse(request->kind, *request->retainedResponsePayload); + const PendingRequestResponse *initial = + retained != retainedInteractionResponses.end() + ? &retained->second + : (graphRetained ? &*graphRetained : nullptr); + const auto response = + PendingRequestDialog::present(presented, owner, initial); + if (!response) + return; + if (request->recoveryOnly) { + retainedInteractionResponses.insert_or_assign(request->id, *response); + showNotice(QStringLiteral( + "The original request ended when the provider changed. Your authored " + "response remains preserved for review, but was not sent.")); + return; + } + nodegraph::NodeAction action{request->node, + nodegraph::NodeActionKind::ResolveInteraction}; + action.payload = authoredResponsePayload(request->kind, *response); + const nodegraph::ChannelSendStatus status = session.sendNodeAction(action); + if (!nodegraph::deliveryGuaranteed(status)) { + retainedInteractionResponses.insert_or_assign(request->id, *response); + showNotice(QStringLiteral( + "Your response was not admitted. Reopen Review to recover the input " + "you entered; the request remains pending.")); + return; + } + retainedInteractionResponses.erase(request->id); + if (nodegraph::wakeFailed(status)) + showNotice(QStringLiteral( + "The response was admitted after a worker wake-up failure; bounded " + "fallback delivery is active.")); } void ShellWidget::Impl::acceptPending(const std::string &requestKey) { - const UiPendingRequestView *current = pendingRequest(requestKey); - if (!current || !current->actionable) + bool busy = false; + const auto request = pendingRequest(requestKey, &busy); + if (!request) { + showNotice( + busy ? QStringLiteral("Request state is busy; no response was sent.") + : QStringLiteral("The pending request is no longer actionable.")); return; - const UiPendingRequestView request = *current; - if (!request.supportsDirectAccept) { + } + if (!request->actionable) { + showNotice(QStringLiteral( + "Controller access is unavailable; no response was sent.")); + return; + } + if (!PendingRequestPolicy::supportsDirectAccept(request->kind)) { reviewPending(requestKey); return; } - static_cast(uiSession.resolvePending( - request, - PendingRequestPolicy::positiveResponse(request.kind, request.raw))); + const nlohmann::json raw = widgetJson(nodegraph::Value(request->payload)); + const PendingRequestResponse response = + PendingRequestPolicy::positiveResponse(request->kind, raw); + nodegraph::NodeAction action{request->node, + nodegraph::NodeActionKind::ResolveInteraction}; + action.payload = authoredResponsePayload(request->kind, response); + static_cast(sendNodeAction( + std::move(action), + QStringLiteral( + "Approval was not admitted; the request remains pending."))); } void ShellWidget::Impl::rejectPending(const std::string &requestKey) { - const UiPendingRequestView *current = pendingRequest(requestKey); - if (!current || !current->actionable) + bool busy = false; + const auto request = pendingRequest(requestKey, &busy); + if (!request) { + showNotice( + busy ? QStringLiteral("Request state is busy; no response was sent.") + : QStringLiteral("The pending request is no longer actionable.")); return; - const UiPendingRequestView request = *current; - static_cast(uiSession.resolvePending( - request, - PendingRequestPolicy::negativeResponse(request.kind, request.raw))); + } + if (!request->actionable) { + showNotice(QStringLiteral( + "Controller access is unavailable; no response was sent.")); + return; + } + const nlohmann::json raw = widgetJson(nodegraph::Value(request->payload)); + const PendingRequestResponse response = + PendingRequestPolicy::negativeResponse(request->kind, raw); + nodegraph::NodeAction action{request->node, + nodegraph::NodeActionKind::ResolveInteraction}; + action.payload = authoredResponsePayload(request->kind, response); + static_cast(sendNodeAction( + std::move(action), + QStringLiteral( + "Rejection was not admitted; the request remains pending."))); } ShellWidget::ShellWidget(FrontendSession &session, QWidget *parent) diff --git a/src/codex/TurnSettingsWidget.cpp b/src/codex/TurnSettingsWidget.cpp index 3c5d8a7..d039dd5 100644 --- a/src/codex/TurnSettingsWidget.cpp +++ b/src/codex/TurnSettingsWidget.cpp @@ -24,6 +24,7 @@ #include #include +#include namespace codexui::codex { namespace { @@ -353,9 +354,57 @@ void TurnSettingsWidget::setContext(std::string identity, const nlohmann::json &permissionProfiles, std::uint64_t settingsRevision, const nlohmann::json &settingsUpdate) { + modelCatalog = models.is_array() ? models : nlohmann::json::array(); + static_cast(applyCanonicalContext(std::move(identity), canonical, + settingsRevision, settingsUpdate)); + refreshModels(modelCatalog); + refreshPermissionProfiles(permissionProfiles); + refreshModelOptions(); + refreshAccessCompatibility(); + refreshMoreIndicator(); +} + +void TurnSettingsWidget::setCanonicalContext( + std::string identity, const nlohmann::json &canonical, + std::uint64_t settingsRevision, const nlohmann::json &settingsUpdate) { + const bool identityChanged = contextIdentity != identity; + const auto differs = [this, &canonical](const char *name) { + return canonicalContext.value(name, nlohmann::json(nullptr)) != + canonical.value(name, nlohmann::json(nullptr)); + }; + const bool modelChanged = identityChanged || differs("model"); + const bool accessChanged = identityChanged || differs("sandbox") || + differs("sandboxPolicy") || + differs("activePermissionProfile"); + if (!applyCanonicalContext(std::move(identity), canonical, settingsRevision, + settingsUpdate)) + return; + if (modelChanged) + refreshModelOptions(); + if (accessChanged) + refreshAccessCompatibility(); + refreshMoreIndicator(); +} + +void TurnSettingsWidget::setModelCatalog(const nlohmann::json &models) { + modelCatalog = models.is_array() ? models : nlohmann::json::array(); + refreshModels(modelCatalog); + refreshModelOptions(); + refreshMoreIndicator(); +} + +void TurnSettingsWidget::setPermissionProfileCatalog( + const nlohmann::json &permissionProfiles) { + refreshPermissionProfiles(permissionProfiles); + refreshAccessCompatibility(); + refreshMoreIndicator(); +} + +bool TurnSettingsWidget::applyCanonicalContext( + std::string identity, const nlohmann::json &canonical, + std::uint64_t settingsRevision, const nlohmann::json &settingsUpdate) { const bool changed = contextIdentity != identity; contextIdentity = std::move(identity); - modelCatalog = models.is_array() ? models : nlohmann::json::array(); std::array(Field::Count)> fields{}; if (changed) { fields.fill(true); @@ -373,14 +422,13 @@ void TurnSettingsWidget::setContext(std::string identity, fields[static_cast(Field::Model)] = differs("model") || received("model"); fields[static_cast(Field::Effort)] = - differs("effort") || differs("reasoningEffort") || - received("effort") || received("reasoningEffort"); + differs("effort") || differs("reasoningEffort") || received("effort") || + received("reasoningEffort"); fields[static_cast(Field::Personality)] = differs("personality") || received("personality"); - const bool sandboxChanged = differs("sandbox") || - differs("sandboxPolicy") || - received("sandbox") || - received("sandboxPolicy"); + const bool sandboxChanged = + differs("sandbox") || differs("sandboxPolicy") || received("sandbox") || + received("sandboxPolicy"); fields[static_cast(Field::Sandbox)] = sandboxChanged; fields[static_cast(Field::Network)] = sandboxChanged; fields[static_cast(Field::Approval)] = @@ -405,17 +453,17 @@ void TurnSettingsWidget::setContext(std::string identity, refreshFromCanonical(canonical, fields); canonicalContext = canonical; canonicalSettingsRevision = settingsRevision; - refreshModels(modelCatalog); - refreshPermissionProfiles(permissionProfiles); - refreshModelOptions(); - refreshAccessCompatibility(); - refreshMoreIndicator(); + return std::ranges::any_of(fields, [](bool refresh) { return refresh; }); } void TurnSettingsWidget::setControlsEnabled(bool enabled) { - setEnabled(enabled); - setToolTip(enabled ? QString{} - : QStringLiteral("Settings apply when starting a turn")); + if (isEnabled() != enabled) + setEnabled(enabled); + const QString tip = + enabled ? QString{} + : QStringLiteral("Settings apply when starting a turn"); + if (toolTip() != tip) + setToolTip(tip); } void TurnSettingsWidget::setWorkspace(QString path) { @@ -572,8 +620,8 @@ void TurnSettingsWidget::refreshFromCanonical( cwd->setText(text(stringValue(canonical, "cwd"))); if (refresh(Field::PermissionProfile)) { QString activeProfile = QString::fromLatin1(DefaultValue); - const nlohmann::json profile = canonical.value( - "activePermissionProfile", nlohmann::json::object()); + const nlohmann::json profile = + canonical.value("activePermissionProfile", nlohmann::json::object()); if (profile.is_object() && profile.contains("id") && profile["id"].is_string()) activeProfile = text(profile["id"].get()); @@ -777,13 +825,12 @@ void TurnSettingsWidget::refreshAccessCompatibility() { selectValue(network, QString::fromLatin1(DefaultValue)); } sandbox->setToolTip({}); - network->setToolTip(value(sandbox) == "danger-full-access" - ? QStringLiteral( - "Full access already includes network access") - : value(sandbox) == DefaultValue - ? QStringLiteral( - "Select an access mode before network access") - : QString{}); + network->setToolTip( + value(sandbox) == "danger-full-access" + ? QStringLiteral("Full access already includes network access") + : value(sandbox) == DefaultValue + ? QStringLiteral("Select an access mode before network access") + : QString{}); } void TurnSettingsWidget::refreshMoreIndicator() { diff --git a/src/codex/TurnSettingsWidget.h b/src/codex/TurnSettingsWidget.h index 3b5f71f..7344e81 100644 --- a/src/codex/TurnSettingsWidget.h +++ b/src/codex/TurnSettingsWidget.h @@ -21,12 +21,20 @@ class TurnSettingsWidget final : public QWidget { public: explicit TurnSettingsWidget(QWidget *parent = nullptr); - void setContext(std::string identity, const nlohmann::json &canonical, - const nlohmann::json &models, - const nlohmann::json &permissionProfiles, - std::uint64_t settingsRevision = 0, - const nlohmann::json &settingsUpdate = - nlohmann::json::object()); + void + setContext(std::string identity, const nlohmann::json &canonical, + const nlohmann::json &models, + const nlohmann::json &permissionProfiles, + std::uint64_t settingsRevision = 0, + const nlohmann::json &settingsUpdate = nlohmann::json::object()); + // The shared-graph binding updates these independently so unrelated stream + // revisions never rebuild catalog-backed controls. + void setCanonicalContext( + std::string identity, const nlohmann::json &canonical, + std::uint64_t settingsRevision = 0, + const nlohmann::json &settingsUpdate = nlohmann::json::object()); + void setModelCatalog(const nlohmann::json &models); + void setPermissionProfileCatalog(const nlohmann::json &permissionProfiles); void setControlsEnabled(bool enabled); void setWorkspace(QString path); @@ -52,6 +60,10 @@ class TurnSettingsWidget final : public QWidget { }; void markTouched(Field field); + [[nodiscard]] bool + applyCanonicalContext(std::string identity, const nlohmann::json &canonical, + std::uint64_t settingsRevision, + const nlohmann::json &settingsUpdate); void refreshFromCanonical( const nlohmann::json &canonical, const std::array(Field::Count)> &fields); diff --git a/src/codex/UiSession.cpp b/src/codex/UiSession.cpp deleted file mode 100644 index 75886a4..0000000 --- a/src/codex/UiSession.cpp +++ /dev/null @@ -1,1340 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/UiSession.h" - -#include "codex/PresentationModel.h" -#include "codex/PresentationProtocol.h" -#include "codex/PresentationStatus.h" -#include "codex/middle/ConversationProjection.h" -#include "codex/middle/PromptCoordinator.h" -#include "codex/ui/UiViewProjection.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::codex { -namespace { - -constexpr std::string_view DraftThreadId = "draft:new-thread"; - -std::int64_t systemClockMilliseconds() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto found = object.find(key); - return found != object.end() && found->is_string() ? found->get() - : std::string{}; -} - -std::string safeMessage(const nlohmann::json &value) { - std::string message = stringValue(value, "message"); - if (message.empty()) - message = stringValue(value, "detail"); - if (!message.empty()) - return message; - const auto error = value.find("error"); - return error != value.end() && error->is_object() - ? stringValue(*error, "message") - : std::string{}; -} - -bool isThreadNotFoundResult(const nlohmann::json &result) { - if (result.value("ok", false)) - return false; - std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - std::ranges::transform(message, message.begin(), [](unsigned char value) { - return static_cast(std::tolower(value)); - }); - return message.find("thread") != std::string::npos && - message.find("not found") != std::string::npos; -} - -bool isTransientCancellation(const nlohmann::json &result) { - return !result.value("ok", false) && - result.value("error", nlohmann::json::object()) - .value("transient", false); -} - -std::optional resultTurnId(const nlohmann::json &result) { - const nlohmann::json scope = result.value("scope", nlohmann::json::object()); - std::string id = stringValue(scope, "turnId"); - if (!id.empty()) - return id; - const nlohmann::json data = result.value("data", nlohmann::json::object()); - id = stringValue(data, "turnId"); - if (!id.empty()) - return id; - const nlohmann::json turn = data.value("turn", nlohmann::json::object()); - id = stringValue(turn, "id"); - return id.empty() ? std::nullopt : std::optional{std::move(id)}; -} - -std::string trimAscii(std::string value) { - const auto whitespace = [](unsigned char character) { - return character == ' ' || character == '\t' || character == '\n' || - character == '\r' || character == '\f' || character == '\v'; - }; - const auto first = std::ranges::find_if_not(value, whitespace); - if (first == value.end()) - return {}; - const auto last = std::find_if_not(value.rbegin(), value.rend(), whitespace); - return std::string(first, last.base()); -} - -} // namespace - -class UiSession::Impl final { -public: - enum class Hydration { NotHydrated, InFlight, Hydrated, Failed }; - enum class SettingsHydration { - Unknown, - WaitingForRead, - InFlight, - Hydrated, - Failed, - }; - - struct ThreadRuntimeState { - Hydration hydration = Hydration::NotHydrated; - SettingsHydration settingsHydration = SettingsHydration::Unknown; - std::uint64_t readRevision = 0; - bool operationReady = false; - bool resumeInFlight = false; - std::optional provisionalActiveTurnId; - std::unordered_set recoveryAttemptedSubmissions; - - void resetForConnection() noexcept { - hydration = Hydration::NotHydrated; - settingsHydration = SettingsHydration::Unknown; - readRevision = 0; - operationReady = false; - resumeInFlight = false; - provisionalActiveTurnId.reset(); - } - }; - - struct HistoryWindow { - std::size_t requested = - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - std::size_t effective = - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - std::size_t lastAuthoritativeCount = 0; - }; - - Impl(PresentationClient client, std::string defaultWorkspace, - UiSession::Clock clock) - : client(std::move(client)), - defaultWorkspace(std::move(defaultWorkspace)), - clock(clock ? std::move(clock) - : UiSession::Clock{systemClockMilliseconds}), - alive(std::make_shared(true)) {} - - ~Impl() { *alive = false; } - - [[nodiscard]] std::int64_t now() const { return clock(); } - [[nodiscard]] std::int64_t nowSeconds() const { return now() / 1000; } - - void changed() { - if (changedHandler) - changedHandler(); - } - - void showNotice(std::string message, bool error = true) { - if (message.empty()) - return; - notices.push_back({nextNoticeId++, std::move(message), error}); - changed(); - } - - void scheduleWakeup(std::int64_t atMilliseconds) { - if (!nextWakeupAt || atMilliseconds < *nextWakeupAt) { - nextWakeupAt = atMilliseconds; - if (wakeupHandler) - wakeupHandler(atMilliseconds); - } - } - - [[nodiscard]] bool providerReady() const { - const ConnectionPresentation &connection = model.connection(); - return connection.connected && connection.providerState == "ready"; - } - - [[nodiscard]] bool canControlProvider() const { - return providerReady() && model.connection().role == "controller"; - } - - [[nodiscard]] std::optional - activeTurnId(const std::string &threadId) const { - if (const auto authoritative = model.activeTurnId(threadId)) - return authoritative; - const auto runtime = runtimeByThread.find(threadId); - if (runtime == runtimeByThread.end() || - !runtime->second.provisionalActiveTurnId) - return std::nullopt; - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return runtime->second.provisionalActiveTurnId; - const auto turn = - thread->turns.find(*runtime->second.provisionalActiveTurnId); - if (turn != thread->turns.end() && - isTerminalTurnStatus(turn->second.status)) - return std::nullopt; - return runtime->second.provisionalActiveTurnId; - } - - void resetRuntimeForConnection() { - resolvingRequests.clear(); - deferredPromptDispatch.clear(); - for (auto &[threadId, runtime] : runtimeByThread) { - static_cast(threadId); - runtime.resetForConnection(); - } - } - - void hydrateProvider() { - if (!providerReady()) - return; - client.execute("threads.list", nlohmann::json::object()); - client.execute("models.list", nlohmann::json::object()); - ensureThreadHydrated(selectedThreadId); - ensureThreadSettingsHydrated(selectedThreadId); - for (const std::string &threadId : prompts.queuedThreadIds()) { - if (threadId == DraftThreadId) { - if (newThreadIntent) - startThreadForDraft(); - } else { - schedulePromptDispatch(threadId); - } - } - client.execute("permission-profiles.list", {{"cwd", defaultWorkspace}}); - } - - void onPresentationFrame(const nlohmann::json &event) { - if (protocolFrameObserver) - protocolFrameObserver(event); - const std::string kind = stringValue(event, "kind"); - const std::string action = stringValue(event, "action"); - const std::string correlationId = stringValue(event, "correlationId"); - const bool staleReadResult = - kind == "result" && action == "thread.read" && !correlationId.empty() && - staleReadResultCorrelations.erase(correlationId) > 0; - if (!staleReadResult) - model.applyEvent(event); - - const ConnectionPresentation &connection = model.connection(); - if (connection.generation != observedConnectionGeneration) { - observedConnectionGeneration = connection.generation; - resetRuntimeForConnection(); - } - if (connection.providerGeneration != observedProviderGeneration) { - observedProviderGeneration = connection.providerGeneration; - resetRuntimeForConnection(); - } - - const std::string type = stringValue(event, "type"); - const nlohmann::json data = event.value("data", nlohmann::json::object()); - const nlohmann::json scope = event.value("scope", nlohmann::json::object()); - const std::string eventThreadId = stringValue(scope, "threadId"); - const bool hydrationResult = - kind == "result" && presentation::isThreadHydrationAction(action); - if (!eventThreadId.empty() && !hydrationResult) - model.noteThreadActivity(eventThreadId, nowSeconds()); - if (kind == "event" && type == "pending-request.removed") { - const auto requestId = scope.find("requestId"); - if (requestId != scope.end() && !requestId->is_null()) - resolvingRequests.erase(requestId->dump()); - } - if (kind == "event" && type == "connection.provider" && - stringValue(data, "state") == "disconnected") - resetRuntimeForConnection(); - - if (kind == "result" && !event.value("ok", false) && - action != "turn.start" && action != "turn.steer" && - action != "thread.read" && action != "thread.resume") { - const std::string message = - safeMessage(event.value("error", nlohmann::json::object())); - showNotice(message.empty() ? "Codex operation failed" : message); - } else if (kind == "event" && type == "notice.added") { - const nlohmann::json notice = - data.value("notice", nlohmann::json::object()); - const std::string message = safeMessage(notice); - if (!message.empty()) - showNotice(message, stringValue(data, "severity") == "error"); - } else if (kind == "event" && type == "system.diagnostic") { - const std::string message = safeMessage(data); - if (!message.empty()) - showNotice("Protocol diagnostic: " + message); - } else if (kind == "event" && type == "connection.lifecycle" && - (stringValue(data, "state") == "failure" || - stringValue(data, "state") == "disconnected")) { - const std::string detail = stringValue(data, "detail"); - if (!detail.starts_with("local-")) - showNotice(detail.empty() ? "Codex bridge disconnected" : detail); - } - - if (kind == "event" && - ((type == "connection.provider" && - stringValue(data, "state") == "ready") || - (type == "connection.bridge" && - stringValue(data, "state") == "opened" && providerReady()))) - hydrateProvider(); - if (kind == "event" && type == "connection.controller" && providerReady() && - model.connection().role == "controller") { - ensureThreadSettingsHydrated(selectedThreadId); - for (const std::string &threadId : prompts.queuedThreadIds()) - schedulePromptDispatch(threadId); - } - - if (type == "thread.removed" && !eventThreadId.empty()) { - prompts.clearThread(eventThreadId); - runtimeByThread.erase(eventThreadId); - historyWindows.erase(eventThreadId); - if (selectedThreadId == eventThreadId) { - selectedThreadId.clear(); - effects.push_back(UiEffect::ClearComposerDraft); - } - } else if (!eventThreadId.empty()) { - if (const ThreadPresentation *thread = model.thread(eventThreadId)) - prompts.reconcile(eventThreadId, *thread); - } - - if (!staleReadResult && kind == "result" && action == "thread.read" && - event.value("ok", false)) - hydrateHistoricalChildren(eventThreadId); - else if (kind == "event" && type == "agents.activity.upsert") - hydrateHistoricalChildren(eventThreadId); - - changed(); - } - - void noteThreadActivity(const std::string &threadId) { - model.noteThreadActivity(threadId, nowSeconds()); - changed(); - } - - void notePromptActivity(const std::string &threadId) { - model.notePromptActivity(threadId, nowSeconds()); - changed(); - } - - void hydrateHistoricalChildren(const std::string &parentThreadId, - bool retryFailed = false) { - const ThreadPresentation *thread = model.thread(parentThreadId); - if (!thread) - return; - for (const std::string &childThreadId : thread->childThreadOrder) { - const ChildThreadOwnership *ownership = - model.childOwnership(childThreadId); - if (!ownership || ownership->parentThreadId != parentThreadId) - continue; - const auto agent = thread->agents.find(ownership->agentId); - if (agent == thread->agents.end() || - !isActiveStatus(agent->second.status)) - continue; - const auto runtime = runtimeByThread.find(childThreadId); - const bool failed = runtime != runtimeByThread.end() && - runtime->second.hydration == Hydration::Failed; - if (!failed || retryFailed) - readThread(childThreadId, failed); - } - } - - void selectThread(std::string threadId) { - if (threadId.empty()) - return; - if (threadId == selectedThreadId) { - hydrateThreadForSelection(threadId); - return; - } - if (optimisticThread && optimisticThread->key == DraftThreadId && - prompts.submissions(std::string(DraftThreadId)).empty()) - optimisticThread.reset(); - selectedThreadId = std::move(threadId); - newThreadIntent = false; - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - historyWindows.try_emplace(selectedThreadId); - hydrateThreadForSelection(selectedThreadId); - changed(); - } - - void beginNewThread(UiNewThreadDraft draft) { - if (newThreadCreationInFlight) { - showNotice("The current new thread is still being created.", false); - return; - } - prompts.clearThread(std::string(DraftThreadId)); - selectedThreadId.clear(); - newThreadIntent = true; - newThreadName = std::move(draft.name); - newThreadWorkspace = - draft.workspace.empty() ? defaultWorkspace : std::move(draft.workspace); - newThreadOptions = nlohmann::json::object(); - if (!draft.baseInstructions.empty()) - newThreadOptions["baseInstructions"] = std::move(draft.baseInstructions); - if (!draft.developerInstructions.empty()) - newThreadOptions["developerInstructions"] = - std::move(draft.developerInstructions); - if (draft.ephemeral) - newThreadOptions["ephemeral"] = true; - optimisticThread = UiOptimisticThreadView{ - std::string(DraftThreadId), - {}, - newThreadName.empty() ? "New thread" : newThreadName, - newThreadWorkspace, - UiOptimisticThreadPhase::Awaiting}; - effects.push_back(UiEffect::ClearComposerDraft); - effects.push_back(UiEffect::FocusComposer); - changed(); - } - - void readThread(const std::string &threadId, bool forced = false) { - if (threadId.empty() || !providerReady()) - return; - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight) - return; - if (!forced && (runtime.hydration == Hydration::InFlight || - runtime.hydration == Hydration::Hydrated || - runtime.hydration == Hydration::Failed)) - return; - runtime.hydration = Hydration::InFlight; - const auto token = alive; - const std::uint64_t revision = nextReadRevision++; - runtime.readRevision = revision; - client.execute( - "thread.read", {{"threadId", threadId}, {"includeTurns", true}}, - [this, token, threadId, revision](const nlohmann::json &result) { - if (!*token) - return; - const auto current = runtimeByThread.find(threadId); - if (current == runtimeByThread.end() || - current->second.readRevision != revision) { - const std::string correlationId = - stringValue(result, "correlationId"); - if (!correlationId.empty()) - staleReadResultCorrelations.insert(correlationId); - return; - } - ThreadRuntimeState &runtime = current->second; - if (result.value("ok", false)) { - runtime.hydration = Hydration::Hydrated; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - resumeThreadForSettings(threadId); - schedulePromptDispatch(threadId); - return; - } - if (isTransientCancellation(result)) { - runtime.hydration = Hydration::NotHydrated; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - runtime.settingsHydration = SettingsHydration::Unknown; - return; - } - runtime.hydration = Hydration::Failed; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead) - runtime.settingsHydration = SettingsHydration::Failed; - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const std::string displayed = - message.empty() ? "Thread loading failed" : message; - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - }); - } - - void ensureThreadSettingsHydrated(const std::string &threadId) { - if (threadId.empty() || !canControlProvider()) - return; - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.settingsHydration == SettingsHydration::WaitingForRead || - runtime.settingsHydration == SettingsHydration::InFlight || - runtime.settingsHydration == SettingsHydration::Hydrated) - return; - if (runtime.hydration != Hydration::Hydrated) { - runtime.settingsHydration = SettingsHydration::WaitingForRead; - ensureThreadHydrated(threadId); - return; - } - resumeThreadForSettings(threadId); - } - - void resumeThreadForSettings(const std::string &threadId) { - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight) - return; - runtime.settingsHydration = SettingsHydration::InFlight; - const auto token = alive; - client.execute( - "thread.resume", {{"threadId", threadId}, {"excludeTurns", true}}, - [this, token, threadId](const nlohmann::json &result) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - if (!result.value("ok", false)) { - if (isTransientCancellation(result)) { - runtime.settingsHydration = SettingsHydration::Unknown; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - if (selectedThreadId == threadId) { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - showNotice(message.empty() ? "Thread settings refresh failed" - : message); - } - } else { - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.hydration = Hydration::Hydrated; - runtime.operationReady = true; - } - schedulePromptDispatch(threadId); - }); - } - - void ensureThreadHydrated(const std::string &threadId) { - if (threadId.empty() || !model.connection().connected) - return; - const auto found = runtimeByThread.find(threadId); - if (found != runtimeByThread.end() && - (found->second.hydration == Hydration::Hydrated || - found->second.hydration == Hydration::InFlight)) - return; - readThread(threadId); - } - - void hydrateThreadForSelection(const std::string &threadId) { - const auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end() && - runtime->second.hydration == Hydration::Failed) - readThread(threadId, true); - else - ensureThreadHydrated(threadId); - ensureThreadSettingsHydrated(threadId); - hydrateHistoricalChildren(threadId, true); - } - - bool submitPrompt(UiPromptDraft draft) { - draft.text = trimAscii(std::move(draft.text)); - if (draft.text.empty()) - return false; - if (!canControlProvider()) { - showNotice("Codex is not ready for a controlled turn. Your message was " - "not sent."); - return false; - } - draft.text = - middle::promptWithFileLinks(std::move(draft.text), draft.attachments); - const bool selectedNewThreadDraft = - draft.visiblySelectedThreadId == DraftThreadId && newThreadIntent; - if (!draft.visiblySelectedThreadId.empty() && - draft.visiblySelectedThreadId != selectedThreadId && - !selectedNewThreadDraft) { - if (!model.thread(draft.visiblySelectedThreadId)) { - showNotice("The visibly selected thread is no longer available. Your " - "message was not sent."); - return false; - } - selectThread(draft.visiblySelectedThreadId); - } - - std::string destination = selectedThreadId; - const ThreadPresentation *thread = model.thread(destination); - if (destination.empty()) { - if (!newThreadIntent) { - showNotice("No destination thread is selected. Your message was not " - "sent; select a thread or use New thread."); - effects.push_back(UiEffect::FocusComposer); - return false; - } - destination = DraftThreadId; - thread = nullptr; - } else if (!thread) { - showNotice("The selected thread is no longer available. Your message " - "was not sent."); - return false; - } - - if (destination != DraftThreadId) { - const auto runtime = runtimeByThread.find(destination); - if (runtime != runtimeByThread.end() && - runtime->second.hydration == Hydration::Failed) { - showNotice("Thread loading failed. Reload the thread before sending; " - "your message was not sent."); - effects.push_back(UiEffect::FocusComposer); - return false; - } - } - - const auto activeTurn = destination == DraftThreadId - ? std::optional{} - : activeTurnId(destination); - const std::int64_t admittedAt = now(); - const std::uint64_t submissionId = prompts.admit( - destination, std::move(draft.text), std::move(draft.attachments), - std::move(draft.turnStartOptions), thread, activeTurn, admittedAt); - const std::int64_t animationAt = - admittedAt + middle::PendingAnimationDelayMilliseconds; - pendingAnimationDeadlines[submissionId] = animationAt; - scheduleWakeup(animationAt); - effects.push_back(UiEffect::PrepareLocalPromptAdmission); - if (destination == DraftThreadId) { - pendingThreadStartOptions = std::move(draft.threadStartOptions); - pendingThreadWorkspace = draft.workspace.empty() - ? newThreadWorkspace - : std::move(draft.workspace); - changed(); - startThreadForDraft(); - } else { - changed(); - schedulePromptDispatch(destination); - } - return true; - } - - void startThreadForDraft() { - if (!canControlProvider() || newThreadCreationInFlight || - prompts.submissions(std::string(DraftThreadId)).empty()) - return; - newThreadCreationInFlight = true; - nlohmann::json options = pendingThreadStartOptions; - options.update(newThreadOptions); - options["cwd"] = pendingThreadWorkspace.empty() - ? (newThreadWorkspace.empty() ? defaultWorkspace - : newThreadWorkspace) - : pendingThreadWorkspace; - const std::string requestedName = newThreadName; - const auto token = alive; - client.execute( - "thread.create", std::move(options), - [this, token, requestedName](const nlohmann::json &result) { - if (!*token) - return; - newThreadCreationInFlight = false; - if (!result.value("ok", false)) { - if (isTransientCancellation(result)) { - changed(); - return; - } - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const std::string error = - message.empty() ? "Thread creation failed" : message; - std::vector ids; - for (const auto &submission : - prompts.submissions(std::string(DraftThreadId))) - ids.push_back(submission.id); - for (const std::uint64_t id : ids) - static_cast( - prompts.fail(std::string(DraftThreadId), id, error)); - if (optimisticThread) - optimisticThread->phase = UiOptimisticThreadPhase::Failed; - showNotice(error); - return; - } - const std::string threadId = - stringValue(result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (threadId.empty()) { - const std::string error = - "Thread creation returned no thread identifier"; - std::vector ids; - for (const auto &submission : - prompts.submissions(std::string(DraftThreadId))) - ids.push_back(submission.id); - for (const std::uint64_t id : ids) - static_cast( - prompts.fail(std::string(DraftThreadId), id, error)); - if (optimisticThread) - optimisticThread->phase = UiOptimisticThreadPhase::Failed; - showNotice(error); - return; - } - if (!prompts.reassignThread(std::string(DraftThreadId), threadId)) { - if (optimisticThread) - optimisticThread->phase = UiOptimisticThreadPhase::Failed; - showNotice("Could not attach the draft prompts to the created " - "thread."); - return; - } - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - if (optimisticThread) { - optimisticThread->threadId = threadId; - } - const bool viewingDraft = selectedThreadId.empty() && newThreadIntent; - if (viewingDraft) { - selectedThreadId = threadId; - newThreadIntent = false; - } - newThreadOptions = nlohmann::json::object(); - newThreadName.clear(); - newThreadWorkspace.clear(); - pendingThreadStartOptions = nlohmann::json::object(); - pendingThreadWorkspace.clear(); - if (!requestedName.empty()) - client.execute("thread.rename", - {{"threadId", threadId}, {"name", requestedName}}); - changed(); - schedulePromptDispatch(threadId); - }); - } - - void schedulePromptDispatch(const std::string &threadId) { - if (threadId.empty()) - return; - deferredPromptDispatch.insert(threadId); - scheduleWakeup(now()); - } - - void dispatchNextPrompt(const std::string &threadId) { - if (threadId.empty() || !canControlProvider()) - return; - auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end() && - runtime->second.settingsHydration == SettingsHydration::InFlight) - return; - const auto submissions = prompts.submissions(threadId); - if (std::ranges::none_of( - submissions, [](const middle::PromptSubmission &submission) { - return submission.state == middle::PromptState::Queued; - })) - return; - if (runtime != runtimeByThread.end() && runtime->second.resumeInFlight) - return; - if (runtime == runtimeByThread.end() || - runtime->second.hydration != Hydration::Hydrated) { - ensureThreadHydrated(threadId); - return; - } - if (prompts.hasInFlight(threadId)) - return; - const ThreadPresentation *thread = model.thread(threadId); - if (!runtime->second.operationReady && thread && - thread->status == "notLoaded") { - resumePromptQueue(threadId); - return; - } - const auto dispatch = prompts.beginNext(threadId, activeTurnId(threadId)); - if (dispatch) - dispatchPrompt(*dispatch); - } - - void dispatchPrompt(middle::PromptDispatch dispatch) { - nlohmann::json input = - nlohmann::json::array({{{"type", "text"}, - {"text", dispatch.prompt}, - {"text_elements", nlohmann::json::array()}}}); - for (const AttachmentDraft &attachment : dispatch.attachments) { - if (attachment.mimeType.starts_with("image/")) - input.push_back({{"type", "localImage"}, {"path", attachment.path}}); - else if (attachment.mimeType.starts_with("audio/")) - input.push_back({{"type", "localAudio"}, {"path", attachment.path}}); - } - const std::string threadId = dispatch.threadId; - const std::uint64_t submissionId = dispatch.id; - const auto token = alive; - auto completed = [this, token, threadId, - submissionId](const nlohmann::json &result) { - if (*token) - completePrompt(threadId, submissionId, result); - }; - if (dispatch.expectedTurnId) { - client.execute("turn.steer", - {{"threadId", dispatch.threadId}, - {"expectedTurnId", *dispatch.expectedTurnId}, - {"clientUserMessageId", dispatch.clientUserMessageId}, - {"input", std::move(input)}}, - std::move(completed)); - } else { - dispatch.turnOptions["clientUserMessageId"] = - dispatch.clientUserMessageId; - dispatch.turnOptions["threadId"] = dispatch.threadId; - dispatch.turnOptions["input"] = std::move(input); - client.execute("turn.start", std::move(dispatch.turnOptions), - std::move(completed)); - } - } - - void resumePromptQueue(const std::string &threadId) { - ThreadRuntimeState &runtime = runtimeByThread[threadId]; - if (runtime.resumeInFlight || !canControlProvider()) - return; - runtime.resumeInFlight = true; - const auto token = alive; - client.execute( - "thread.resume", {{"threadId", threadId}}, - [this, token, threadId](const nlohmann::json &result) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - runtime.resumeInFlight = false; - if (!result.value("ok", false)) { - if (isTransientCancellation(result)) { - runtime.hydration = Hydration::NotHydrated; - runtime.settingsHydration = SettingsHydration::Unknown; - runtime.operationReady = false; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const std::string displayed = - message.empty() ? "Thread resume failed" : message; - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - return; - } - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - schedulePromptDispatch(threadId); - }); - } - - void completePrompt(const std::string &threadId, std::uint64_t submissionId, - const nlohmann::json &result) { - if (isTransientCancellation(result)) { - if (prompts.requeue(threadId, submissionId)) { - if (auto runtime = runtimeByThread.find(threadId); - runtime != runtimeByThread.end()) { - runtime->second.hydration = Hydration::NotHydrated; - runtime->second.operationReady = false; - } - changed(); - } - return; - } - if (attemptThreadRecovery(threadId, submissionId, result)) - return; - const auto runtime = runtimeByThread.find(threadId); - if (runtime != runtimeByThread.end()) - runtime->second.recoveryAttemptedSubmissions.erase(submissionId); - if (result.value("ok", false)) { - if (runtime != runtimeByThread.end()) - runtime->second.operationReady = true; - const middle::PromptSubmission *submission = - prompts.submission(threadId, submissionId); - const bool startsTurn = submission && submission->startsTurn; - const std::optional turnId = resultTurnId(result); - static_cast( - prompts.acknowledge(threadId, submissionId, turnId)); - if (startsTurn && turnId && runtime != runtimeByThread.end()) - runtime->second.provisionalActiveTurnId = *turnId; - if (optimisticThread && optimisticThread->threadId == threadId) - optimisticThread->phase = UiOptimisticThreadPhase::Confirmed; - } else { - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - const std::string displayed = - message.empty() ? "Submission failed" : message; - static_cast(prompts.fail(threadId, submissionId, displayed)); - if (optimisticThread && optimisticThread->threadId == threadId) - optimisticThread->phase = UiOptimisticThreadPhase::Failed; - showNotice(message.empty() ? "Turn submission failed" : message); - } - pendingAnimationDeadlines.erase(submissionId); - changed(); - schedulePromptDispatch(threadId); - } - - bool attemptThreadRecovery(const std::string &threadId, - std::uint64_t submissionId, - const nlohmann::json &result) { - if (!isThreadNotFoundResult(result)) - return false; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return false; - ThreadRuntimeState &runtime = found->second; - if (!runtime.recoveryAttemptedSubmissions.insert(submissionId).second) - return false; - if (!prompts.requeue(threadId, submissionId)) - return false; - runtime.hydration = Hydration::NotHydrated; - runtime.operationReady = false; - changed(); - runtime.resumeInFlight = true; - const auto token = alive; - client.execute( - "thread.resume", {{"threadId", threadId}}, - [this, token, threadId](const nlohmann::json &resumeResult) { - if (!*token) - return; - const auto found = runtimeByThread.find(threadId); - if (found == runtimeByThread.end()) - return; - ThreadRuntimeState &runtime = found->second; - runtime.resumeInFlight = false; - if (!resumeResult.value("ok", false)) { - if (isTransientCancellation(resumeResult)) { - runtime.hydration = Hydration::NotHydrated; - runtime.settingsHydration = SettingsHydration::Unknown; - runtime.operationReady = false; - return; - } - runtime.settingsHydration = SettingsHydration::Failed; - const std::string message = safeMessage( - resumeResult.value("error", nlohmann::json::object())); - const std::string displayed = - message.empty() ? "Thread recovery failed" : message; - static_cast(prompts.failQueued(threadId, displayed)); - showNotice(displayed); - return; - } - runtime.hydration = Hydration::Hydrated; - runtime.settingsHydration = SettingsHydration::Hydrated; - runtime.operationReady = true; - schedulePromptDispatch(threadId); - }); - return true; - } - - void tick() { - nextWakeupAt.reset(); - const auto deferred = - std::exchange(deferredPromptDispatch, std::set{}); - for (const std::string &threadId : deferred) - dispatchNextPrompt(threadId); - - const std::int64_t current = now(); - bool projectionChanged = false; - for (auto iterator = pendingAnimationDeadlines.begin(); - iterator != pendingAnimationDeadlines.end();) { - if (iterator->second > current) { - scheduleWakeup(iterator->second); - ++iterator; - continue; - } - iterator = pendingAnimationDeadlines.erase(iterator); - projectionChanged = true; - } - if (projectionChanged) - changed(); - } - - [[nodiscard]] bool isPendingActionable(const std::string &requestKey) const { - const auto request = model.pendingRequestPresentations().find(requestKey); - return canControlProvider() && - request != model.pendingRequestPresentations().end() && - request->second.generation == model.connection().generation && - !resolvingRequests.contains(requestKey); - } - - [[nodiscard]] UiPendingRequestView - pendingView(const PendingRequestPresentation &request) const { - return { - request.id, - request.kind, - request.threadId, - request.generation, - request.raw, - PendingRequestPolicy::title(request.kind), - PendingRequestPolicy::detail(request.id, request.threadId, request.raw), - PendingRequestPolicy::directAcceptLabel(request.kind), - PendingRequestPolicy::supportsDirectAccept(request.kind), - isPendingActionable(request.id)}; - } - - bool resolvePending(UiPendingRequestView request, - PendingRequestResponse response) { - const auto current = model.pendingRequestPresentations().find(request.id); - if (!canControlProvider() || - current == model.pendingRequestPresentations().end() || - current->second.generation != model.connection().generation || - current->second.generation != request.generation || - current->second.kind != request.kind || - current->second.threadId != request.threadId || - current->second.raw != request.raw || - resolvingRequests.contains(request.id)) { - showNotice("The pending request is no longer actionable.", false); - return false; - } - const nlohmann::json nativeId = - nlohmann::json::parse(request.id, nullptr, false); - if (nativeId.is_discarded()) { - showNotice("The pending request has an invalid identity."); - return false; - } - resolvingRequests.insert(request.id); - if (!client.respond(nativeId, std::move(response.result), - std::move(response.error))) { - resolvingRequests.erase(request.id); - showNotice("The pending response could not be sent."); - return false; - } - if (!current->second.threadId.empty()) - model.noteThreadActivity(current->second.threadId, nowSeconds()); - changed(); - return true; - } - - UiSettingsView projectSettings() const { - UiSettingsView result; - result.identity = "no-thread"; - if (const ThreadPresentation *thread = model.thread(selectedThreadId)) { - result.identity = thread->id; - result.settingsUpdate = thread->latestSettingsUpdate; - result.settingsRevision = thread->settingsRevision; - result.canonical = thread->raw; - const auto settings = thread->domains.find("thread.settings.changed"); - if (settings != thread->domains.end() && settings->second.is_object()) { - nlohmann::json update = settings->second; - if (update.contains("threadSettings") && - update["threadSettings"].is_object()) - update = update["threadSettings"]; - if (update.contains("effort")) - result.canonical.erase("reasoningEffort"); - if (update.contains("sandboxPolicy")) - result.canonical.erase("sandbox"); - result.canonical.merge_patch(update); - } - } else if (newThreadIntent) { - result.identity = DraftThreadId; - result.canonical["cwd"] = - newThreadWorkspace.empty() ? defaultWorkspace : newThreadWorkspace; - } else { - result.canonical["cwd"] = defaultWorkspace; - } - const auto profiles = - model.globalDomains().find("operation.permission-profiles.list"); - if (profiles != model.globalDomains().end()) - result.permissionProfiles = profiles->second; - result.modelCatalog = model.modelCatalog(); - return result; - } - - UiSessionView &refreshView(bool conversationFollowing, - std::string draftWorkspace) { - const std::int64_t current = now(); - const std::string visibleThreadId = - selectedThreadId.empty() && newThreadIntent ? std::string(DraftThreadId) - : selectedThreadId; - viewState = UiSessionView{}; - viewState.selectedThreadId = selectedThreadId; - viewState.newThreadIntent = newThreadIntent; - viewState.threads = ui::projectThreadListSnapshot(model, visibleThreadId); - viewState.inspector = ui::projectInspectorSnapshot( - model, selectedThreadId, [this](std::string_view requestId) { - return isPendingActionable(std::string(requestId)); - }); - viewState.settings = projectSettings(); - viewState.optimisticThread = optimisticThread; - - const ThreadPresentation *thread = model.thread(selectedThreadId); - middle::AuthoritativeItemIndex authoritativeItems = - middle::indexAuthoritativeItems(visibleThreadId, thread); - if (thread) - prompts.reconcile(selectedThreadId, authoritativeItems); - const std::size_t authoritativeCount = authoritativeItems.ordered.size(); - HistoryWindow &history = historyWindows[visibleThreadId]; - if (!conversationFollowing && - authoritativeCount > history.lastAuthoritativeCount) - history.effective += authoritativeCount - history.lastAuthoritativeCount; - else if (conversationFollowing) - history.effective = history.requested; - history.lastAuthoritativeCount = authoritativeCount; - UiConversationView &conversation = viewState.conversation; - conversation.key = visibleThreadId; - conversation.snapshot = middle::ConversationProjection::project( - authoritativeItems, thread, prompts.submissions(visibleThreadId), - history.effective, current); - conversation.snapshot.activeTurnId = activeTurnId(selectedThreadId); - if (thread) { - conversation.mode = UiConversationMode::Thread; - conversation.title = thread->title; - conversation.workspace = thread->cwd; - const PresentationStatus status = classifyStatus(thread->status); - conversation.status = displayStatus(thread->status); - conversation.statusTone = std::string(status.tone); - conversation.lastActivityAt = thread->lastActivityAt; - conversation.emptyMessage = "No materialized activity."; - } else if (newThreadIntent) { - conversation.mode = UiConversationMode::NewThread; - conversation.title = newThreadName.empty() ? "New thread" : newThreadName; - conversation.workspace = - draftWorkspace.empty() - ? (newThreadWorkspace.empty() ? defaultWorkspace - : newThreadWorkspace) - : std::move(draftWorkspace); - conversation.emptyMessage = "Send a message to create this thread."; - } else { - conversation.mode = UiConversationMode::NoSelection; - conversation.title = "Select a thread"; - conversation.workspace = "No workspace"; - conversation.emptyMessage = "Conversation activity appears here."; - } - - const ConnectionPresentation &connection = model.connection(); - UiStatusView &status = viewState.status; - status.connected = connection.connected; - status.retrying = connection.retrying; - status.role = connection.role; - status.providerState = connection.providerState; - status.connectionSettings = connection.settings; - status.workspace = conversation.workspace; - status.activeTurn = activeTurnId(selectedThreadId).has_value(); - status.totalPending = model.pendingRequestCount(); - const std::string selectedKey = - stringValue(connection.settings, "selected"); - const nlohmann::json available = - connection.settings.value("available", nlohmann::json::array()); - if (available.is_array()) { - for (const auto &entry : available) { - if (stringValue(entry, "key") == selectedKey) { - status.selectedTransport = stringValue(entry, "label"); - break; - } - } - } - for (const auto &[id, request] : model.pendingRequestPresentations()) { - UiPendingRequestView projected = pendingView(request); - if (request.threadId == selectedThreadId) { - ++status.selectedPending; - if (!viewState.selectedPendingRequest) - viewState.selectedPendingRequest = projected; - } - viewState.pendingRequests.push_back(std::move(projected)); - } - status.canSubmit = canControlProvider(); - status.canEditSettings = status.canSubmit && !status.activeTurn; - return viewState; - } - - PresentationClient client; - std::string defaultWorkspace; - UiSession::Clock clock; - std::shared_ptr alive; - UiSession::ChangedHandler changedHandler; - UiSession::WakeupHandler wakeupHandler; - UiSession::ProtocolFrameObserver protocolFrameObserver; - std::optional nextWakeupAt; - - PresentationModel model; - middle::PromptCoordinator prompts; - std::string selectedThreadId; - bool newThreadIntent = false; - bool newThreadCreationInFlight = false; - nlohmann::json newThreadOptions = nlohmann::json::object(); - std::string newThreadName; - std::string newThreadWorkspace; - nlohmann::json pendingThreadStartOptions = nlohmann::json::object(); - std::string pendingThreadWorkspace; - std::optional optimisticThread; - - std::unordered_map runtimeByThread; - std::unordered_set resolvingRequests; - std::unordered_set staleReadResultCorrelations; - std::uint64_t nextReadRevision = 1; - std::unordered_map historyWindows; - std::uint64_t observedConnectionGeneration = 0; - std::uint64_t observedProviderGeneration = 0; - std::set deferredPromptDispatch; - std::map pendingAnimationDeadlines; - - std::vector notices; - std::vector effects; - std::uint64_t nextNoticeId = 1; - UiSessionView viewState; -}; - -UiSession::UiSession(PresentationClient client, std::string defaultWorkspace, - Clock clock) - : impl(std::make_unique( - std::move(client), std::move(defaultWorkspace), std::move(clock))) {} - -UiSession::~UiSession() = default; - -void UiSession::setChangedHandler(ChangedHandler handler) { - impl->changedHandler = std::move(handler); -} - -void UiSession::setWakeupHandler(WakeupHandler handler) { - impl->wakeupHandler = std::move(handler); - if (impl->wakeupHandler && impl->nextWakeupAt) - impl->wakeupHandler(*impl->nextWakeupAt); -} - -void UiSession::setProtocolFrameObserver(ProtocolFrameObserver observer) { - impl->protocolFrameObserver = std::move(observer); -} - -void UiSession::onPresentationFrame(const nlohmann::json &frame) { - impl->onPresentationFrame(frame); -} - -void UiSession::noteThreadActivity(const std::string &threadId) { - impl->noteThreadActivity(threadId); -} - -void UiSession::notePromptActivity(const std::string &threadId) { - impl->notePromptActivity(threadId); -} - -void UiSession::tick() { impl->tick(); } - -std::string UiSession::conversationKey() const { - return impl->selectedThreadId.empty() && impl->newThreadIntent - ? std::string(DraftThreadId) - : impl->selectedThreadId; -} - -const UiSessionView &UiSession::refreshView(bool conversationFollowing, - std::string draftWorkspace) { - return impl->refreshView(conversationFollowing, std::move(draftWorkspace)); -} - -std::vector UiSession::takeNotices() { - return std::exchange(impl->notices, {}); -} - -std::vector UiSession::takeEffects() { - return std::exchange(impl->effects, {}); -} - -void UiSession::refreshThreads() { - if (impl->providerReady()) - impl->client.execute("threads.list", nlohmann::json::object()); -} - -void UiSession::connectTransport() { impl->client.send("connection.connect"); } - -void UiSession::disconnectTransport() { - impl->client.send("connection.disconnect"); -} - -void UiSession::reconnectTransport() { - impl->client.send("connection.reconnect"); -} - -void UiSession::configureConnection(nlohmann::json settings) { - const auto token = impl->alive; - impl->client.execute( - "connection.configure", std::move(settings), - [implementation = impl.get(), token](const nlohmann::json &result) { - if (!*token || result.value("ok", false)) - return; - const std::string message = - safeMessage(result.value("error", nlohmann::json::object())); - implementation->showNotice( - message.empty() ? "Connection configuration failed" : message); - }); -} - -void UiSession::toggleController() { - impl->client.send(impl->model.connection().role == "controller" - ? "controller.release" - : "controller.claim"); -} - -void UiSession::selectThread(std::string threadId) { - impl->selectThread(std::move(threadId)); -} - -void UiSession::reloadThread(const std::string &threadId) { - impl->runtimeByThread[threadId].settingsHydration = - Impl::SettingsHydration::Unknown; - impl->readThread(threadId, true); - impl->ensureThreadSettingsHydrated(threadId); -} - -void UiSession::beginNewThread(UiNewThreadDraft draft) { - impl->beginNewThread(std::move(draft)); -} - -void UiSession::renameThread(const std::string &threadId, std::string name) { - name = trimAscii(std::move(name)); - if (!impl->canControlProvider() || !impl->model.thread(threadId) || - name.empty()) - return; - impl->client.execute("thread.rename", - {{"threadId", threadId}, {"name", std::move(name)}}); -} - -void UiSession::forkThread(const std::string &threadId) { - if (threadId.empty() || !impl->canControlProvider()) - return; - const auto token = impl->alive; - impl->client.execute( - "thread.fork", {{"threadId", threadId}}, - [implementation = impl.get(), token](const nlohmann::json &result) { - if (!*token || !result.value("ok", false)) - return; - const std::string id = - stringValue(result.value("data", nlohmann::json::object()) - .value("thread", nlohmann::json::object()), - "id"); - if (!id.empty()) - implementation->selectThread(id); - }); -} - -void UiSession::toggleThreadArchive(const std::string &threadId) { - if (!impl->canControlProvider()) - return; - const ThreadPresentation *thread = impl->model.thread(threadId); - if (!thread) - return; - impl->client.execute(thread->archived ? "thread.unarchive" : "thread.archive", - {{"threadId", threadId}}); -} - -void UiSession::deleteThread(const std::string &threadId) { - if (!threadId.empty() && impl->canControlProvider()) - impl->client.execute("thread.delete", {{"threadId", threadId}}); -} - -bool UiSession::submitPrompt(UiPromptDraft draft) { - return impl->submitPrompt(std::move(draft)); -} - -void UiSession::interruptTurn() { - const auto turn = impl->activeTurnId(impl->selectedThreadId); - if (turn) - impl->client.execute( - "turn.interrupt", - {{"threadId", impl->selectedThreadId}, {"turnId", *turn}}); -} - -void UiSession::loadEarlierConversation() { - const std::string key = - impl->selectedThreadId.empty() && impl->newThreadIntent - ? std::string(DraftThreadId) - : impl->selectedThreadId; - Impl::HistoryWindow &history = impl->historyWindows[key]; - history.requested += - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - history.effective += - middle::ConversationProjection::DefaultAuthoritativeItemLimit; - impl->changed(); -} - -bool UiSession::resolvePending(UiPendingRequestView request, - PendingRequestResponse response) { - return impl->resolvePending(std::move(request), std::move(response)); -} - -} // namespace codexui::codex diff --git a/src/codex/UiSession.h b/src/codex/UiSession.h deleted file mode 100644 index f984d11..0000000 --- a/src/codex/UiSession.h +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_UISESSION_H -#define CODEXUI_CODEX_UISESSION_H - -#include "codex/AttachmentDraft.h" -#include "codex/PendingRequestPolicy.h" -#include "codex/PresentationClient.h" -#include "codex/middle/MiddleTypes.h" -#include "codex/ui/UiViewState.h" - -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui::codex { - -struct UiNotice { - std::uint64_t id = 0; - std::string message; - bool error = true; - - bool operator==(const UiNotice &) const = default; -}; - -struct UiNewThreadDraft { - std::string workspace; - std::string name; - std::string baseInstructions; - std::string developerInstructions; - bool ephemeral = false; -}; - -struct UiPromptDraft { - std::string text; - std::vector attachments; - nlohmann::json turnStartOptions = nlohmann::json::object(); - nlohmann::json threadStartOptions = nlohmann::json::object(); - std::string workspace; - std::string visiblySelectedThreadId; -}; - -struct UiSettingsView { - std::string identity; - nlohmann::json canonical = nlohmann::json::object(); - nlohmann::json modelCatalog = nlohmann::json::array(); - nlohmann::json permissionProfiles = nlohmann::json::array(); - std::uint64_t settingsRevision = 0; - nlohmann::json settingsUpdate = nlohmann::json::object(); - - bool operator==(const UiSettingsView &) const = default; -}; - -struct UiPendingRequestView { - std::string id; - std::string kind; - std::string threadId; - std::uint64_t generation = 0; - nlohmann::json raw = nlohmann::json::object(); - std::string title; - std::string detail; - std::string directAcceptLabel; - bool supportsDirectAccept = false; - bool actionable = false; - - bool operator==(const UiPendingRequestView &) const = default; -}; - -struct UiStatusView { - bool connected = false; - bool retrying = false; - std::string role; - std::string providerState; - std::string selectedTransport; - std::string workspace; - bool activeTurn = false; - std::size_t selectedPending = 0; - std::size_t totalPending = 0; - bool canSubmit = false; - bool canEditSettings = false; - nlohmann::json connectionSettings = nlohmann::json::object(); - - bool operator==(const UiStatusView &) const = default; -}; - -enum class UiConversationMode { NoSelection, NewThread, Thread }; - -struct UiConversationView { - UiConversationMode mode = UiConversationMode::NoSelection; - std::string key; - std::string title; - std::string workspace; - std::string status; - std::string statusTone; - std::optional lastActivityAt; - std::string emptyMessage; - middle::ConversationSnapshot snapshot; - - bool operator==(const UiConversationView &) const = default; -}; - -enum class UiOptimisticThreadPhase { Awaiting, Confirmed, Failed }; - -struct UiOptimisticThreadView { - std::string key; - std::string threadId; - std::string title; - std::string workspace; - UiOptimisticThreadPhase phase = UiOptimisticThreadPhase::Awaiting; - - bool operator==(const UiOptimisticThreadView &) const = default; -}; - -struct UiSessionView { - std::string selectedThreadId; - bool newThreadIntent = false; - ui::ThreadListSnapshot threads; - UiConversationView conversation; - ui::InspectorSnapshot inspector; - UiSettingsView settings; - UiStatusView status; - std::optional optimisticThread; - std::vector pendingRequests; - std::optional selectedPendingRequest; - - bool operator==(const UiSessionView &) const = default; -}; - -enum class UiEffect { - ClearComposerDraft, - FocusComposer, - PrepareLocalPromptAdmission, -}; - -// Authoritative UI/UX state owner. It is called on the existing GUI thread in -// this refactor. The class itself is toolkit-neutral and talks downward only -// through PresentationClient's generic presentation-protocol API. -class UiSession final { -public: - using Clock = std::function; - using ChangedHandler = std::function; - using WakeupHandler = std::function; - using ProtocolFrameObserver = - std::function; - - explicit UiSession(PresentationClient client, std::string defaultWorkspace, - Clock clock = {}); - ~UiSession(); - - UiSession(const UiSession &) = delete; - UiSession &operator=(const UiSession &) = delete; - - void setChangedHandler(ChangedHandler handler); - void setWakeupHandler(WakeupHandler handler); - void setProtocolFrameObserver(ProtocolFrameObserver observer); - - void onPresentationFrame(const nlohmann::json &frame); - void noteThreadActivity(const std::string &threadId); - void notePromptActivity(const std::string &threadId); - void tick(); - - [[nodiscard]] std::string conversationKey() const; - [[nodiscard]] const UiSessionView & - refreshView(bool conversationFollowing, std::string draftWorkspace = {}); - [[nodiscard]] std::vector takeNotices(); - [[nodiscard]] std::vector takeEffects(); - - void refreshThreads(); - void connectTransport(); - void disconnectTransport(); - void reconnectTransport(); - void configureConnection(nlohmann::json settings); - void toggleController(); - - void selectThread(std::string threadId); - void reloadThread(const std::string &threadId); - void beginNewThread(UiNewThreadDraft draft); - void renameThread(const std::string &threadId, std::string name); - void forkThread(const std::string &threadId); - void toggleThreadArchive(const std::string &threadId); - void deleteThread(const std::string &threadId); - - [[nodiscard]] bool submitPrompt(UiPromptDraft draft); - void interruptTurn(); - void loadEarlierConversation(); - - [[nodiscard]] bool - resolvePending(UiPendingRequestView request, - PendingRequestResponse response); - -private: - class Impl; - std::unique_ptr impl; -}; - -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_UISESSION_H diff --git a/src/codex/PresentationStatus.h b/src/codex/UiStatus.h similarity index 90% rename from src/codex/PresentationStatus.h rename to src/codex/UiStatus.h index dc65baa..de16ebd 100644 --- a/src/codex/PresentationStatus.h +++ b/src/codex/UiStatus.h @@ -1,7 +1,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later OR MIT -#ifndef CODEXUI_CODEX_PRESENTATIONSTATUS_H -#define CODEXUI_CODEX_PRESENTATIONSTATUS_H +#ifndef CODEXUI_CODEX_UISTATUS_H +#define CODEXUI_CODEX_UISTATUS_H #include #include @@ -19,13 +19,13 @@ enum class StatusKind { NotLoaded, }; -struct PresentationStatus { +struct UiStatus { StatusKind kind; std::string_view text; std::string_view tone; }; -constexpr PresentationStatus classifyStatus(std::string_view status) noexcept { +constexpr UiStatus classifyStatus(std::string_view status) noexcept { if (status == "active" || status == "inProgress" || status == "running" || status == "started") return {StatusKind::Active, "running", "active"}; @@ -44,7 +44,7 @@ constexpr PresentationStatus classifyStatus(std::string_view status) noexcept { } inline std::string displayStatus(std::string_view status) { - const PresentationStatus classified = classifyStatus(status); + const UiStatus classified = classifyStatus(status); if (classified.kind != StatusKind::Unknown || status.empty()) return std::string(classified.text); @@ -88,4 +88,4 @@ constexpr bool isTerminalTurnStatus(std::string_view status) noexcept { } // namespace codexui::codex -#endif // CODEXUI_CODEX_PRESENTATIONSTATUS_H +#endif // CODEXUI_CODEX_UISTATUS_H diff --git a/src/codex/WorkerMailboxReceiver.cpp b/src/codex/WorkerMailboxReceiver.cpp new file mode 100644 index 0000000..2f56239 --- /dev/null +++ b/src/codex/WorkerMailboxReceiver.cpp @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/WorkerMailboxReceiver.h" + +#include +#include + +#include + +namespace codexui::codex { +namespace { + +constexpr double WakeRecoveryIntervalSeconds = 0.1; + +snode::log::Scope makeLogScope() { + return {.origin = snode::log::Origin::Application, + .boundary = snode::log::Boundary::Application, + .component = "codexui.nodegraph", + .identity = {.instance = "worker-mailbox", + .role = snode::log::Role::Client}}; +} + +} // namespace + +WorkerMailboxReceiver * +WorkerMailboxReceiver::create(nodegraph::ThreadChannels &channels, + MessageHandler onMessage, + FailureHandler onFailure) { + if (!onMessage || channels.qtToWorkerEventFd() < 0) + return nullptr; + + auto *receiver = new WorkerMailboxReceiver(channels, std::move(onMessage), + std::move(onFailure)); + if (!receiver->ReadEventReceiver::enable(channels.qtToWorkerEventFd())) { + delete receiver; + return nullptr; + } + return receiver; +} + +WorkerMailboxReceiver::WorkerMailboxReceiver( + nodegraph::ThreadChannels &channels, MessageHandler onMessage, + FailureHandler onFailure) + : core::eventreceiver::ReadEventReceiver( + "CodexUI worker mailbox", makeLogScope(), + utils::Timeval(WakeRecoveryIntervalSeconds)), + channels_(channels), onMessage_(std::move(onMessage)), + onFailure_(std::move(onFailure)), + deferredReceiver_(std::make_shared(this)) {} + +WorkerMailboxReceiver::~WorkerMailboxReceiver() { invalidateScheduledDrain(); } + +void WorkerMailboxReceiver::close() { + if (closing_) + return; + closing_ = true; + invalidateScheduledDrain(); + if (ReadEventReceiver::isEnabled()) + ReadEventReceiver::disable(); +} + +void WorkerMailboxReceiver::readEvent() { consumeWakeAndMessages(true); } + +void WorkerMailboxReceiver::readTimeout() { + // The eventfd is the normal notification path. This bounded timeout is only + // a recovery path for a payload admitted immediately before a failed wake, + // including ShutdownRequest, so the worker cannot sleep indefinitely. + if (channels_.qtToWorkerSizeApprox() != 0) + consumeWakeAndMessages(false); + + // SNode.C descriptor inactivity timeouts remain expired until explicitly + // rearmed. Leaving this receiver expired makes the worker event loop poll + // with a zero timeout forever after the first idle 100 ms. Rearm the narrow + // wake-failure safety net after every timeout so an idle worker sleeps. + if (!closing_) + setTimeout(utils::Timeval(WakeRecoveryIntervalSeconds)); +} + +void WorkerMailboxReceiver::unobservedEvent() { + invalidateScheduledDrain(); + delete this; +} + +void WorkerMailboxReceiver::destruct() { close(); } + +void WorkerMailboxReceiver::shutdownEvent( + const core::ShutdownContext &context) { + static_cast(context); + close(); +} + +void WorkerMailboxReceiver::consumeWakeAndMessages(bool drainWake) { + if (closing_) + return; + + if (drainWake) { + const nodegraph::EventFd::DrainResult wake = + channels_.drainQtToWorkerWake(); + if (!wake.accepted()) { + fail("Qt-to-worker eventfd failed while draining"); + return; + } + } + + std::size_t consumed = 0; + while (!closing_ && consumed < MaximumMessagesPerEvent) { + nodegraph::QtToWorkerMessage message; + if (!channels_.tryReceiveForWorker(message)) + return; + + ++consumed; + try { + // tryReceiveForWorker has already moved the message out of the queue and + // released its slot before application logic is entered. + onMessage_(std::move(message)); + } catch (...) { + fail("Qt-to-worker action handler threw an exception"); + return; + } + } + + if (!closing_ && channels_.qtToWorkerSizeApprox() != 0) + scheduleNextDrain(); +} + +void WorkerMailboxReceiver::scheduleNextDrain() { + if (closing_ || drainScheduled_) + return; + drainScheduled_ = true; + + const std::weak_ptr weak = deferredReceiver_; + try { + core::EventReceiver::atNextTick([weak] { + const std::shared_ptr receiver = weak.lock(); + if (!receiver || *receiver == nullptr) + return; + (*receiver)->drainScheduled_ = false; + (*receiver)->consumeWakeAndMessages(false); + }); + } catch (...) { + drainScheduled_ = false; + fail("SNode.C rejected deferred Qt-to-worker mailbox draining"); + } +} + +void WorkerMailboxReceiver::fail(std::string reason) noexcept { + if (closing_) + return; + close(); + if (!onFailure_) + return; + try { + onFailure_(std::move(reason)); + } catch (...) { + // The receiver is already closed. Failure reporting must never revive the + // only mailbox consumer or prevent the worker shutdown path. + } +} + +void WorkerMailboxReceiver::invalidateScheduledDrain() noexcept { + if (deferredReceiver_) + *deferredReceiver_ = nullptr; + deferredReceiver_.reset(); + drainScheduled_ = false; +} + +} // namespace codexui::codex diff --git a/src/codex/WorkerMailboxReceiver.h b/src/codex/WorkerMailboxReceiver.h new file mode 100644 index 0000000..58c0947 --- /dev/null +++ b/src/codex/WorkerMailboxReceiver.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_WORKERMAILBOXRECEIVER_H +#define CODEXUI_CODEX_WORKERMAILBOXRECEIVER_H + +#include "codex/nodegraph/Messages.h" +#include "codex/nodegraph/ThreadChannels.h" + +#include + +#include +#include +#include +#include + +namespace codexui::codex { + +// Observes the Qt-to-worker eventfd on the existing SNode.C event loop. The +// receiver borrows ThreadChannels, never owns or closes its eventfd, and +// destroys itself after SNode.C has finished unregistering the descriptor. +class WorkerMailboxReceiver final + : private core::eventreceiver::ReadEventReceiver { +public: + using MessageHandler = std::function; + using FailureHandler = std::function; + + // Must be called on the SNode.C worker thread. The returned pointer remains + // valid only until close() starts deferred destruction. + [[nodiscard]] static WorkerMailboxReceiver * + create(nodegraph::ThreadChannels &channels, MessageHandler onMessage, + FailureHandler onFailure); + + WorkerMailboxReceiver(const WorkerMailboxReceiver &) = delete; + WorkerMailboxReceiver &operator=(const WorkerMailboxReceiver &) = delete; + + // Must be called on the SNode.C worker thread. ThreadChannels must outlive + // the receiver and be closed only after the worker event loop has stopped. + void close(); + +private: + static constexpr std::size_t MaximumMessagesPerEvent = 64; + + WorkerMailboxReceiver(nodegraph::ThreadChannels &channels, + MessageHandler onMessage, FailureHandler onFailure); + ~WorkerMailboxReceiver() override; + + void readEvent() override; + void readTimeout() override; + void unobservedEvent() override; + void destruct() override; + void shutdownEvent(const core::ShutdownContext &context) override; + + void consumeWakeAndMessages(bool drainWake); + void scheduleNextDrain(); + void invalidateScheduledDrain() noexcept; + void fail(std::string reason) noexcept; + + nodegraph::ThreadChannels &channels_; + MessageHandler onMessage_; + FailureHandler onFailure_; + std::shared_ptr deferredReceiver_; + bool drainScheduled_ = false; + bool closing_ = false; +}; + +} // namespace codexui::codex + +#endif // CODEXUI_CODEX_WORKERMAILBOXRECEIVER_H diff --git a/src/codex/ipc/QtSocketPairEndpoint.cpp b/src/codex/ipc/QtSocketPairEndpoint.cpp deleted file mode 100644 index e40c7ec..0000000 --- a/src/codex/ipc/QtSocketPairEndpoint.cpp +++ /dev/null @@ -1,221 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ipc/QtSocketPairEndpoint.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui::codex::ipc { - -QtSocketPairEndpoint::QtSocketPairEndpoint(int descriptor, - std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerActivation, - std::size_t maximumWriteBytesPerActivation, - QObject *parent) - : QObject(parent), descriptor(descriptor), - maximumQueuedBytes(maximumQueuedBytes), - maximumReadBytesPerActivation(maximumReadBytesPerActivation), - maximumWriteBytesPerActivation(maximumWriteBytesPerActivation) { - readNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Read, this); - writeNotifier = new QSocketNotifier(descriptor, QSocketNotifier::Write, this); - writeNotifier->setEnabled(false); - connect(readNotifier, &QSocketNotifier::activated, this, - [this] { readReady(); }); - connect(writeNotifier, &QSocketNotifier::activated, this, - [this] { writeReady(); }); -} - -QtSocketPairEndpoint::~QtSocketPairEndpoint() { - destroying = true; - onData = {}; - onError = {}; - onClosed = {}; - closeTransport(); -} - -bool QtSocketPairEndpoint::send(const char *data, std::size_t size) { - if (!isOpen() || size > maximumQueuedBytes || - queuedBytes() > maximumQueuedBytes - size) - return false; - - if (size != 0) { - writeChunks.emplace_back(data, size); - queuedWriteBytes += size; - } - QPointer guard(this); - writeReady(); - return guard && guard->isOpen(); -} - -bool QtSocketPairEndpoint::send(const std::string &data) { - return send(data.data(), data.size()); -} - -std::size_t QtSocketPairEndpoint::queuedBytes() const noexcept { - return queuedWriteBytes; -} - -std::size_t QtSocketPairEndpoint::retainedWriteBytes() const noexcept { - std::size_t retained = 0; - for (const std::string &chunk : writeChunks) - retained += chunk.capacity(); - return retained; -} - -bool QtSocketPairEndpoint::isOpen() const noexcept { - return descriptor >= 0 && !closing; -} - -void QtSocketPairEndpoint::setOnData(DataHandler handler) { - onData = std::move(handler); -} - -void QtSocketPairEndpoint::setOnError(ErrorHandler handler) { - onError = std::move(handler); -} - -void QtSocketPairEndpoint::setOnClosed(ClosedHandler handler) { - onClosed = std::move(handler); -} - -void QtSocketPairEndpoint::close() noexcept { - if (closing) - return; - ClosedHandler closed = std::move(onClosed); - onClosed = {}; - closeTransport(); - if (!destroying && closed) { - try { - closed(); - } catch (...) { - } - } -} - -void QtSocketPairEndpoint::closeTransport() noexcept { - if (closing) - return; - closing = true; - if (readNotifier) - readNotifier->setEnabled(false); - if (writeNotifier) - writeNotifier->setEnabled(false); - if (descriptor >= 0) { - ::shutdown(descriptor, SHUT_RDWR); - ::close(descriptor); - descriptor = -1; - } - writeChunks.clear(); - firstChunkOffset = 0; - queuedWriteBytes = 0; -} - -void QtSocketPairEndpoint::readReady() { - std::array buffer{}; - std::size_t totalRead = 0; - while (isOpen() && totalRead < maximumReadBytesPerActivation) { - const std::size_t requested = - std::min(buffer.size(), maximumReadBytesPerActivation - totalRead); - const ssize_t received = - ::recv(descriptor, buffer.data(), requested, 0); - if (received > 0) { - totalRead += static_cast(received); - if (onData) { - QPointer guard(this); - try { - onData(buffer.data(), static_cast(received)); - } catch (...) { - if (guard) - guard->fail(EPROTO); - return; - } - if (!guard) - return; - } - continue; - } - if (received == 0) { - close(); - return; - } - if (errno == EINTR) - continue; - if (errno == EAGAIN || errno == EWOULDBLOCK) - return; - fail(errno != 0 ? errno : EIO); - return; - } -} - -void QtSocketPairEndpoint::writeReady() { - std::size_t totalWritten = 0; - while (isOpen() && queuedBytes() != 0 && - totalWritten < maximumWriteBytesPerActivation) { - const std::string &chunk = writeChunks.front(); - const std::size_t requested = std::min( - chunk.size() - firstChunkOffset, - maximumWriteBytesPerActivation - totalWritten); - const ssize_t sent = ::send(descriptor, chunk.data() + firstChunkOffset, - requested, MSG_NOSIGNAL); - if (sent > 0) { - const std::size_t size = static_cast(sent); - firstChunkOffset += size; - queuedWriteBytes -= size; - totalWritten += size; - if (firstChunkOffset == chunk.size()) { - writeChunks.pop_front(); - firstChunkOffset = 0; - } - continue; - } - if (sent < 0 && errno == EINTR) - continue; - if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - writeNotifier->setEnabled(true); - return; - } - fail(sent == 0 ? EPIPE : (errno != 0 ? errno : EIO)); - return; - } - - if (queuedBytes() == 0) { - writeChunks.clear(); - firstChunkOffset = 0; - if (writeNotifier) - writeNotifier->setEnabled(false); - } else if (writeNotifier) { - writeNotifier->setEnabled(true); - } -} - -void QtSocketPairEndpoint::fail(int errorNumber) noexcept { - if (closing) - return; - ErrorHandler error = std::move(onError); - ClosedHandler closed = std::move(onClosed); - onError = {}; - onClosed = {}; - closeTransport(); - QPointer guard(this); - if (!destroying && error) { - try { - error(errorNumber); - } catch (...) { - } - } - if (guard && !destroying && closed) { - try { - closed(); - } catch (...) { - } - } -} - -} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/QtSocketPairEndpoint.h b/src/codex/ipc/QtSocketPairEndpoint.h deleted file mode 100644 index b928c09..0000000 --- a/src/codex/ipc/QtSocketPairEndpoint.h +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H -#define CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H - -#include - -#include -#include -#include -#include - -class QSocketNotifier; - -namespace codexui::codex::ipc { - -class QtSocketPairEndpoint final : public QObject { -public: - using DataHandler = std::function; - using ErrorHandler = std::function; - using ClosedHandler = std::function; - - explicit QtSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerActivation = - 256U * 1024U, - std::size_t maximumWriteBytesPerActivation = - 256U * 1024U, - QObject *parent = nullptr); - ~QtSocketPairEndpoint() override; - - QtSocketPairEndpoint(const QtSocketPairEndpoint &) = delete; - QtSocketPairEndpoint &operator=(const QtSocketPairEndpoint &) = delete; - - [[nodiscard]] bool send(const char *data, std::size_t size); - [[nodiscard]] bool send(const std::string &data); - [[nodiscard]] std::size_t queuedBytes() const noexcept; - [[nodiscard]] std::size_t retainedWriteBytes() const noexcept; - [[nodiscard]] bool isOpen() const noexcept; - - void setOnData(DataHandler handler); - void setOnError(ErrorHandler handler); - void setOnClosed(ClosedHandler handler); - void close() noexcept; - -private: - void readReady(); - void writeReady(); - void fail(int errorNumber) noexcept; - void closeTransport() noexcept; - - int descriptor = -1; - std::size_t maximumQueuedBytes; - std::size_t maximumReadBytesPerActivation; - std::size_t maximumWriteBytesPerActivation; - std::deque writeChunks; - std::size_t firstChunkOffset = 0; - std::size_t queuedWriteBytes = 0; - QSocketNotifier *readNotifier = nullptr; - QSocketNotifier *writeNotifier = nullptr; - DataHandler onData; - ErrorHandler onError; - ClosedHandler onClosed; - bool closing = false; - bool destroying = false; -}; - -} // namespace codexui::codex::ipc - -#endif // CODEXUI_CODEX_IPC_QTSOCKETPAIRENDPOINT_H diff --git a/src/codex/ipc/SNodeSocketPairEndpoint.cpp b/src/codex/ipc/SNodeSocketPairEndpoint.cpp deleted file mode 100644 index efdc5cb..0000000 --- a/src/codex/ipc/SNodeSocketPairEndpoint.cpp +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ipc/SNodeSocketPairEndpoint.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace codexui::codex::ipc { -namespace { - -constexpr std::size_t MaximumChunkBytes = 16U * 1024U; -constexpr std::size_t MaximumWriteBytesPerEvent = 256U * 1024U; - -snode::log::Scope makeLogScope() { - return {.origin = snode::log::Origin::Application, - .boundary = snode::log::Boundary::Connection, - .component = "codexui.ipc", - .identity = {.instance = "socketpair", - .role = snode::log::Role::Client}}; -} - -} // namespace - -SNodeSocketPairEndpoint * -SNodeSocketPairEndpoint::create(int descriptor, std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerEvent) { - if (descriptor < 0 || maximumQueuedBytes == 0 || - maximumReadBytesPerEvent == 0) - return nullptr; - - auto *endpoint = new SNodeSocketPairEndpoint(descriptor, maximumQueuedBytes, - maximumReadBytesPerEvent); - const bool readEnabled = endpoint->ReadEventReceiver::enable(descriptor); - const bool writeEnabled = - readEnabled && endpoint->WriteEventReceiver::enable(descriptor); - if (!readEnabled || !writeEnabled) { - endpoint->closing = true; - endpoint->closeDescriptor(); - if (!readEnabled) { - delete endpoint; - } else { - // disable() is deferred by SNode.C. Once the registered read receiver is - // actually unobserved, unobservedEvent() owns destruction. - endpoint->initializing = false; - endpoint->ReadEventReceiver::disable(); - } - return nullptr; - } - - endpoint->WriteEventReceiver::suspend(); - endpoint->initializing = false; - return endpoint; -} - -SNodeSocketPairEndpoint::SNodeSocketPairEndpoint( - int descriptor, std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerEvent) - : core::eventreceiver::ReadEventReceiver("SocketPairEndpoint", - makeLogScope(), TIMEOUT::DISABLE), - core::eventreceiver::WriteEventReceiver("SocketPairEndpoint", - makeLogScope(), TIMEOUT::DISABLE), - descriptor(descriptor), maximumQueuedBytes(maximumQueuedBytes), - maximumReadBytesPerEvent(maximumReadBytesPerEvent) {} - -SNodeSocketPairEndpoint::~SNodeSocketPairEndpoint() { closeDescriptor(); } - -bool SNodeSocketPairEndpoint::send(const char *data, std::size_t size) { - const std::size_t outstanding = queuedBytes(); - if (closing || !WriteEventReceiver::isEnabled() || - size > maximumQueuedBytes || outstanding > maximumQueuedBytes - size) - return false; - if (size == 0) - return true; - - if (writeOffset != 0 && (writeOffset == writeBuffer.size() || - writeOffset >= writeBuffer.size() / 2)) { - writeBuffer.erase(writeBuffer.begin(), - writeBuffer.begin() + - static_cast(writeOffset)); - writeOffset = 0; - } - writeBuffer.insert(writeBuffer.end(), data, data + size); - if (WriteEventReceiver::isSuspended()) - WriteEventReceiver::resume(); - return true; -} - -bool SNodeSocketPairEndpoint::send(const std::string &data) { - return send(data.data(), data.size()); -} - -std::size_t SNodeSocketPairEndpoint::queuedBytes() const noexcept { - return writeBuffer.size() - writeOffset; -} - -void SNodeSocketPairEndpoint::setOnData(DataHandler handler) { - onData = std::move(handler); -} - -void SNodeSocketPairEndpoint::setOnError(ErrorHandler handler) { - onError = std::move(handler); -} - -void SNodeSocketPairEndpoint::setOnClosed(ClosedHandler handler) { - onClosed = std::move(handler); -} - -void SNodeSocketPairEndpoint::close() { - if (closing) - return; - closing = true; - writeBuffer.clear(); - writeOffset = 0; - if (ReadEventReceiver::isEnabled()) - ReadEventReceiver::disable(); - if (WriteEventReceiver::isEnabled()) - WriteEventReceiver::disable(); -} - -void SNodeSocketPairEndpoint::readEvent() { - std::array chunk{}; - std::size_t totalRead = 0; - while (!closing && totalRead < maximumReadBytesPerEvent) { - const std::size_t requested = - std::min(chunk.size(), maximumReadBytesPerEvent - totalRead); - const ssize_t result = - core::system::recv(descriptor, chunk.data(), requested, 0); - if (result > 0) { - const std::size_t size = static_cast(result); - totalRead += size; - if (onData) { - try { - onData(chunk.data(), size); - } catch (...) { - reportError(EPROTO); - return; - } - } - continue; - } - if (result == 0) { - close(); - return; - } - if (errno == EINTR) - continue; - if (errno == EAGAIN || errno == EWOULDBLOCK) - return; - reportError(errno != 0 ? errno : EIO); - return; - } -} - -void SNodeSocketPairEndpoint::writeEvent() { - std::size_t totalWritten = 0; - while (!closing && queuedBytes() != 0 && - totalWritten < MaximumWriteBytesPerEvent) { - const std::size_t requested = - std::min({queuedBytes(), MaximumChunkBytes, - MaximumWriteBytesPerEvent - totalWritten}); - const ssize_t result = core::system::send( - descriptor, writeBuffer.data() + writeOffset, requested, MSG_NOSIGNAL); - if (result > 0) { - const std::size_t size = static_cast(result); - writeOffset += size; - totalWritten += size; - continue; - } - if (result < 0 && errno == EINTR) - continue; - if (result < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) - return; - reportError(result == 0 ? EPIPE : (errno != 0 ? errno : EIO)); - return; - } - - if (queuedBytes() == 0) { - writeBuffer.clear(); - writeOffset = 0; - if (!closing) - WriteEventReceiver::suspend(); - } -} - -void SNodeSocketPairEndpoint::unobservedEvent() { - if (initializing) - return; - closeDescriptor(); - if (onClosed) { - try { - onClosed(); - } catch (...) { - } - } - delete this; -} - -void SNodeSocketPairEndpoint::destruct() { close(); } - -void SNodeSocketPairEndpoint::shutdownEvent( - const core::ShutdownContext &context) { - static_cast(context); - close(); -} - -void SNodeSocketPairEndpoint::closeDescriptor() noexcept { - if (descriptor >= 0) { - ::shutdown(descriptor, SHUT_RDWR); - ::close(descriptor); - descriptor = -1; - } -} - -void SNodeSocketPairEndpoint::reportError(int errorNumber) { - ErrorHandler error = std::move(onError); - onError = {}; - if (error) { - try { - error(errorNumber); - } catch (...) { - } - } - close(); -} - -} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/SNodeSocketPairEndpoint.h b/src/codex/ipc/SNodeSocketPairEndpoint.h deleted file mode 100644 index 59d3419..0000000 --- a/src/codex/ipc/SNodeSocketPairEndpoint.h +++ /dev/null @@ -1,67 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H -#define CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H - -#include -#include - -#include -#include -#include -#include - -namespace codexui::codex::ipc { - -class SNodeSocketPairEndpoint final - : public core::eventreceiver::ReadEventReceiver, - public core::eventreceiver::WriteEventReceiver { -public: - using DataHandler = std::function; - using ErrorHandler = std::function; - using ClosedHandler = std::function; - - static SNodeSocketPairEndpoint *create(int descriptor, - std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerEvent); - - SNodeSocketPairEndpoint(const SNodeSocketPairEndpoint &) = delete; - SNodeSocketPairEndpoint &operator=(const SNodeSocketPairEndpoint &) = delete; - - [[nodiscard]] bool send(const char *data, std::size_t size); - [[nodiscard]] bool send(const std::string &data); - [[nodiscard]] std::size_t queuedBytes() const noexcept; - - void setOnData(DataHandler handler); - void setOnError(ErrorHandler handler); - void setOnClosed(ClosedHandler handler); - void close(); - -private: - SNodeSocketPairEndpoint(int descriptor, std::size_t maximumQueuedBytes, - std::size_t maximumReadBytesPerEvent); - ~SNodeSocketPairEndpoint() override; - - void readEvent() override; - void writeEvent() override; - void unobservedEvent() override; - void destruct() override; - void shutdownEvent(const core::ShutdownContext &context) override; - void closeDescriptor() noexcept; - void reportError(int errorNumber); - - int descriptor; - std::size_t maximumQueuedBytes; - std::size_t maximumReadBytesPerEvent; - std::vector writeBuffer; - std::size_t writeOffset = 0; - DataHandler onData; - ErrorHandler onError; - ClosedHandler onClosed; - bool initializing = true; - bool closing = false; -}; - -} // namespace codexui::codex::ipc - -#endif // CODEXUI_CODEX_IPC_SNODESOCKETPAIRENDPOINT_H diff --git a/src/codex/ipc/SocketPair.cpp b/src/codex/ipc/SocketPair.cpp deleted file mode 100644 index 78f74ac..0000000 --- a/src/codex/ipc/SocketPair.cpp +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ipc/SocketPair.h" - -#include -#include -#include -#include - -namespace codexui::codex::ipc { -namespace { - -void closeDescriptor(int &descriptor) noexcept { - if (descriptor >= 0) { - ::close(descriptor); - descriptor = -1; - } -} - -} // namespace - -SocketPair::SocketPair() noexcept { - int endpoints[2]{-1, -1}; - if (::socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0, - endpoints) == 0) { - first = endpoints[0]; - second = endpoints[1]; - } else { - creationError = errno != 0 ? errno : EIO; - } -} - -SocketPair::SocketPair(SocketPair &&other) noexcept - : first(std::exchange(other.first, -1)), - second(std::exchange(other.second, -1)), - creationError(std::exchange(other.creationError, 0)) {} - -SocketPair::~SocketPair() { - closeFirstEndpoint(); - closeSecondEndpoint(); -} - -SocketPair &SocketPair::operator=(SocketPair &&other) noexcept { - if (this != &other) { - closeFirstEndpoint(); - closeSecondEndpoint(); - first = std::exchange(other.first, -1); - second = std::exchange(other.second, -1); - creationError = std::exchange(other.creationError, 0); - } - return *this; -} - -bool SocketPair::isValid() const noexcept { - return hasFirstEndpoint() && hasSecondEndpoint(); -} - -bool SocketPair::hasFirstEndpoint() const noexcept { return first >= 0; } - -bool SocketPair::hasSecondEndpoint() const noexcept { return second >= 0; } - -int SocketPair::error() const noexcept { return creationError; } - -int SocketPair::firstEndpoint() const noexcept { return first; } - -int SocketPair::secondEndpoint() const noexcept { return second; } - -int SocketPair::releaseFirstEndpoint() noexcept { - return std::exchange(first, -1); -} - -int SocketPair::releaseSecondEndpoint() noexcept { - return std::exchange(second, -1); -} - -void SocketPair::closeFirstEndpoint() noexcept { closeDescriptor(first); } - -void SocketPair::closeSecondEndpoint() noexcept { closeDescriptor(second); } - -} // namespace codexui::codex::ipc diff --git a/src/codex/ipc/SocketPair.h b/src/codex/ipc/SocketPair.h deleted file mode 100644 index 2989d5b..0000000 --- a/src/codex/ipc/SocketPair.h +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_IPC_SOCKETPAIR_H -#define CODEXUI_CODEX_IPC_SOCKETPAIR_H - -namespace codexui::codex::ipc { - -class SocketPair final { -public: - SocketPair() noexcept; - SocketPair(const SocketPair &) = delete; - SocketPair(SocketPair &&other) noexcept; - ~SocketPair(); - - SocketPair &operator=(const SocketPair &) = delete; - SocketPair &operator=(SocketPair &&other) noexcept; - - [[nodiscard]] bool isValid() const noexcept; - [[nodiscard]] bool hasFirstEndpoint() const noexcept; - [[nodiscard]] bool hasSecondEndpoint() const noexcept; - [[nodiscard]] int error() const noexcept; - - [[nodiscard]] int firstEndpoint() const noexcept; - [[nodiscard]] int secondEndpoint() const noexcept; - [[nodiscard]] int releaseFirstEndpoint() noexcept; - [[nodiscard]] int releaseSecondEndpoint() noexcept; - - void closeFirstEndpoint() noexcept; - void closeSecondEndpoint() noexcept; - -private: - int first = -1; - int second = -1; - int creationError = 0; -}; - -} // namespace codexui::codex::ipc - -#endif // CODEXUI_CODEX_IPC_SOCKETPAIR_H diff --git a/src/codex/middle/ComposerPane.cpp b/src/codex/middle/ComposerPane.cpp index b1ef34c..46fbc8f 100644 --- a/src/codex/middle/ComposerPane.cpp +++ b/src/codex/middle/ComposerPane.cpp @@ -279,9 +279,9 @@ void ComposerPane::setAttentionRequest(QString title, QString detail, acceptLabel = QStringLiteral("Accept"); const bool unchanged = attentionTitle_->text() == title && attentionDetail_->text() == detail && - attentionAcceptButton_->isVisible() == directAccept && - attentionReviewButton_->isVisible() != directAccept && - attentionAcceptButton_->text() == acceptLabel; + attentionAcceptButton_->isVisible() == directAccept && + attentionReviewButton_->isVisible() != directAccept && + attentionAcceptButton_->text() == acceptLabel; if (unchanged) return; attentionTitle_->setText(std::move(title)); @@ -293,9 +293,14 @@ void ComposerPane::setAttentionRequest(QString title, QString detail, } void ComposerPane::setAttentionEnabled(bool enabled) { + setAttentionActionEnabled(enabled, false); +} + +void ComposerPane::setAttentionActionEnabled(bool enabled, + bool reviewEnabled) { attentionRejectButton_->setEnabled(enabled); attentionAcceptButton_->setEnabled(enabled); - attentionReviewButton_->setEnabled(enabled); + attentionReviewButton_->setEnabled(enabled || reviewEnabled); } void ComposerPane::setActiveTurn(bool active) { @@ -513,8 +518,8 @@ void ComposerPane::refreshActionStyle() { } void ComposerPane::refreshSubmissionEnabled() { - sendButton_->setEnabled( - canSubmit_ && !promptEditor_->toPlainText().trimmed().isEmpty()); + sendButton_->setEnabled(canSubmit_ && + !promptEditor_->toPlainText().trimmed().isEmpty()); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ComposerPane.h b/src/codex/middle/ComposerPane.h index 60f3c39..f75b66c 100644 --- a/src/codex/middle/ComposerPane.h +++ b/src/codex/middle/ComposerPane.h @@ -54,6 +54,7 @@ class ComposerPane final : public QWidget { void setAttentionRequest(QString title, QString detail, bool directAccept, QString acceptLabel); void setAttentionEnabled(bool enabled); + void setAttentionActionEnabled(bool enabled, bool reviewEnabled); void setActiveTurn(bool active); void setCanSubmit(bool canSubmit); void setSettingsEnabled(bool enabled); diff --git a/src/codex/middle/ConversationCards.cpp b/src/codex/middle/ConversationCards.cpp index c0a64e9..2a91513 100644 --- a/src/codex/middle/ConversationCards.cpp +++ b/src/codex/middle/ConversationCards.cpp @@ -2,14 +2,14 @@ #include "codex/middle/ConversationCards.h" -#include "codex/PresentationStatus.h" +#include "codex/UiStatus.h" #include "codex/ui/UiStyle.h" #include #include #include #include -#include +#include #include #include #include @@ -23,16 +23,18 @@ #include #include #include +#include #include #include #include -#include +#include #include #include #include #include #include #include +#include #include #include #include @@ -40,6 +42,7 @@ #include #include +#include #include #include #include @@ -55,11 +58,11 @@ constexpr int PendingAnimationIntervalMilliseconds = 32; constexpr qint64 PendingHalfCycleMilliseconds = 850; constexpr int ThumbnailMaximumWidth = 280; constexpr int ThumbnailMaximumHeight = 180; -constexpr int ViewerMaximumImageExtent = 4096; constexpr qsizetype MaximumGenericActivityCharacters = 4096; constexpr int CardHeaderActionSpacing = 4; constexpr int CopyMorphDurationMilliseconds = 160; constexpr int CopyCheckHoldMilliseconds = 500; +constexpr int MarkdownBottomPaintGuard = 4; QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); @@ -80,11 +83,14 @@ QString trimmedTrailingLines(const QString &value) { } bool initiallyCollapsed(CardKind kind, bool commandInitiallyCollapsed, - bool imageInitiallyCollapsed) { + bool imageInitiallyCollapsed, + bool fileChangesInitiallyCollapsed) { if (kind == CardKind::CommandExecution) return commandInitiallyCollapsed; if (kind == CardKind::ImageGeneration) return imageInitiallyCollapsed; + if (kind == CardKind::FileChanges) + return fileChangesInitiallyCollapsed; return kind != CardKind::UserMessage && kind != CardKind::AgentMessage && kind != CardKind::LocalPrompt; } @@ -145,12 +151,11 @@ class CardCopyButton final : public QToolButton { morph_ = new QVariantAnimation(this); morph_->setDuration(CopyMorphDurationMilliseconds); morph_->setEasingCurve(QEasingCurve::InOutCubic); - QObject::connect( - morph_, &QVariantAnimation::valueChanged, this, - [this](const QVariant &value) { - morphProgress_ = value.toReal(); - update(); - }); + QObject::connect(morph_, &QVariantAnimation::valueChanged, this, + [this](const QVariant &value) { + morphProgress_ = value.toReal(); + update(); + }); QObject::connect(morph_, &QVariantAnimation::finished, this, [this] { if (returningToCopy_) { finishFeedback(); @@ -243,7 +248,12 @@ class CardCopyButton final : public QToolButton { bool returningToCopy_ = false; }; -void openImageViewer(const QString &path); +bool openLocalFile(const QString &path) { + if (path.isEmpty()) + return false; + return QDesktopServices::openUrl( + QUrl::fromLocalFile(QFileInfo(path).absoluteFilePath())); +} class ImageThumbnail final : public QLabel { public: @@ -327,7 +337,7 @@ class ImageThumbnail final : public QLabel { bool activate() { if (!property("imageAvailable").toBool()) return false; - openImageViewer(path_); + openLocalFile(path_); return true; } @@ -410,70 +420,6 @@ class ImageRibbon final : public QScrollArea { QSize naturalSize_; }; -class ImageViewer final : public QDialog { -public: - explicit ImageViewer(const QString &path) : QDialog(nullptr, Qt::Window) { - setObjectName(QStringLiteral("messageImageViewer")); - setAttribute(Qt::WA_DeleteOnClose); - setWindowModality(Qt::NonModal); - setWindowTitle(QFileInfo(path).fileName()); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(8, 8, 8, 8); - scroll_ = new QScrollArea(this); - scroll_->setWidgetResizable(true); - imageLabel_ = new QLabel(scroll_); - imageLabel_->setObjectName(QStringLiteral("messageImageViewerImage")); - imageLabel_->setAlignment(Qt::AlignCenter); - - QImageReader reader(path); - reader.setAutoTransform(true); - const QSize source = reader.size(); - if (source.isValid() && (source.width() > ViewerMaximumImageExtent || - source.height() > ViewerMaximumImageExtent)) - reader.setScaledSize(source.scaled(ViewerMaximumImageExtent, - ViewerMaximumImageExtent, - Qt::KeepAspectRatio)); - image_ = reader.read(); - if (image_.isNull()) - imageLabel_->setText(QStringLiteral("Image unavailable")); - scroll_->setWidget(imageLabel_); - layout->addWidget(scroll_); - resize(900, 650); - updatePixmap(); - } - -protected: - void showEvent(QShowEvent *event) override { - QDialog::showEvent(event); - updatePixmap(); - } - - void resizeEvent(QResizeEvent *event) override { - QDialog::resizeEvent(event); - updatePixmap(); - } - -private: - void updatePixmap() { - if (image_.isNull() || !scroll_) - return; - const QSize available = scroll_->viewport()->size() - QSize(8, 8); - if (available.isEmpty()) - return; - imageLabel_->setPixmap(QPixmap::fromImage(image_.scaled( - available, Qt::KeepAspectRatio, Qt::SmoothTransformation))); - } - - QImage image_; - QScrollArea *scroll_ = nullptr; - QLabel *imageLabel_ = nullptr; -}; - -void openImageViewer(const QString &path) { - auto *viewer = new ImageViewer(path); - viewer->show(); -} - QLabel *makeLabel(const QString &value, const char *kind = "body", QWidget *parent = nullptr) { auto *label = new QLabel(value, parent); @@ -500,6 +446,10 @@ QLabel *makeMarkdownLabel(const QString &value, QWidget *parent = nullptr) { label->setTextFormat(Qt::RichText); label->setWordWrap(true); label->setMinimumWidth(0); + // QTextDocument and QLabel round rich-text line geometry independently. + // Keep one descent of paint space below the measured document so the final + // baseline cannot be clipped when a nested card is fixed to heightForWidth. + label->setContentsMargins(0, 0, 0, MarkdownBottomPaintGuard); label->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); label->setOpenExternalLinks(true); label->setTextInteractionFlags(Qt::TextSelectableByMouse | @@ -633,6 +583,35 @@ QString fileChangesText(const FileChangesData &data) { return rows.join(QLatin1Char('\n')); } +QString fileChangesHtml(const FileChangesData &data, QStringList &openPaths) { + openPaths.clear(); + QStringList rows; + for (const FileChangeData &change : data.changes) { + if (change.path.empty()) + continue; + const QString displayPath = text(change.path); + QFileInfo resolved(displayPath); + if (resolved.isRelative() && !data.cwd.empty()) + resolved = QFileInfo(QDir(text(data.cwd)), displayPath); + const int targetIndex = openPaths.size(); + openPaths.push_back(QDir::cleanPath(resolved.absoluteFilePath())); + + QString detail = displayChangeKind(change.kind); + if (change.additions && change.deletions) + detail += QStringLiteral(" +%1 −%2") + .arg(*change.additions) + .arg(*change.deletions); + rows.push_back( + QStringLiteral("%3" + "  ·  %4") + .arg(targetIndex) + .arg(QString::fromLatin1(UiStyle::blue), + displayPath.toHtmlEscaped(), detail.toHtmlEscaped())); + } + return rows.join(QStringLiteral("
")); +} + std::optional totalDiffCounts(const FileChangesData &data) { DiffCounts total; bool available = false; @@ -671,6 +650,16 @@ QString boundedGenericActivity(const nlohmann::json &raw) { return rendered + QStringLiteral("\n\n[Activity details truncated]"); } +QString boundedGenericActivity(const GenericActivityData &activity) { + if (activity.displayDetail.empty()) + return boundedGenericActivity(activity.raw); + QString rendered = text(activity.displayDetail); + if (rendered.size() <= MaximumGenericActivityCharacters) + return rendered; + rendered.truncate(MaximumGenericActivityCharacters); + return rendered + QStringLiteral("\n\n[Activity details truncated]"); +} + CardCopyContent cardCopyContent(const VisibleCardData &card) { return std::visit( [](const auto &payload) -> CardCopyContent { @@ -703,7 +692,7 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { joinedCopyText({text(payload.revisedPrompt), text(payload.path)}), false}; } else if constexpr (std::is_same_v) { - return {boundedGenericActivity(payload.raw), false}; + return {boundedGenericActivity(payload), false}; } else { return payload.prompt.empty() ? CardCopyContent{textList(payload.imagePaths) @@ -717,7 +706,7 @@ CardCopyContent cardCopyContent(const VisibleCardData &card) { bool presentationEquals(const VisibleCardData &left, const VisibleCardData &right) { - if (left.kind != right.kind) + if (left.kind != right.kind || left.activeWork != right.activeWork) return false; const auto *first = std::get_if(&left.payload); const auto *second = std::get_if(&right.payload); @@ -726,7 +715,9 @@ bool presentationEquals(const VisibleCardData &left, first->imagePaths == second->imagePaths && first->state == second->state && first->showPendingAnimation == second->showPendingAnimation && - first->error == second->error; + first->error == second->error && + first->admittedAtMs == second->admittedAtMs && + first->requiresExplicitRecovery == second->requiresExplicitRecovery; } return left.payload == right.payload; } @@ -745,13 +736,38 @@ ContentSizedTextView::ContentSizedTextView(int maximumContentHeight, setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); document()->setDocumentMargin(CommandTextPadding); + connect(verticalScrollBar(), &QScrollBar::sliderPressed, this, + [this] { pinScrollToStart_ = false; }); + connect(verticalScrollBar(), &QScrollBar::actionTriggered, this, + [this] { pinScrollToStart_ = false; }); + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, + [this](int value) { + QScrollBar *bar = verticalScrollBar(); + if (!pinScrollToStart_ || value == bar->minimum()) + return; + const QSignalBlocker blocker(bar); + bar->setValue(bar->minimum()); + }); } bool ContentSizedTextView::setContent(const QString &content) { if (toPlainText() == content) return false; + QScrollBar *bar = verticalScrollBar(); + const bool hadUserScrollRange = bar->maximum() > bar->minimum(); + const int previousScrollValue = bar->value(); + pinScrollToStart_ = !hadUserScrollRange; setPlainText(content); - measureAtCurrentWidth(true); + if (hadUserScrollRange) + bar->setValue(previousScrollValue); + else { + QTextCursor cursor(document()); + cursor.movePosition(QTextCursor::Start); + setTextCursor(cursor); + } + static_cast(measureAtCurrentWidth(true)); + if (!hadUserScrollRange) + bar->setValue(bar->minimum()); return true; } @@ -806,6 +822,7 @@ QSize ContentSizedTextView::minimumSizeHint() const { } void ContentSizedTextView::wheelEvent(QWheelEvent *event) { + pinScrollToStart_ = false; QScrollBar *bar = verticalScrollBar(); const int delta = !event->pixelDelta().isNull() ? event->pixelDelta().y() : event->angleDelta().y(); @@ -826,10 +843,10 @@ void ContentSizedTextView::resizeEvent(QResizeEvent *event) { // Wrapping is authoritative only after QTextEdit has assigned its // viewport width. Propagate a changed hint immediately so a multiline view // cannot remain at an earlier one-line height with a premature scrollbar. - measureAtCurrentWidth(true); + static_cast(measureAtCurrentWidth(true)); } -void ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { +bool ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { const QString content = toPlainText(); int wantedHeight = 0; if (!content.isEmpty()) { @@ -840,10 +857,15 @@ void ContentSizedTextView::measureAtCurrentWidth(bool notifyParent) { } wantedHeight = std::clamp(wantedHeight, 0, maximumHeight()); if (wantedHeight == preferredHeight_) - return; + return false; preferredHeight_ = wantedHeight; if (notifyParent) updateGeometry(); + return true; +} + +bool ContentSizedTextView::contentHeightCapped() const noexcept { + return preferredHeight_ >= maximumHeight(); } CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) @@ -870,12 +892,11 @@ CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) preservedScrollValue_ = verticalScrollBar()->value(); followsLatest_ = isAtBottom(); }); - connect(verticalScrollBar(), &QScrollBar::actionTriggered, this, - [this](int) { - preservedScrollValue_ = verticalScrollBar()->sliderPosition(); - followsLatest_ = - preservedScrollValue_ >= verticalScrollBar()->maximum() - 1; - }); + connect(verticalScrollBar(), &QScrollBar::actionTriggered, this, [this](int) { + preservedScrollValue_ = verticalScrollBar()->sliderPosition(); + followsLatest_ = + preservedScrollValue_ >= verticalScrollBar()->maximum() - 1; + }); connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, [this](int, int) { if (!programmaticScroll_) @@ -883,7 +904,7 @@ CommandOutputView::CommandOutputView(const QString &output, QWidget *parent) }); setOutput(output); - measureAtCurrentWidth(false); + static_cast(measureAtCurrentWidth(false)); settleScroll(); } @@ -895,11 +916,16 @@ bool CommandOutputView::followsLatest() const noexcept { return followsLatest_; } +bool CommandOutputView::isHeightCapped() const noexcept { + return contentHeightCapped(); +} + bool CommandOutputView::setOutput(const QString &output) { const QString displayOutput = trimmedTrailingLines(output); if (currentOutput_ == displayOutput) return false; + const bool retainedHeightIsCapped = isHeightCapped(); const bool retainedFollow = followsLatest_; const int retainedValue = preservedScrollValue_; const bool appendOnly = @@ -916,10 +942,13 @@ bool CommandOutputView::setOutput(const QString &output) { followsLatest_ = retainedFollow; preservedScrollValue_ = retainedValue; programmaticScroll_ = false; - // Asking the document layout for its size here completes wrapping at the - // already assigned viewport width. The enclosing conversation can then - // account for the final card height in the same reconciliation transaction. - measureAtCurrentWidth(true); + // Once the output has reached its bounded height, subsequent text cannot + // change the enclosing card's geometry. Avoid a complete QTextDocument + // measurement and ancestor LayoutRequest for the common streaming case. + if (!retainedHeightIsCapped || !appendOnly || displayOutput.isEmpty()) + static_cast(measureAtCurrentWidth(true)); + else + viewport()->update(); settleScroll(); return true; } @@ -972,10 +1001,12 @@ bool CommandOutputView::isAtBottom() const { class ConversationCard::Impl final { public: Impl(ConversationCard *owner, const VisibleCardData &initial, - bool commandInitiallyCollapsed, bool imageInitiallyCollapsed) + bool commandInitiallyCollapsed, bool imageInitiallyCollapsed, + bool fileChangesInitiallyCollapsed) : owner(owner), current(initial), collapsed(initiallyCollapsed(initial.kind, commandInitiallyCollapsed, - imageInitiallyCollapsed)) { + imageInitiallyCollapsed, + fileChangesInitiallyCollapsed)) { owner->setObjectName(QStringLiteral("conversationCard")); owner->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); owner->setProperty("conversationCardKey", @@ -1032,6 +1063,8 @@ class ConversationCard::Impl final { owner->setProperty("kind", "raised"); std::visit([this](const auto &payload) { createComposition(payload); }, initial.payload); + if (initial.activeWork) + setActiveWork(*initial.activeWork); refreshCopyPresentation(); refreshFoldPresentation(); } @@ -1042,34 +1075,99 @@ class ConversationCard::Impl final { next.kind == CardKind::UserMessage)); } - bool apply(const VisibleCardData &next) { + PresentationImpact applyPresentation(const VisibleCardData &next) { if (!canApply(next)) { Q_ASSERT_X(false, "ConversationCard::apply", "a persistent conversation card received an incompatible " "key or kind"); - return false; + return PresentationImpact::None; } const bool becomingAuthoritative = current.kind == CardKind::LocalPrompt && next.kind == CardKind::UserMessage; + const bool payloadChanged = current.payload != next.payload; const bool presentationChanged = becomingAuthoritative || !presentationEquals(current, next); + const bool activeWorkOnly = !becomingAuthoritative && !payloadChanged && + current.activeWork != next.activeWork; + bool cappedCommandOutputOnly = false; + bool commandLifecycleOnly = false; + if (!becomingAuthoritative && output && output->isHeightCapped() && + current.kind == CardKind::CommandExecution && + next.kind == CardKind::CommandExecution) { + const auto *before = std::get_if(¤t.payload); + const auto *after = std::get_if(&next.payload); + cappedCommandOutputOnly = + before && after && before->output != after->output && + after->output.starts_with(before->output) && + before->command == after->command && + before->status == after->status && before->cwd == after->cwd && + before->exitCode == after->exitCode && + before->durationMilliseconds == after->durationMilliseconds && + terminalOutputHasVisibleText(before->output) && + terminalOutputHasVisibleText(after->output) && + current.activeWork == next.activeWork; + } + if (!becomingAuthoritative && + current.kind == CardKind::CommandExecution && + next.kind == CardKind::CommandExecution) { + const auto *before = std::get_if(¤t.payload); + const auto *after = std::get_if(&next.payload); + commandLifecycleOnly = + before && after && before->command == after->command && + before->output == after->output && before->cwd == after->cwd && + !before->status.empty() && !after->status.empty() && + !commandMetadata(*before).isEmpty() && + !commandMetadata(*after).isEmpty(); + } if (becomingAuthoritative) promoteToAuthoritativeUserMessage(); current = next; if (!presentationChanged) - return false; + return PresentationImpact::None; + if (activeWorkOnly) { + setActiveWork(next.activeWork.value_or(false)); + return PresentationImpact::PaintOnly; + } + const int previousNaturalHeight = + commandLifecycleOnly ? naturalHeightForCurrentWidth() : -1; std::visit([this](const auto &payload) { updateComposition(payload); }, next.payload); + if (next.activeWork) + setActiveWork(*next.activeWork); refreshCopyPresentation(); refreshFoldPresentation(); - owner->updateGeometry(); + const int nextNaturalHeight = commandLifecycleOnly + ? naturalHeightForCurrentWidth() + : -1; + const bool measuredLifecyclePaintOnly = + commandLifecycleOnly && previousNaturalHeight >= 0 && + nextNaturalHeight == previousNaturalHeight; + const bool geometryChanged = + !cappedCommandOutputOnly && !measuredLifecyclePaintOnly; + if (geometryChanged) + owner->updateGeometry(); owner->update(); - return true; + return geometryChanged ? PresentationImpact::GeometryChanged + : PresentationImpact::PaintOnly; + } + + [[nodiscard]] int naturalHeightForCurrentWidth() { + if (!layout || owner->width() <= 0) + return -1; + layout->invalidate(); + const int width = owner->contentsRect().width(); + return layout->hasHeightForWidth() + ? layout->heightForWidth(width) + 2 * owner->frameWidth() + : layout->sizeHint().height() + 2 * owner->frameWidth(); } void promoteToAuthoritativeUserMessage() { if (animationTimer) animationTimer->stop(); + if (pendingDelayTimer) + pendingDelayTimer->stop(); + pendingFeedbackVisible = false; + pendingFeedbackDeadlineMs.reset(); owner->setObjectName(QStringLiteral("conversationCard")); owner->setProperty("conversationCardKind", static_cast(CardKind::UserMessage)); @@ -1082,6 +1180,17 @@ class ConversationCard::Impl final { metadata->clear(); metadata->hide(); } + if (phase) { + if (owner->property("nestedConversationCard").toBool()) { + showPhase(QStringLiteral("steering"), + QStringLiteral("steeringMessagePhase")); + setPhaseTone(QStringLiteral("steering")); + } else { + phase->hide(); + } + } + if (recovery) + recovery->hide(); } void setCollapsed(bool next) { @@ -1105,6 +1214,8 @@ class ConversationCard::Impl final { } void setNestedConversationCard(bool nested) { + if (owner->property("nestedConversationCard").toBool() == nested) + return; owner->setProperty("nestedConversationCard", nested); if (current.kind == CardKind::UserMessage || current.kind == CardKind::LocalPrompt) { @@ -1124,38 +1235,91 @@ class ConversationCard::Impl final { owner->update(); } - void setNestedCards(const std::vector &cards) { - const std::unordered_set retained(cards.begin(), - cards.end()); + void setViewportVisible(bool visible) { + if (viewportVisible == visible) + return; + viewportVisible = visible; + owner->setProperty("conversationViewportVisible", visible); + if (refreshPendingPresentation()) + owner->updateGeometry(); + owner->update(); + } + + void setNestedItems(const std::vector &items) { + std::vector currentItems; + currentItems.reserve(static_cast(nestedLayout->count())); + for (int index = 0; index < nestedLayout->count(); ++index) + if (QWidget *item = nestedLayout->itemAt(index)->widget()) + currentItems.push_back(item); + + const bool unchanged = currentItems == items; + const bool appendOnly = + currentItems.size() <= items.size() && + std::equal(currentItems.begin(), currentItems.end(), items.begin()); + if (unchanged) { + const bool visible = std::ranges::any_of( + items, [](const QWidget *item) { return item && !item->isHidden(); }); + if (hasVisibleNestedCards != visible) { + hasVisibleNestedCards = visible; + refreshFoldPresentation(); + } + return; + } + if (appendOnly) { + for (std::size_t index = currentItems.size(); index < items.size(); + ++index) { + QWidget *item = items[index]; + if (!item) + continue; + const bool explicitlyHidden = item->isHidden(); + nestedLayout->addWidget(item); + item->setVisible(!explicitlyHidden); + if (auto *card = dynamic_cast(item)) + card->impl_->setNestedConversationCard(true); + } + hasVisibleNestedCards = std::ranges::any_of( + items, [](const QWidget *item) { return item && !item->isHidden(); }); + refreshFoldPresentation(); + return; + } + + const std::unordered_set retained(items.begin(), items.end()); for (int index = nestedLayout->count() - 1; index >= 0; --index) { - auto *card = dynamic_cast( - nestedLayout->itemAt(index)->widget()); - if (!card || retained.contains(card)) + QWidget *item = nestedLayout->itemAt(index)->widget(); + if (!item || retained.contains(item)) continue; - const bool explicitlyHidden = card->isHidden(); - nestedLayout->removeWidget(card); - card->setParent(owner->parentWidget()); - card->setVisible(!explicitlyHidden); - card->impl_->setNestedConversationCard(false); + const bool explicitlyHidden = item->isHidden(); + nestedLayout->removeWidget(item); + item->setParent(owner->parentWidget()); + item->setVisible(!explicitlyHidden); + if (auto *card = dynamic_cast(item)) + card->impl_->setNestedConversationCard(false); } - for (std::size_t index = 0; index < cards.size(); ++index) { - ConversationCard *card = cards[index]; - if (!card) + for (std::size_t index = 0; index < items.size(); ++index) { + QWidget *item = items[index]; + if (!item) continue; - const bool explicitlyHidden = card->isHidden(); + const bool explicitlyHidden = item->isHidden(); const int position = static_cast(index); - if (nestedLayout->indexOf(card) != position) - nestedLayout->insertWidget(position, card); - card->setVisible(!explicitlyHidden); - card->impl_->setNestedConversationCard(true); + if (nestedLayout->indexOf(item) != position) + nestedLayout->insertWidget(position, item); + item->setVisible(!explicitlyHidden); + if (auto *card = dynamic_cast(item)) + card->impl_->setNestedConversationCard(true); } - hasVisibleNestedCards = - std::ranges::any_of(cards, [](const ConversationCard *card) { - return card && !card->isHidden(); - }); + hasVisibleNestedCards = std::ranges::any_of( + items, [](const QWidget *item) { return item && !item->isHidden(); }); refreshFoldPresentation(); } + void setNestedCards(const std::vector &cards) { + std::vector items; + items.reserve(cards.size()); + for (ConversationCard *card : cards) + items.push_back(card); + setNestedItems(items); + } + [[nodiscard]] bool hasVisibleContent() const { for (int index = 0; index < contentLayout->count(); ++index) { if (QWidget *widget = contentLayout->itemAt(index)->widget(); @@ -1344,13 +1508,34 @@ class ConversationCard::Impl final { title->setText(QStringLiteral("File changes")); metadata = makeLabel({}, "meta", content); body = makeLabel({}, "body", content); + body->setObjectName(QStringLiteral("fileChangesList")); + body->setTextFormat(Qt::RichText); + body->setOpenExternalLinks(false); + body->setTextInteractionFlags(Qt::TextSelectableByMouse | + Qt::LinksAccessibleByMouse | + Qt::LinksAccessibleByKeyboard); + QObject::connect(body, &QLabel::linkActivated, owner, + [this](const QString &link) { + constexpr QLatin1StringView prefix("codexui-file:"); + if (!link.startsWith(prefix)) + return; + bool valid = false; + const int index = link.sliced(prefix.size()).toInt(&valid); + if (valid && index >= 0 && + index < fileChangeOpenPaths.size()) + static_cast( + openLocalFile(fileChangeOpenPaths.at(index))); + }); contentLayout->addWidget(body); contentLayout->addWidget(metadata); updateComposition(changes); } void updateComposition(const FileChangesData &changes) { - setVisibleText(body, fileChangesText(changes)); + const QString html = fileChangesHtml(changes, fileChangeOpenPaths); + if (body->text() != html) + body->setText(html); + body->setVisible(!html.isEmpty()); showStatus(text(changes.status), QStringLiteral("fileChangesStatus")); QStringList values{QStringLiteral("%1 paths").arg(changes.changes.size())}; if (const auto counts = totalDiffCounts(changes)) @@ -1406,7 +1591,7 @@ class ConversationCard::Impl final { ? QStringLiteral("Activity") : UiStyle::humanizeLabel(text(activity.type))); showStatus(text(activity.status), QStringLiteral("genericActivityStatus")); - metadata->setText(boundedGenericActivity(activity.raw)); + metadata->setText(boundedGenericActivity(activity)); metadata->setObjectName(QStringLiteral("genericActivityMetadata")); metadata->show(); } @@ -1421,14 +1606,32 @@ class ConversationCard::Impl final { metadata = makeLabel({}, "meta", content); contentLayout->addWidget(body); contentLayout->addWidget(metadata); + recovery = new QPushButton(QStringLiteral("Restore to composer"), content); + recovery->setObjectName(QStringLiteral("promptRecoveryButton")); + recovery->setProperty("kind", "secondary"); + recovery->setAccessibleName(QStringLiteral("Restore prompt to composer")); + recovery->hide(); + contentLayout->addWidget(recovery, 0, Qt::AlignLeft); + QObject::connect(recovery, &QPushButton::clicked, owner, + [this] { emit owner->recoveryRequested(); }); createImageContainer(); animationTimer = new QTimer(owner); + animationTimer->setObjectName(QStringLiteral("pendingAnimationTimer")); animationTimer->setInterval(PendingAnimationIntervalMilliseconds); QObject::connect(animationTimer, &QTimer::timeout, owner, [this] { if (refreshPendingPresentation()) owner->updateGeometry(); owner->update(); }); + pendingDelayTimer = new QTimer(owner); + pendingDelayTimer->setObjectName(QStringLiteral("pendingDelayTimer")); + pendingDelayTimer->setSingleShot(true); + QObject::connect(pendingDelayTimer, &QTimer::timeout, owner, [this] { + pendingFeedbackVisible = true; + if (refreshPendingPresentation()) + owner->updateGeometry(); + owner->update(); + }); updateComposition(prompt); } @@ -1446,14 +1649,29 @@ class ConversationCard::Impl final { prompt->state == PromptState::InFlight; const bool failed = prompt->state == PromptState::Failed; const bool steering = owner->property("nestedConversationCard").toBool(); - const QString foreground = - waiting - ? steering ? QStringLiteral("#146f73") : QStringLiteral("#536b8f") - : failed ? QStringLiteral("#982f3d") - : QStringLiteral("#1d2633"); + const QString foreground = waiting ? steering ? QStringLiteral("#146f73") + : QStringLiteral("#536b8f") + : failed ? QStringLiteral("#982f3d") + : QStringLiteral("#1d2633"); const QString style = QStringLiteral("background:transparent;color:%1;").arg(foreground); bool changed = false; + const QString lifecycle = + waiting ? steering ? QStringLiteral("steering · pending") + : QStringLiteral("pending") + : steering ? QStringLiteral("steering") : QString{}; + const bool phaseWasVisible = phase && phase->isVisible(); + const QString previousPhase = phase ? phase->text() : QString{}; + if (!lifecycle.isEmpty()) { + showPhase(lifecycle, steering ? QStringLiteral("steeringMessagePhase") + : QStringLiteral("pendingPromptStatus")); + setPhaseTone(steering ? QStringLiteral("steering") + : QStringLiteral("active")); + } else if (phase) { + phase->hide(); + } + changed = changed || previousPhase != lifecycle || + phaseWasVisible != !lifecycle.isEmpty(); for (QLabel *label : {title, body, metadata}) { if (label->styleSheet() != style) { label->setStyleSheet(style); @@ -1468,12 +1686,51 @@ class ConversationCard::Impl final { : QStringLiteral("Not sent: %1").arg(text(prompt->error)); changed = setVisibleText(metadata, status) || changed; + const bool recoveryVisible = failed && prompt->requiresExplicitRecovery; + if (recovery && recovery->isVisible() != recoveryVisible) { + recovery->setVisible(recoveryVisible); + changed = true; + } - if (waiting && prompt->showPendingAnimation) { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (waiting) { + if (prompt->admittedAtMs) { + const std::int64_t admitted = *prompt->admittedAtMs; + constexpr std::int64_t maximum = + std::numeric_limits::max(); + pendingFeedbackDeadlineMs = + admitted > maximum - PendingAnimationDelayMilliseconds + ? maximum + : admitted + PendingAnimationDelayMilliseconds; + } else if (!pendingFeedbackDeadlineMs) { + pendingFeedbackDeadlineMs = now + PendingAnimationDelayMilliseconds; + } + pendingFeedbackVisible = + prompt->showPendingAnimation || + (pendingFeedbackDeadlineMs && now >= *pendingFeedbackDeadlineMs); + } else { + pendingFeedbackVisible = false; + pendingFeedbackDeadlineMs.reset(); + } + owner->setProperty("pendingFeedbackVisible", + waiting && pendingFeedbackVisible); + + if (!waiting || !viewportVisible) { + pendingDelayTimer->stop(); + animationTimer->stop(); + } else if (pendingFeedbackVisible) { + pendingDelayTimer->stop(); if (!animationTimer->isActive()) animationTimer->start(); } else { animationTimer->stop(); + const qint64 remaining = + std::max(1, *pendingFeedbackDeadlineMs - now); + const int interval = static_cast( + std::min(remaining, std::numeric_limits::max())); + if (!pendingDelayTimer->isActive() || + pendingDelayTimer->remainingTime() > interval + 1) + pendingDelayTimer->start(interval); } return changed; } @@ -1496,7 +1753,13 @@ class ConversationCard::Impl final { ContentSizedTextView *command = nullptr; CommandOutputView *output = nullptr; QTimer *animationTimer = nullptr; + QTimer *pendingDelayTimer = nullptr; + QPushButton *recovery = nullptr; + bool pendingFeedbackVisible = false; + bool viewportVisible = true; + std::optional pendingFeedbackDeadlineMs; ImageRibbon *images = nullptr; + QStringList fileChangeOpenPaths; QWidget *nestedCards = nullptr; QVBoxLayout *nestedLayout = nullptr; bool hasVisibleNestedCards = false; @@ -1505,10 +1768,12 @@ class ConversationCard::Impl final { ConversationCard::ConversationCard(const VisibleCardData &data, QWidget *parent, bool commandInitiallyCollapsed, - bool imageInitiallyCollapsed) + bool imageInitiallyCollapsed, + bool fileChangesInitiallyCollapsed) : QFrame(parent), impl_(std::make_unique(this, data, commandInitiallyCollapsed, - imageInitiallyCollapsed)) {} + imageInitiallyCollapsed, + fileChangesInitiallyCollapsed)) {} ConversationCard::~ConversationCard() = default; @@ -1530,11 +1795,23 @@ bool ConversationCard::setAuthoritativeTurnActive(bool active) { return impl_->setAuthoritativeTurnActive(active); } +void ConversationCard::setNestedPresentation(bool nested) { + impl_->setNestedConversationCard(nested); +} + void ConversationCard::setNestedCards( const std::vector &cards) { impl_->setNestedCards(cards); } +void ConversationCard::setNestedItems(const std::vector &items) { + impl_->setNestedItems(items); +} + +void ConversationCard::setViewportVisible(bool visible) { + impl_->setViewportVisible(visible); +} + std::optional ConversationCard::commandOutputScrollState() const { if (!impl_->output) @@ -1549,7 +1826,12 @@ void ConversationCard::restoreCommandOutputScrollState( } bool ConversationCard::apply(const VisibleCardData &data) { - return impl_->apply(data); + return applyPresentation(data) != PresentationImpact::None; +} + +PresentationImpact +ConversationCard::applyPresentation(const VisibleCardData &data) { + return impl_->applyPresentation(data); } bool ConversationCard::canApply(const VisibleCardData &data) const noexcept { @@ -1562,7 +1844,7 @@ void ConversationCard::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(QStringLiteral("#98a2b3")), 1.5)); + painter.setPen(QPen(QColor(QStringLiteral("#98a2b3")), 2.0)); painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), 9.0, 9.0); return; @@ -1571,7 +1853,7 @@ void ConversationCard::paintEvent(QPaintEvent *event) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.setBrush(Qt::NoBrush); - painter.setPen(QPen(QColor(QStringLiteral("#6f98e8")), 1.5)); + painter.setPen(QPen(QColor(QStringLiteral("#6f98e8")), 2.0)); painter.drawRoundedRect(QRectF(rect()).adjusted(1.0, 1.0, -1.0, -1.0), 8.0, 8.0); }; @@ -1593,13 +1875,12 @@ void ConversationCard::paintEvent(QPaintEvent *event) { prompt->state == PromptState::InFlight; const bool failed = prompt->state == PromptState::Failed; const bool steering = property("nestedConversationCard").toBool(); - const bool animated = waiting && prompt->showPendingAnimation; + const bool animated = waiting && property("pendingFeedbackVisible").toBool(); const QColor background = failed ? QColor(QStringLiteral("#fff0f2")) : QColor(steering ? QStringLiteral("#eefafa") : QStringLiteral("#eaf2ff")); - const QColor border = failed - ? QColor(QStringLiteral("#efb8c0")) + const QColor border = failed ? QColor(QStringLiteral("#efb8c0")) : waiting ? QColor(steering ? QStringLiteral("#5caeb1") : QStringLiteral("#79a0d7")) @@ -1647,9 +1928,11 @@ void ConversationCard::paintEvent(QPaintEvent *event) { ConversationCard *createConversationCard(const VisibleCardData &data, QWidget *parent, bool commandInitiallyCollapsed, - bool imageInitiallyCollapsed) { + bool imageInitiallyCollapsed, + bool fileChangesInitiallyCollapsed) { return new ConversationCard(data, parent, commandInitiallyCollapsed, - imageInitiallyCollapsed); + imageInitiallyCollapsed, + fileChangesInitiallyCollapsed); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationCards.h b/src/codex/middle/ConversationCards.h index eeaa3ae..77afee0 100644 --- a/src/codex/middle/ConversationCards.h +++ b/src/codex/middle/ConversationCards.h @@ -21,6 +21,8 @@ class QWheelEvent; namespace codexui::codex::middle { +enum class PresentationImpact { None, PaintOnly, GeometryChanged }; + class ContentSizedTextView : public QTextEdit { public: explicit ContentSizedTextView(int maximumContentHeight, @@ -34,10 +36,12 @@ class ContentSizedTextView : public QTextEdit { protected: void wheelEvent(QWheelEvent *event) override; void resizeEvent(QResizeEvent *event) override; - void measureAtCurrentWidth(bool notifyParent); + [[nodiscard]] bool measureAtCurrentWidth(bool notifyParent); + [[nodiscard]] bool contentHeightCapped() const noexcept; private: int preferredHeight_ = 0; + bool pinScrollToStart_ = false; bool wheelGestureActive_ = false; bool wheelGestureDecided_ = false; bool wheelGestureOwned_ = false; @@ -56,6 +60,7 @@ class CommandOutputView final : public ContentSizedTextView { [[nodiscard]] ScrollState scrollState() const; [[nodiscard]] bool followsLatest() const noexcept; + [[nodiscard]] bool isHeightCapped() const noexcept; // Returns false for a true no-op. Programmatic document/range changes do // not alter the user's follow/paused choice. @@ -84,7 +89,8 @@ class ConversationCard : public QFrame { explicit ConversationCard(const VisibleCardData &data, QWidget *parent = nullptr, bool commandInitiallyCollapsed = true, - bool imageInitiallyCollapsed = true); + bool imageInitiallyCollapsed = true, + bool fileChangesInitiallyCollapsed = true); ~ConversationCard() override; [[nodiscard]] CardKind cardKind() const noexcept; @@ -92,7 +98,16 @@ class ConversationCard : public QFrame { [[nodiscard]] bool isCollapsed() const noexcept; void setCollapsed(bool collapsed); bool setAuthoritativeTurnActive(bool active); + // Select the established nested-card presentation for a child, or clear it + // when the card becomes a turn root or a standalone activity. + void setNestedPresentation(bool nested); void setNestedCards(const std::vector &cards); + // ConversationView supplies the retained child widgets in canonical order. + // They stay in this existing nested layout while the thread is selected. + void setNestedItems(const std::vector &items); + // ConversationView uses this to pause local feedback timers while a card is + // not painted. + void setViewportVisible(bool visible); [[nodiscard]] std::optional commandOutputScrollState() const; void @@ -104,9 +119,11 @@ class ConversationCard : public QFrame { // kinds in place and also performs the one supported semantic transition // from an admitted local prompt to its authoritative user message. bool apply(const VisibleCardData &data); + PresentationImpact applyPresentation(const VisibleCardData &data); signals: void foldRequested(bool collapsed); + void recoveryRequested(); protected: void paintEvent(QPaintEvent *event) override; @@ -119,7 +136,8 @@ class ConversationCard : public QFrame { [[nodiscard]] ConversationCard * createConversationCard(const VisibleCardData &data, QWidget *parent = nullptr, bool commandInitiallyCollapsed = true, - bool imageInitiallyCollapsed = true); + bool imageInitiallyCollapsed = true, + bool fileChangesInitiallyCollapsed = true); } // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationProjection.cpp b/src/codex/middle/ConversationProjection.cpp deleted file mode 100644 index 14e73e4..0000000 --- a/src/codex/middle/ConversationProjection.cpp +++ /dev/null @@ -1,579 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/middle/ConversationProjection.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::codex::middle { -namespace { - -// The Inspector is the production owner of structured turn plans. Keep the -// complete Conversation projection available for a one-line policy reversal. -constexpr bool projectStructuredPlansInConversation = false; - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto value = object.find(key); - return value != object.end() && value->is_string() ? value->get() - : std::string{}; -} - -std::string statusValue(const nlohmann::json &object) { - const auto status = object.find("status"); - if (status == object.end()) - return {}; - if (status->is_string()) - return status->get(); - return stringValue(*status, "type"); -} - -std::optional integerValue(const nlohmann::json &object, - const char *key) { - if (!object.is_object()) - return std::nullopt; - const auto value = object.find(key); - if (value == object.end() || !value->is_number_integer()) - return std::nullopt; - return value->get(); -} - -std::optional optionalText(const nlohmann::json &object, - const char *key) { - if (!object.is_object()) - return std::nullopt; - const auto value = object.find(key); - if (value == object.end() || !value->is_string()) - return std::nullopt; - return value->get(); -} - -std::uint64_t omittedTextBytes(const ItemPresentation &item, - const char *field) { - const auto value = - std::find_if(item.textRetention.begin(), item.textRetention.end(), - [field](const TextRetentionPresentation &entry) { - return entry.field == field; - }); - return value == item.textRetention.end() ? 0 : value->discardedBytes; -} - -std::string withTruncationNotice(std::string value, std::uint64_t omitted, - std::string_view subject, bool markdown) { - if (omitted == 0) - return value; - const std::string notice = "Earlier " + std::string(subject) + - " was truncated (" + std::to_string(omitted) + - " bytes omitted)."; - return markdown ? "> " + notice + "\n\n" + value - : '[' + notice + "]\n" + value; -} - -std::pair unifiedDiffCounts(std::string_view diff) { - int additions = 0; - int deletions = 0; - for (std::size_t offset = 0; offset <= diff.size();) { - const std::size_t end = diff.find('\n', offset); - const std::string_view line = - diff.substr(offset, end == std::string_view::npos ? diff.size() - offset - : end - offset); - if (line.starts_with("+++ ") || line.starts_with("--- ")) { - if (end == std::string_view::npos) - break; - offset = end + 1; - continue; - } - if (line.starts_with('+')) - ++additions; - else if (line.starts_with('-')) - ++deletions; - if (end == std::string_view::npos) - break; - offset = end + 1; - } - return {additions, deletions}; -} - -std::string messageText(const nlohmann::json &item) { - const std::string type = stringValue(item, "type"); - if (type == "agentMessage" || type == "plan") - return stringValue(item, "text"); - if (type != "userMessage") - return {}; - - std::string result; - const auto content = item.find("content"); - if (content != item.end() && content->is_array()) { - for (const nlohmann::json &entry : *content) { - const std::string value = stringValue(entry, "text"); - if (value.empty()) - continue; - if (!result.empty()) - result.push_back('\n'); - result += value; - } - } - if (result.empty()) { - const std::string fallback = stringValue(item, "text"); - if (!fallback.empty()) - result = fallback; - } - return result; -} - -std::vector messageImagePaths(const nlohmann::json &item) { - std::vector result; - const auto content = item.find("content"); - if (content == item.end() || !content->is_array()) - return result; - for (const nlohmann::json &entry : *content) { - if (stringValue(entry, "type") != "localImage") - continue; - const std::string path = stringValue(entry, "path"); - if (!path.empty()) - result.push_back(path); - } - return result; -} - -std::vector localImagePaths(const PromptSubmission &submission) { - std::vector result; - for (const AttachmentDraft &attachment : submission.attachments) - if (attachment.mimeType.starts_with("image/")) - result.push_back(attachment.path); - return result; -} - -std::string joinedStrings(const nlohmann::json &value) { - if (!value.is_array()) - return {}; - std::string result; - for (const nlohmann::json &entry : value) { - if (!entry.is_string()) - continue; - if (!result.empty()) - result += ", "; - result += entry.get(); - } - return result; -} - -std::vector stringList(const nlohmann::json &value) { - std::vector result; - if (!value.is_array()) - return result; - for (const nlohmann::json &entry : value) - if (entry.is_string()) - result.push_back(entry.get()); - return result; -} - -bool hasStructuredPlan(const TurnPresentation &turn) { - if (!turn.plan.is_object()) - return false; - const auto steps = turn.plan.find("steps"); - return !stringValue(turn.plan, "explanation").empty() || - (steps != turn.plan.end() && steps->is_array() && !steps->empty()); -} - -PlanData structuredPlan(const TurnPresentation &turn) { - PlanData result; - result.explanation = stringValue(turn.plan, "explanation"); - const auto steps = turn.plan.find("steps"); - if (steps == turn.plan.end() || !steps->is_array()) - return result; - result.steps.reserve(steps->size()); - for (const nlohmann::json &step : *steps) { - const std::string value = stringValue(step, "step"); - if (!value.empty()) - result.steps.push_back({value, stringValue(step, "status")}); - } - return result; -} - -std::string sectionComponent(std::string_view prefix, std::string_view threadId, - std::string_view suffix) { - std::string result(prefix); - result += std::to_string(threadId.size()); - result.push_back(':'); - result.append(threadId); - result += std::to_string(suffix.size()); - result.push_back(':'); - result.append(suffix); - return result; -} - -VisibleCardData authoritativeCard(const AuthoritativeItemKey &identity, - const ItemPresentation &presentation, - CardKey visualKey) { - const nlohmann::json &item = presentation.raw; - const std::string type = stringValue(item, "type"); - VisibleCardData result{std::move(visualKey), CardKind::GenericActivity, - identity.threadId, identity.turnId, - identity.itemId, - GenericActivityData{type, item, statusValue(item)}}; - - if (type == "userMessage") { - result.kind = CardKind::UserMessage; - result.payload = - UserMessageData{messageText(item), messageImagePaths(item)}; - } else if (type == "agentMessage") { - result.kind = CardKind::AgentMessage; - result.payload = AgentMessageData{ - withTruncationNotice(messageText(item), - omittedTextBytes(presentation, "text"), - "Codex response", true), - stringValue(item, "phase") == "final_answer"}; - } else if (type == "commandExecution") { - result.kind = CardKind::CommandExecution; - const char *outputField = "aggregatedOutput"; - std::string output = stringValue(item, "aggregatedOutput"); - if (output.empty()) { - outputField = "output"; - output = stringValue(item, "output"); - } - output = withTruncationNotice(output, - omittedTextBytes(presentation, outputField), - "command output", false); - if (!terminalOutputHasVisibleText(output)) - output.clear(); - std::optional exitCode; - const auto rawExitCode = item.find("exitCode"); - if (rawExitCode != item.end() && rawExitCode->is_number_integer()) - exitCode = rawExitCode->get(); - std::optional duration = integerValue(item, "durationMs"); - if (!duration) - duration = integerValue(item, "duration_ms"); - result.payload = CommandExecutionData{ - stringValue(item, "command"), output, stringValue(item, "status"), - stringValue(item, "cwd"), exitCode, duration}; - } else if (type == "collabAgentToolCall" || type == "subAgentActivity") { - result.kind = CardKind::AgentActivity; - result.payload = AgentActivityData{ - stringValue(item, "tool"), - stringValue(item, "status"), - stringValue(item, "kind"), - stringValue(item, "prompt"), - stringValue(item, "resultText"), - stringList(item.value("receiverThreadIds", nlohmann::json::array())), - stringValue(item, "model"), - stringValue(item, "reasoningEffort"), - stringValue(item, "agentThreadId"), - stringValue(item, "agentPath"), - stringValue(item, "senderThreadId")}; - } else if (type == "reasoning") { - result.kind = CardKind::Reasoning; - result.payload = ReasoningData{withTruncationNotice( - joinedStrings(item.value("summary", nlohmann::json::array())), - omittedTextBytes(presentation, "summary"), "reasoning", true)}; - } else if (type == "fileChange") { - result.kind = CardKind::FileChanges; - const nlohmann::json changes = - item.value("changes", nlohmann::json::array()); - FileChangesData projected{stringValue(item, "status"), {}}; - if (changes.is_array()) { - projected.changes.reserve(changes.size()); - for (const nlohmann::json &change : changes) { - FileChangeData entry{stringValue(change, "path"), - stringValue(change, "kind"), std::nullopt, - std::nullopt}; - if (const auto diff = optionalText(change, "diff")) { - const auto [additions, deletions] = unifiedDiffCounts(*diff); - entry.additions = additions; - entry.deletions = deletions; - } - projected.changes.push_back(std::move(entry)); - } - } - result.payload = std::move(projected); - } else if (type == "imageGeneration" || type == "imageView") { - std::string path = stringValue(item, "path"); - if (path.empty()) - path = stringValue(item, "savedPath"); - if (path.empty()) - path = stringValue(item, "saved_path"); - std::string revisedPrompt = stringValue(item, "revisedPrompt"); - if (revisedPrompt.empty()) - revisedPrompt = stringValue(item, "revised_prompt"); - result.kind = CardKind::ImageGeneration; - result.payload = - ImageGenerationData{path, - type == "imageView" ? "completed" - : stringValue(item, "status"), - revisedPrompt}; - } else if (type == "plan") { - const std::string plan = withTruncationNotice( - messageText(item), omittedTextBytes(presentation, "text"), "plan text", - true); - if (!plan.empty()) { - result.kind = CardKind::Plan; - result.payload = PlanData{{}, {}, plan}; - } - } - return result; -} - -struct ProjectedNode { - std::size_t position = 0; - std::uint64_t tieBreaker = 0; - std::string sectionKey; - std::string turnId; - VisibleCardData card; - bool turnRoot = false; -}; - -std::optional admissionBoundaryPosition( - const std::optional &admissionAnchor, - bool admissionAtStart, const AuthoritativeItemIndex &authoritativeItems) { - if (admissionAnchor) { - const auto anchor = authoritativeItems.position(*admissionAnchor); - if (anchor) - return (*anchor + 1) * 2; - } - if (admissionAtStart) - return 0; - return std::nullopt; -} - -std::size_t submissionPosition( - const PromptSubmission &submission, - const AuthoritativeItemIndex &authoritativeItems, - std::optional materializedIndex = std::nullopt) { - const auto admitted = admissionBoundaryPosition(submission.admissionAnchor, - submission.admissionAtStart, - authoritativeItems); - if (admitted) - return *admitted; - if (materializedIndex) - return *materializedIndex * 2 + 1; - // A queued pre-hydration prompt has no committed boundary yet. Until - // reconcile establishes one, keep it at the tail of retained history. - return authoritativeItems.ordered.size() * 2 + 2; -} - -} // namespace - -ConversationSnapshot ConversationProjection::project( - const AuthoritativeItemIndex &authoritativeItems, - const ThreadPresentation *authoritativeThread, - std::span localSubmissions, - std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds) { - ConversationSnapshot result; - result.threadId = authoritativeItems.threadId; - - const std::size_t firstVisible = - authoritativeItems.ordered.size() > authoritativeItemLimit - ? authoritativeItems.ordered.size() - authoritativeItemLimit - : 0; - std::unordered_set representedTurns; - for (std::size_t index = firstVisible; - index < authoritativeItems.ordered.size(); ++index) - representedTurns.insert(authoritativeItems.ordered[index].key.turnId); - - // Roots are structural context, not activity-window budget. Pin the real - // opening userMessage for every turn represented by the retained suffix. - // This keeps long active and completed turns owned by the same You card and - // prevents a later steering message from becoming an inferred root. - std::set pinnedRootIndexes; - for (const std::string &turnId : representedTurns) { - const auto root = - authoritativeItems.turnRootUserMessagePositions.find(turnId); - if (root != authoritativeItems.turnRootUserMessagePositions.end() && - root->second < firstVisible) - pinnedRootIndexes.insert(root->second); - } - result.hiddenAuthoritativeItemCount = firstVisible - pinnedRootIndexes.size(); - result.hasMore = result.hiddenAuthoritativeItemCount > 0; - - std::map bindings; - for (const PromptSubmission &submission : localSubmissions) - if (submission.materializedItem) - bindings.emplace(*submission.materializedItem, &submission); - - std::vector nodes; - nodes.reserve(authoritativeItems.ordered.size() - firstVisible + - pinnedRootIndexes.size() + localSubmissions.size()); - for (std::size_t index = 0; index < authoritativeItems.ordered.size(); - ++index) { - if (index < firstVisible && !pinnedRootIndexes.contains(index)) - continue; - const AuthoritativeItem &item = authoritativeItems.ordered[index]; - const auto binding = bindings.find(item.key); - if (binding != bindings.end() && binding->second->localCardVisible()) - continue; - CardKey visualKey = - item.promptAlias ? CardKey{item.promptAlias->key} : CardKey{item.key}; - if (binding != bindings.end()) - visualKey = LocalPromptKey{binding->second->id}; - std::size_t position = index * 2 + 1; - std::uint64_t tieBreaker = 0; - if (binding != bindings.end()) { - position = - submissionPosition(*binding->second, authoritativeItems, index); - tieBreaker = binding->second->admissionOrdinal; - } else if (item.promptAlias) { - const auto admitted = admissionBoundaryPosition( - item.promptAlias->admissionAnchor, !item.promptAlias->admissionAnchor, - authoritativeItems); - position = admitted.value_or(position); - tieBreaker = item.promptAlias->admissionOrdinal; - } - VisibleCardData card = - authoritativeCard(item.key, *item.presentation, std::move(visualKey)); - const auto root = - authoritativeItems.turnRootUserMessagePositions.find(item.key.turnId); - const bool turnRoot = - root != authoritativeItems.turnRootUserMessagePositions.end() && - root->second == index; - nodes.push_back({position, tieBreaker, - sectionComponent("turn:", authoritativeItems.threadId, - item.key.turnId), - item.key.turnId, std::move(card), turnRoot}); - } - - if (projectStructuredPlansInConversation && authoritativeThread) { - std::unordered_map firstItemIndexes; - std::unordered_map lastItemIndexes; - for (std::size_t index = 0; index < authoritativeItems.ordered.size(); - ++index) { - firstItemIndexes.try_emplace(authoritativeItems.ordered[index].key.turnId, - index); - lastItemIndexes[authoritativeItems.ordered[index].key.turnId] = index; - } - std::unordered_map> nextItemIndexes; - std::optional nextItemIndex; - for (auto turnId = authoritativeThread->turnOrder.rbegin(); - turnId != authoritativeThread->turnOrder.rend(); ++turnId) { - nextItemIndexes.emplace(*turnId, nextItemIndex); - const auto first = firstItemIndexes.find(*turnId); - if (first != firstItemIndexes.end()) - nextItemIndex = first->second; - } - - for (const std::string &turnId : authoritativeThread->turnOrder) { - const auto turn = authoritativeThread->turns.find(turnId); - if (turn == authoritativeThread->turns.end() || - !hasStructuredPlan(turn->second)) - continue; - - const auto last = lastItemIndexes.find(turnId); - const std::optional lastItemIndex = - last == lastItemIndexes.end() - ? std::nullopt - : std::optional{last->second}; - const auto next = nextItemIndexes.find(turnId); - const std::optional followingItemIndex = - next == nextItemIndexes.end() ? std::nullopt : next->second; - if (lastItemIndex && *lastItemIndex < firstVisible) - continue; - const std::size_t position = - lastItemIndex ? *lastItemIndex * 2 + 2 - : followingItemIndex ? *followingItemIndex * 2 - : authoritativeItems.ordered.size() * 2 + 2; - nodes.push_back( - {position, - 0, - sectionComponent("turn:", authoritativeItems.threadId, turnId), - turnId, - {TurnPlanKey{authoritativeItems.threadId, turnId}, - CardKind::Plan, - authoritativeItems.threadId, - turnId, - {}, - structuredPlan(turn->second)}, - false}); - } - } - - for (const PromptSubmission &submission : localSubmissions) { - if (!submission.localCardVisible()) - continue; - std::optional materializedIndex; - if (submission.materializedItem) - materializedIndex = - authoritativeItems.position(*submission.materializedItem); - const std::size_t position = - submissionPosition(submission, authoritativeItems, materializedIndex); - - bool knownTurn = false; - if (authoritativeThread && submission.expectedTurnId) { - const auto turn = - authoritativeThread->turns.find(*submission.expectedTurnId); - knownTurn = turn != authoritativeThread->turns.end(); - } - const std::string turnId = - submission.expectedTurnId.value_or(std::string{}); - const std::string sectionKey = - knownTurn - ? sectionComponent("turn:", authoritativeItems.threadId, turnId) - : "pending:" + std::to_string(submission.id); - VisibleCardData card{LocalPromptKey{submission.id}, - CardKind::LocalPrompt, - authoritativeItems.threadId, - turnId, - {}, - LocalPromptData{submission.id, submission.prompt, - submission.state == PromptState::Queued - ? PromptState::InFlight - : submission.state, - (submission.state == PromptState::Queued || - submission.state == PromptState::InFlight) && - nowMilliseconds - - submission.admittedAtMilliseconds >= - PendingAnimationDelayMilliseconds, - submission.error, - localImagePaths(submission)}}; - const bool authoritativeRootExists = - !turnId.empty() && - authoritativeItems.turnRootUserMessagePositions.contains(turnId); - bool turnRoot = - (!knownTurn || submission.startsTurn) && !authoritativeRootExists; - if (submission.materializedItem) { - const auto root = authoritativeItems.turnRootUserMessagePositions.find( - submission.materializedItem->turnId); - const auto materialized = - authoritativeItems.position(*submission.materializedItem); - turnRoot = - root != authoritativeItems.turnRootUserMessagePositions.end() && - materialized && root->second == *materialized; - } - nodes.push_back({position, submission.admissionOrdinal, sectionKey, turnId, - std::move(card), turnRoot}); - } - - std::ranges::sort(nodes, - [](const ProjectedNode &left, const ProjectedNode &right) { - if (left.position != right.position) - return left.position < right.position; - return left.tieBreaker < right.tieBreaker; - }); - - // Aggregate by section key rather than merely grouping adjacent nodes. This - // guarantees one structural section for each represented turn. - std::map sectionIndexes; - for (ProjectedNode &node : nodes) { - auto section = sectionIndexes.find(node.sectionKey); - if (section == sectionIndexes.end()) { - const std::size_t index = result.sections.size(); - sectionIndexes.emplace(node.sectionKey, index); - result.sections.push_back( - {node.sectionKey, node.turnId, {}, std::nullopt}); - section = sectionIndexes.find(node.sectionKey); - } - TurnSection &projectedSection = result.sections[section->second]; - if (node.turnRoot) - projectedSection.rootCardKey = node.card.key; - projectedSection.cards.push_back(std::move(node.card)); - } - return result; -} - -} // namespace codexui::codex::middle diff --git a/src/codex/middle/ConversationProjection.h b/src/codex/middle/ConversationProjection.h deleted file mode 100644 index aaed353..0000000 --- a/src/codex/middle/ConversationProjection.h +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H -#define CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H - -#include "codex/PresentationModel.h" -#include "codex/middle/MiddleTypes.h" -#include "codex/middle/PromptCoordinator.h" - -#include -#include -#include -#include - -namespace codexui::codex::middle { - -// Pure canonical projection. Initial rendering is simply reconciliation from -// an empty snapshot; no separate full-rebuild ordering exists. -class ConversationProjection final { -public: - static constexpr std::size_t DefaultAuthoritativeItemLimit = - AuthoritativeHistoryPageSize; - - [[nodiscard]] static ConversationSnapshot - project(const AuthoritativeItemIndex &authoritativeItems, - const ThreadPresentation *authoritativeThread, - std::span localSubmissions, - std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds); - - [[nodiscard]] static ConversationSnapshot - project(const ThreadPresentation &authoritativeThread, - std::span localSubmissions, - std::size_t authoritativeItemLimit, std::int64_t nowMilliseconds) { - const auto items = - indexAuthoritativeItems(authoritativeThread.id, &authoritativeThread); - return project(items, &authoritativeThread, localSubmissions, - authoritativeItemLimit, nowMilliseconds); - } -}; - -} // namespace codexui::codex::middle - -#endif // CODEXUI_CODEX_MIDDLE_CONVERSATIONPROJECTION_H diff --git a/src/codex/middle/ConversationView.cpp b/src/codex/middle/ConversationView.cpp index d833198..1e0e6c6 100644 --- a/src/codex/middle/ConversationView.cpp +++ b/src/codex/middle/ConversationView.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +74,20 @@ ConversationView::ConversationView(QWidget *parent) content_->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); content_->installEventFilter(this); + // Keep preparatory widgets outside the visible QObject subtree as well as + // outside its layouts. Tests, accessibility walks, and presentation code + // must observe only the atomically committed surface. + stagingHost_ = new QWidget; + stagingHost_->setObjectName(QStringLiteral("conversationStagingHost")); + stagingHost_->hide(); + + stagingOverlay_ = new QLabel(QStringLiteral("Loading conversation…"), + viewport()); + stagingOverlay_->setObjectName(QStringLiteral("conversationStagingOverlay")); + stagingOverlay_->setAlignment(Qt::AlignCenter); + stagingOverlay_->setAutoFillBackground(true); + stagingOverlay_->hide(); + contentLayout_ = new QVBoxLayout(content_); contentLayout_->setContentsMargins(0, 0, 0, 0); contentLayout_->setSpacing(CardSpacing); @@ -147,10 +163,25 @@ ConversationView::ConversationView(QWidget *parent) recomputeGeometry(); } +ConversationView::~ConversationView() { + cancelStructuralStaging(); + delete stagingHost_; +} + void ConversationView::setLoadMoreAction(std::function action) { loadMoreAction_ = std::move(action); } +void ConversationView::setPromptMaterializedAction( + std::function action) { + promptMaterializedAction_ = std::move(action); +} + +void ConversationView::setPromptRecoveryAction( + std::function action) { + promptRecoveryAction_ = std::move(action); +} + void ConversationView::setEmptyMessage(QString message) { if (message == emptyMessage_) return; @@ -207,15 +238,682 @@ void ConversationView::setThread(const std::string &threadId) { } bool ConversationView::reconcile(const ConversationSnapshot &snapshot) { - return reconcile(snapshot, false, false); + if (!committingStructuralStage_ && pendingStructuralSnapshot_) + cancelStructuralStaging(); + return reconcile(ConversationSnapshot(snapshot), false, false); +} + +void ConversationView::reconcileStaged(ConversationSnapshot snapshot) { + if (snapshot == snapshot_ && snapshot.threadId == threadId_) { + cancelStructuralStaging(); + return; + } + + std::vector missing; + for (const TurnSection §ion : snapshot.sections) { + for (const VisibleCardData &data : section.cards) { + const std::string key = stableKey(data.key); + const auto retained = cards_.find(key); + if (retained == cards_.end() || !retained->second->canApply(data)) + missing.push_back(key); + } + } + + // A structure change without construction can commit directly. Even one + // rich arriving card is built in a hidden pass first so an active wheel or + // touchpad sequence gets an event-loop boundary before the cached geometry + // commit. This is normally only a few milliseconds and never exposes a + // placeholder or partially parented Turn. + if (missing.empty()) { + cancelStructuralStaging(); + static_cast(reconcile(std::move(snapshot), false, false)); + return; + } + + cancelStructuralStaging(); + pendingStructuralSnapshot_ = std::move(snapshot); + pendingStructuralCardKeys_ = std::move(missing); + pendingStructuralCardIndex_ = 0; + stagingHost_->resize(std::max(0, viewport()->width()), + std::max(0, viewport()->height())); + if (pendingStructuralSnapshot_->threadId != threadId_) { + stagingOverlay_->setGeometry(viewport()->rect()); + stagingOverlay_->show(); + stagingOverlay_->raise(); + } + setProperty("structuralStageStarts", + property("structuralStageStarts").toULongLong() + 1); + scheduleStructuralStagePass(); +} + +void ConversationView::scheduleStructuralStagePass() { + if (structuralStagePassScheduled_ || !pendingStructuralSnapshot_) + return; + structuralStagePassScheduled_ = true; + QTimer::singleShot(1, Qt::PreciseTimer, this, [this] { + structuralStagePassScheduled_ = false; + runStructuralStagePass(); + }); +} + +VisibleCardData *ConversationView::pendingCard(const std::string &key) { + if (!pendingStructuralSnapshot_) + return nullptr; + for (TurnSection §ion : pendingStructuralSnapshot_->sections) { + const auto found = std::ranges::find_if(section.cards, [&](const auto &card) { + return stableKey(card.key) == key; + }); + if (found != section.cards.end()) + return &*found; + } + return nullptr; +} + +void ConversationView::runStructuralStagePass() { + if (!pendingStructuralSnapshot_) + return; + + // One rich card is the indivisible Qt unit. Yield after each constructor so + // input and already-painted surfaces remain responsive during an 80-item + // history expansion. + while (pendingStructuralCardIndex_ < pendingStructuralCardKeys_.size()) { + const std::string key = + pendingStructuralCardKeys_[pendingStructuralCardIndex_++]; + VisibleCardData *data = pendingCard(key); + if (!data) + continue; + const auto retained = cards_.find(key); + if (retained != cards_.end() && retained->second->canApply(*data)) + continue; + QElapsedTimer constructionElapsed; + constructionElapsed.start(); + ConversationCard *card = createRetainedCard(*data, stagingHost_, key); + stagedCards_.insert_or_assign(key, card); + setProperty("lastStructuralStageCardConstructionMicros", + constructionElapsed.nsecsElapsed() / 1000); + setProperty("structuralStageCardPasses", + property("structuralStageCardPasses").toULongLong() + 1); + scheduleStructuralStagePass(); + return; + } + + ConversationSnapshot completed = std::move(*pendingStructuralSnapshot_); + pendingStructuralSnapshot_.reset(); + pendingStructuralCardKeys_.clear(); + pendingStructuralCardIndex_ = 0; + const QScopedValueRollback committing(committingStructuralStage_, true); + QElapsedTimer elapsed; + elapsed.start(); + static_cast(reconcile(std::move(completed), false, false)); + setProperty("structuralStageCommitMillis", elapsed.elapsed()); + for (auto &[key, card] : stagedCards_) { + static_cast(key); + delete card; + } + stagedCards_.clear(); + stagingOverlay_->hide(); + setProperty("structuralStageCommits", + property("structuralStageCommits").toULongLong() + 1); +} + +void ConversationView::cancelStructuralStaging() { + pendingStructuralSnapshot_.reset(); + pendingStructuralCardKeys_.clear(); + pendingStructuralCardIndex_ = 0; + for (auto &[key, card] : stagedCards_) { + static_cast(key); + delete card; + } + stagedCards_.clear(); + stagingOverlay_->hide(); +} + +std::optional +ConversationView::applyCardPresentation(const VisibleCardData &data) { + const std::string key = stableKey(data.key); + VisibleCardData *stagedData = pendingCard(key); + if (stagedData && data.threadId == pendingStructuralSnapshot_->threadId) { + if (*stagedData != data) { + const auto staged = stagedCards_.find(key); + if (staged != stagedCards_.end()) { + if (staged->second->canApply(data)) { + static_cast(staged->second->applyPresentation(data)); + } else { + delete staged->second; + stagedCards_.erase(staged); + } + } + *stagedData = data; + } + // A not-yet-committed card has no visible Qt presentation to invalidate. + // Its newest canonical fields will appear in the atomic stage commit. + if (!cards_.contains(key)) + return PresentationImpact::None; + } + + if (data.threadId != threadId_) + return std::nullopt; + const auto retained = cards_.find(key); + if (retained == cards_.end() || !retained->second->canApply(data)) + return std::nullopt; + + VisibleCardData *previous = nullptr; + for (TurnSection §ion : snapshot_.sections) { + const auto found = std::ranges::find_if(section.cards, [&](const auto &card) { + return stableKey(card.key) == key; + }); + if (found != section.cards.end()) { + previous = &*found; + break; + } + } + if (!previous || cardVisible(*previous) != cardVisible(data)) + return std::nullopt; + if (*previous == data) + return PresentationImpact::None; + + const bool becomingAuthoritative = + previous->kind == CardKind::LocalPrompt && + data.kind == CardKind::UserMessage && data.target; + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + applying_ = true; + const PresentationImpact impact = retained->second->applyPresentation(data); + *previous = data; + if (impact == PresentationImpact::GeometryChanged) { + const QSignalBlocker scrollSignals(verticalScrollBar()); + stopFollowingAnimation(); + recomputeCardGeometries({retained->second}); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } else if (impact == PresentationImpact::PaintOnly) { + settlePaintOnlyCard(retained->second); + } + applying_ = false; + storeCurrentThreadState(); + if (becomingAuthoritative && promptMaterializedAction_) + static_cast(promptMaterializedAction_(data.target)); + if (impact != PresentationImpact::None) { + setProperty("graphRefreshPasses", + property("graphRefreshPasses").toULongLong() + 1); + setProperty("targetedCardCommits", + property("targetedCardCommits").toULongLong() + 1); + } + return impact; } -bool ConversationView::reconcile(const ConversationSnapshot &snapshot, +ConversationCard *ConversationView::createRetainedCard( + const VisibleCardData &data, QWidget *parent, const std::string &key) { + ConversationCard *card = createConversationCard( + data, parent, !presentationOptions_.commandsInitiallyExpanded, + !presentationOptions_.imagesInitiallyExpanded); + card->setProperty("conversationAnchorKey", QString::fromStdString(key)); + if (const auto collapsed = cardCollapsedStates_.find(key); + collapsed != cardCollapsedStates_.end()) + card->setCollapsed(collapsed->second); + connect(card, &ConversationCard::foldRequested, this, + [this, key, card](bool collapsed) { + const auto retained = cards_.find(key); + if (retained != cards_.end() && retained->second == card) + setCardCollapsed(key, card, collapsed); + }); + connect(card, &ConversationCard::recoveryRequested, this, + [this, key, card] { + const auto retained = cards_.find(key); + if (retained == cards_.end() || retained->second != card || + !promptRecoveryAction_ || !card->data().target) + return; + promptRecoveryAction_(card->data().target); + }); + return card; +} + +bool ConversationView::tryReconcileSingleInsertion( + ConversationSnapshot &snapshot, bool settleFollowImmediately) { + QElapsedTimer insertionElapsed; + insertionElapsed.start(); + if (snapshot.threadId != snapshot_.threadId || + snapshot.threadId != threadId_ || snapshot.hasMore != snapshot_.hasMore || + snapshot.hiddenAuthoritativeItemCount != + snapshot_.hiddenAuthoritativeItemCount || + snapshot.sections.size() < snapshot_.sections.size() || + snapshot.sections.size() > snapshot_.sections.size() + 1) + return false; + + struct Insertion { + std::size_t section = 0; + std::size_t card = 0; + bool newSection = false; + }; + std::optional insertion; + std::unordered_map previousCards; + for (const TurnSection §ion : snapshot_.sections) + for (const VisibleCardData &card : section.cards) + previousCards.emplace(stableKey(card.key), &card); + + std::size_t previousSection = 0; + for (std::size_t sectionIndex = 0; sectionIndex < snapshot.sections.size(); + ++sectionIndex) { + const TurnSection &nextSection = snapshot.sections[sectionIndex]; + if (previousSection >= snapshot_.sections.size() || + snapshot_.sections[previousSection].key != nextSection.key) { + if (insertion || nextSection.cards.size() != 1 || + (nextSection.rootCardKey && + stableKey(*nextSection.rootCardKey) != + stableKey(nextSection.cards.front().key))) + return false; + insertion = Insertion{sectionIndex, 0, true}; + continue; + } + + const TurnSection &oldSection = snapshot_.sections[previousSection++]; + if (oldSection.turnId != nextSection.turnId || + oldSection.rootCardKey != nextSection.rootCardKey || + nextSection.cards.size() < oldSection.cards.size() || + nextSection.cards.size() > oldSection.cards.size() + 1) + return false; + + std::size_t oldCardIndex = 0; + for (std::size_t cardIndex = 0; cardIndex < nextSection.cards.size(); + ++cardIndex) { + const VisibleCardData &nextCard = nextSection.cards[cardIndex]; + if (oldCardIndex < oldSection.cards.size() && + stableKey(oldSection.cards[oldCardIndex].key) == + stableKey(nextCard.key)) { + const VisibleCardData &oldCard = oldSection.cards[oldCardIndex++]; + const auto retained = cards_.find(stableKey(nextCard.key)); + if (retained == cards_.end() || + !retained->second->canApply(nextCard) || + cardVisible(oldCard) != cardVisible(nextCard)) + return false; + continue; + } + if (insertion || cards_.contains(stableKey(nextCard.key))) + return false; + insertion = Insertion{sectionIndex, cardIndex, false}; + } + if (oldCardIndex != oldSection.cards.size()) + return false; + } + if (previousSection != snapshot_.sections.size() || !insertion) + return false; + + const TurnSection &insertedSectionData = + snapshot.sections[insertion->section]; + const VisibleCardData &insertedData = + insertedSectionData.cards[insertion->card]; + const std::string insertedKey = stableKey(insertedData.key); + if (previousCards.contains(insertedKey)) + return false; + + TurnSectionWidget *retainedSection = nullptr; + if (!insertion->newSection) { + const auto found = sections_.find(insertedSectionData.key); + if (found == sections_.end()) + return false; + retainedSection = found->second; + } + for (const VisibleCardData &cardData : insertedSectionData.cards) { + const std::string key = stableKey(cardData.key); + if (key != insertedKey && !cards_.contains(key)) + return false; + } + if (insertedSectionData.rootCardKey) { + const std::string rootKey = stableKey(*insertedSectionData.rootCardKey); + if (rootKey != insertedKey && !cards_.contains(rootKey)) + return false; + } + + // QWidget construction is indivisible and must stay on Qt-main. Build the + // one new rich subtree outside the visible hierarchy, then expose only its + // final parented geometry in the structural commit below. + QElapsedTimer constructionElapsed; + setProperty("lastIncrementalValidationMicros", + insertionElapsed.nsecsElapsed() / 1000); + constructionElapsed.start(); + ConversationCard *insertedCard = nullptr; + const auto staged = stagedCards_.find(insertedKey); + if (staged != stagedCards_.end() && + staged->second->canApply(insertedData)) { + insertedCard = staged->second; + stagedCards_.erase(staged); + } else { + if (staged != stagedCards_.end()) { + delete staged->second; + stagedCards_.erase(staged); + } + insertedCard = createRetainedCard(insertedData, stagingHost_, insertedKey); + } + insertedCard->hide(); + setProperty("lastIncrementalCardConstructionMicros", + constructionElapsed.nsecsElapsed() / 1000); + + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + std::vector nextDisplayedKeys; + for (const TurnSection §ion : snapshot.sections) + for (const VisibleCardData &card : section.cards) + if (cardVisible(card)) + nextDisplayedKeys.push_back(stableKey(card.key)); + const bool appendedVisibleCard = + nextDisplayedKeys.size() == displayedCardKeys_.size() + 1 && + std::equal(displayedCardKeys_.begin(), displayedCardKeys_.end(), + nextDisplayedKeys.begin()); + + stopFollowingAnimation(); + std::vector geometryCards; + std::vector materializedPrompts; + if (std::holds_alternative(insertedData.key) && + insertedData.kind == CardKind::UserMessage && insertedData.target) + materializedPrompts.push_back(insertedData.target); + + { + const QScopedValueRollback applying(applying_, true); + const QSignalBlocker scrollSignals(verticalScrollBar()); + + // A coalesced notification may pair the insertion with field changes to + // retained cards. Apply those through their normal local path. + for (const TurnSection §ion : snapshot.sections) { + for (const VisibleCardData &cardData : section.cards) { + const std::string key = stableKey(cardData.key); + if (key == insertedKey) + continue; + const auto before = previousCards.find(key); + if (before == previousCards.end() || *before->second == cardData) + continue; + ConversationCard *card = cards_.at(key); + if (before->second->kind == CardKind::LocalPrompt && + cardData.kind == CardKind::UserMessage && cardData.target) + materializedPrompts.push_back(cardData.target); + if (card->applyPresentation(cardData) == + PresentationImpact::GeometryChanged) + geometryCards.push_back(card); + } + } + + const bool cachedSectionAppend = + insertion->newSection && insertion->section + 1 == + snapshot.sections.size() && + !displayedSectionKeys_.empty() && geometryCards.empty(); + int appendedSectionTop = 0; + if (cachedSectionAppend) { + const auto previous = sections_.find(displayedSectionKeys_.back()); + if (previous != sections_.end()) + appendedSectionTop = previous->second->geometry().bottom() + 1 + + contentLayout_->spacing(); + contentLayout_->setEnabled(false); + } + + TurnSectionWidget *section = retainedSection; + if (insertion->newSection) { + section = new TurnSectionWidget(content_); + section->setProperty("turnSectionKey", + QString::fromStdString(insertedSectionData.key)); + section->setProperty("turnId", + QString::fromStdString(insertedSectionData.turnId)); + section->resize(std::max(0, content_->width()), 0); + if (cachedSectionAppend) + section->layout()->setEnabled(false); + sections_.emplace(insertedSectionData.key, section); + contentLayout_->insertWidget(1 + static_cast(insertion->section), + section); + } + cards_.emplace(insertedKey, insertedCard); + insertedCard->setParent(section); + // Nested-card visibility is part of the owner's fold presentation. + // Establish it before setNestedCards() computes whether the container is + // visible; changing only the child afterward leaves the owner collapsed. + insertedCard->setVisible(cardVisible(insertedData)); + + std::vector orderedCards; + orderedCards.reserve(insertedSectionData.cards.size()); + for (const VisibleCardData &cardData : insertedSectionData.cards) + orderedCards.push_back(cards_.at(stableKey(cardData.key))); + ConversationCard *root = nullptr; + if (insertedSectionData.rootCardKey) + root = cards_.at(stableKey(*insertedSectionData.rootCardKey)); + QWidget *rootNested = + root ? root->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) + : nullptr; + const int previousNestedHeight = rootNested ? rootNested->height() : 0; + const bool previousNestedVisible = rootNested && !rootNested->isHidden(); + const int previousRootHeight = root ? root->height() : 0; + const int previousSectionHeight = section->height(); + const bool cachedNestedAppend = + root && root != insertedCard && !insertion->newSection && + insertion->card + 1 == insertedSectionData.cards.size() && + insertion->section + 1 == snapshot.sections.size() && + geometryCards.empty() && rootNested && rootNested->layout(); + if (cachedNestedAppend) { + rootNested->layout()->setEnabled(false); + root->layout()->setEnabled(false); + section->layout()->setEnabled(false); + contentLayout_->setEnabled(false); + } + if (root) { + std::vector nestedCards; + nestedCards.reserve(orderedCards.size() - 1); + for (ConversationCard *card : orderedCards) { + card->setProperty("turnContainer", card == root); + if (card == root) { + card->setNestedPresentation(false); + } else { + card->setAuthoritativeTurnActive(false); + nestedCards.push_back(card); + } + } + root->setNestedCards(nestedCards); + if (section->cards->indexOf(root) != 0) + section->cards->insertWidget(0, root); + } else { + for (std::size_t index = 0; index < orderedCards.size(); ++index) { + ConversationCard *card = orderedCards[index]; + card->setProperty("turnContainer", false); + card->setNestedPresentation(false); + card->setAuthoritativeTurnActive(false); + if (section->cards->indexOf(card) != static_cast(index)) + section->cards->insertWidget(static_cast(index), card); + } + } + section->cardKeys.clear(); + section->cardKeys.reserve(insertedSectionData.cards.size()); + for (const VisibleCardData &cardData : insertedSectionData.cards) + section->cardKeys.push_back(stableKey(cardData.key)); + + const bool sectionVisible = std::ranges::any_of( + insertedSectionData.cards, + [this](const VisibleCardData &card) { return cardVisible(card); }); + section->setVisible(sectionVisible); + if (empty_->isVisible()) + empty_->hide(); + + if (snapshot.activeTurnId != snapshot_.activeTurnId) { + const auto updateActiveRoot = [this](const ConversationSnapshot &state, + bool active) { + if (!state.activeTurnId) + return; + const auto found = std::ranges::find_if( + state.sections, [&](const TurnSection &candidate) { + return candidate.turnId == *state.activeTurnId && + candidate.rootCardKey.has_value(); + }); + if (found == state.sections.end()) + return; + const auto retained = cards_.find(stableKey(*found->rootCardKey)); + if (retained != cards_.end()) + retained->second->setAuthoritativeTurnActive(active); + }; + updateActiveRoot(snapshot_, false); + updateActiveRoot(snapshot, true); + } else if (root) { + root->setAuthoritativeTurnActive( + snapshot.activeTurnId && + insertedSectionData.turnId == *snapshot.activeTurnId); + } + + geometryCards.push_back(insertedCard); + displayedSectionKeys_.clear(); + displayedSectionKeys_.reserve(snapshot.sections.size()); + for (const TurnSection &candidate : snapshot.sections) + displayedSectionKeys_.push_back(candidate.key); + displayedCardKeys_ = std::move(nextDisplayedKeys); + snapshot_ = std::move(snapshot); + QElapsedTimer geometryElapsed; + geometryElapsed.start(); + if (cachedNestedAppend) { + recomputeAppendedNestedCardGeometry( + insertedCard, root, section, previousNestedHeight, + previousNestedVisible, previousRootHeight, previousSectionHeight); + } else if (cachedSectionAppend && root == insertedCard) { + recomputeAppendedSectionGeometry(insertedCard, section, + appendedSectionTop); + } else { + if (cachedSectionAppend) + contentLayout_->setEnabled(true); + recomputeCardGeometries(geometryCards); + } + setProperty("lastIncrementalGeometryMicros", + geometryElapsed.nsecsElapsed() / 1000); + + if (follow && (appendedVisibleCard || settleFollowImmediately)) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } + + if (follow && !appendedVisibleCard && !settleFollowImmediately) { + const int stableValue = verticalScrollBar()->value(); + if (verticalScrollBar()->maximum() > stableValue + 3) + animateToBottom(stableValue); + else + setScrollValue(verticalScrollBar()->maximum()); + } + storeCurrentThreadState(); + for (nodegraph::NodeRef &prompt : materializedPrompts) + if (promptMaterializedAction_ && + !promptMaterializedAction_(std::move(prompt))) + break; + setProperty("graphRefreshPasses", + property("graphRefreshPasses").toULongLong() + 1); + setProperty("incrementalStructuralCommits", + property("incrementalStructuralCommits").toULongLong() + 1); + setProperty("lastIncrementalStructuralMicros", + insertionElapsed.nsecsElapsed() / 1000); + return true; +} + +bool ConversationView::reconcile(ConversationSnapshot snapshot, bool force, bool settleFollowImmediately) { if (!force && snapshot == snapshot_ && snapshot.threadId == threadId_) return false; const bool switchedThread = snapshot.threadId != threadId_; + const auto sameStructure = [this, &snapshot] { + if (snapshot.threadId != snapshot_.threadId || + snapshot.hasMore != snapshot_.hasMore || + snapshot.hiddenAuthoritativeItemCount != + snapshot_.hiddenAuthoritativeItemCount || + snapshot.sections.size() != snapshot_.sections.size()) + return false; + for (std::size_t sectionIndex = 0; + sectionIndex < snapshot.sections.size(); ++sectionIndex) { + const TurnSection &before = snapshot_.sections[sectionIndex]; + const TurnSection &after = snapshot.sections[sectionIndex]; + if (before.key != after.key || before.turnId != after.turnId || + before.rootCardKey != after.rootCardKey || + before.cards.size() != after.cards.size()) + return false; + for (std::size_t cardIndex = 0; cardIndex < after.cards.size(); + ++cardIndex) { + const VisibleCardData &oldCard = before.cards[cardIndex]; + const VisibleCardData &newCard = after.cards[cardIndex]; + if (stableKey(oldCard.key) != stableKey(newCard.key) || + cardVisible(oldCard) != cardVisible(newCard)) + return false; + const auto retained = cards_.find(stableKey(newCard.key)); + if (retained == cards_.end() || + !retained->second->canApply(newCard)) + return false; + } + } + return true; + }; + + // The established snapshot API remains the structural authority, but most + // protocol traffic changes only presentation fields of existing cards. + // Keep those updates inside their card instead of rebuilding nesting and + // traversing every QWidget/layout in the retained history window. + if (!force && !switchedThread && sameStructure()) { + const Anchor anchor = captureAnchor(); + const bool follow = mode_ == Mode::Following; + PresentationImpact impact = PresentationImpact::None; + std::vector geometryCards; + std::vector materializedPrompts; + applying_ = true; + const QSignalBlocker scrollSignals(verticalScrollBar()); + for (std::size_t sectionIndex = 0; + sectionIndex < snapshot.sections.size(); ++sectionIndex) { + const TurnSection &before = snapshot_.sections[sectionIndex]; + const TurnSection &after = snapshot.sections[sectionIndex]; + for (std::size_t cardIndex = 0; cardIndex < after.cards.size(); + ++cardIndex) { + const VisibleCardData &oldCard = before.cards[cardIndex]; + const VisibleCardData &newCard = after.cards[cardIndex]; + if (oldCard == newCard) + continue; + ConversationCard *card = cards_.at(stableKey(newCard.key)); + if (oldCard.kind == CardKind::LocalPrompt && + newCard.kind == CardKind::UserMessage && newCard.target) + materializedPrompts.push_back(newCard.target); + const PresentationImpact cardImpact = card->applyPresentation(newCard); + if (cardImpact == PresentationImpact::GeometryChanged) + geometryCards.push_back(card); + if (static_cast(cardImpact) > static_cast(impact)) + impact = cardImpact; + } + } + if (snapshot.activeTurnId != snapshot_.activeTurnId) { + for (const TurnSection §ion : snapshot.sections) { + if (!section.rootCardKey) + continue; + const auto retained = cards_.find(stableKey(*section.rootCardKey)); + if (retained != cards_.end() && + retained->second->setAuthoritativeTurnActive( + snapshot.activeTurnId && + section.turnId == *snapshot.activeTurnId) && + static_cast(PresentationImpact::PaintOnly) > + static_cast(impact)) + impact = PresentationImpact::PaintOnly; + } + } + snapshot_ = std::move(snapshot); + if (impact == PresentationImpact::GeometryChanged) { + recomputeCardGeometries(geometryCards); + if (follow) + setScrollValue(verticalScrollBar()->maximum()); + else + restoreAnchor(anchor); + } + applying_ = false; + storeCurrentThreadState(); + if (promptMaterializedAction_) + for (nodegraph::NodeRef &prompt : materializedPrompts) + if (!promptMaterializedAction_(std::move(prompt))) + break; + if (impact != PresentationImpact::None) + setProperty("graphRefreshPasses", + property("graphRefreshPasses").toULongLong() + 1); + return impact != PresentationImpact::None; + } + + if (!force && !switchedThread && + tryReconcileSingleInsertion(snapshot, settleFollowImmediately)) + return true; + if (switchedThread) setThread(snapshot.threadId); @@ -257,17 +955,27 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot, visualChange = true; } if (showLoadMore) { - const std::size_t page = std::min(AuthoritativeHistoryPageSize, - snapshot.hiddenAuthoritativeItemCount); + const std::size_t page = + snapshot.hiddenAuthoritativeItemCount == 0 + ? AuthoritativeHistoryPageSize + : std::min(AuthoritativeHistoryPageSize, + snapshot.hiddenAuthoritativeItemCount); const QString label = QStringLiteral("Load %1 more activities") .arg(static_cast(page)); if (loadMore_->text() != label) { loadMore_->setText(label); visualChange = true; } - loadMore_->setToolTip(QStringLiteral("%1 earlier activities are retained") - .arg(static_cast( - snapshot.hiddenAuthoritativeItemCount))); + const QString tooltip = snapshot.hiddenAuthoritativeItemCount == 0 + ? QStringLiteral( + "Earlier activities are available") + : QStringLiteral( + "%1 earlier activities are retained") + .arg(static_cast( + snapshot + .hiddenAuthoritativeItemCount)); + if (loadMore_->toolTip() != tooltip) + loadMore_->setToolTip(tooltip); } struct DesiredSection { @@ -287,6 +995,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot, std::vector displayedKeys; std::vector> commandOutputRestorations; + std::vector materializedPrompts; const auto retainCommandOutputState = [this](const std::string &key, ConversationCard *card) { const auto state = card ? card->commandOutputScrollState() : std::nullopt; @@ -400,21 +1109,33 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot, const auto existingCard = cards_.find(key); if (existingCard != cards_.end()) { card = existingCard->second; + if (card->data().kind == CardKind::LocalPrompt && + cardData.kind == CardKind::UserMessage && cardData.target) + materializedPrompts.push_back(cardData.target); visualChange = card->apply(cardData) || visualChange; } else { - card = createConversationCard( - cardData, section, !presentationOptions_.commandsInitiallyExpanded, - !presentationOptions_.imagesInitiallyExpanded); - card->setProperty("conversationAnchorKey", QString::fromStdString(key)); - if (const auto collapsed = cardCollapsedStates_.find(key); - collapsed != cardCollapsedStates_.end()) - card->setCollapsed(collapsed->second); - connect(card, &ConversationCard::foldRequested, this, - [this, key, card](bool collapsed) { - const auto retained = cards_.find(key); - if (retained != cards_.end() && retained->second == card) - setCardCollapsed(key, card, collapsed); - }); + const auto staged = stagedCards_.find(key); + if (staged != stagedCards_.end() && + staged->second->canApply(cardData)) { + card = staged->second; + stagedCards_.erase(staged); + card->setParent(section); + // The staging pass created this card from the same presentation and + // applyCardPresentation keeps it current while the hidden batch is + // being prepared. Reapplying every rich subtree here makes the + // atomic reveal proportional to presentation work already done. + // Reparent only; the final geometry transaction below establishes + // its committed width and height. + } else { + if (staged != stagedCards_.end()) { + delete staged->second; + stagedCards_.erase(staged); + } + card = createRetainedCard(cardData, section, key); + } + if (std::holds_alternative(cardData.key) && + cardData.kind == CardKind::UserMessage && cardData.target) + materializedPrompts.push_back(cardData.target); if (const auto saved = commandOutputStates_.find(key); saved != commandOutputStates_.end()) { commandOutputRestorations.emplace_back(card, saved->second); @@ -511,8 +1232,7 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot, visualChange = true; } displayedCardKeys_ = std::move(displayedKeys); - snapshot_ = snapshot; - + snapshot_ = std::move(snapshot); recomputeGeometry(); const bool outputGrew = visibleOutputFootprint() > outputFootprintBefore; for (const auto &[card, state] : commandOutputRestorations) @@ -543,6 +1263,13 @@ bool ConversationView::reconcile(const ConversationSnapshot &snapshot, setScrollValue(verticalScrollBar()->maximum()); } storeCurrentThreadState(); + if (promptMaterializedAction_) + for (nodegraph::NodeRef &prompt : materializedPrompts) + if (!promptMaterializedAction_(std::move(prompt))) + break; + if (visualChange) + setProperty("graphRefreshPasses", + property("graphRefreshPasses").toULongLong() + 1); return visualChange; } @@ -699,6 +1426,8 @@ void ConversationView::resizeEvent(QResizeEvent *event) { viewport()->setUpdatesEnabled(false); const QSignalBlocker scrollSignals(verticalScrollBar()); QAbstractScrollArea::resizeEvent(event); + stagingHost_->resize(viewport()->size()); + stagingOverlay_->setGeometry(viewport()->rect()); recomputeGeometry(); if (follow) setScrollValue(verticalScrollBar()->maximum()); @@ -781,14 +1510,306 @@ void ConversationView::animateToBottom(int previousValue) { followAnimation_->start(); } +void ConversationView::recomputeCardGeometries( + const std::vector &changedCards) { + if (changedCards.empty() || !content_ || !viewport()) + return; + contentLayout_->setEnabled(true); + setProperty("conversationLocalGeometryPasses", + property("conversationLocalGeometryPasses").toULongLong() + 1); + + const auto appendUnique = [](auto &values, auto *value) { + if (value && std::ranges::find(values, value) == values.end()) + values.push_back(value); + }; + std::vector sections; + std::vector turnContainers; + std::vector directCards; + for (ConversationCard *card : changedCards) { + if (!card) + continue; + TurnSectionWidget *section = nullptr; + ConversationCard *turnContainer = + card->property("turnContainer").toBool() ? card : nullptr; + for (QWidget *parent = card->parentWidget(); parent; + parent = parent->parentWidget()) { + if (!turnContainer) { + auto *candidate = dynamic_cast(parent); + if (candidate && candidate->property("turnContainer").toBool()) + turnContainer = candidate; + } + if (auto *candidate = dynamic_cast(parent)) { + section = candidate; + break; + } + } + appendUnique(sections, section); + appendUnique(turnContainers, turnContainer); + if (card != turnContainer) + appendUnique(directCards, card); + } + + for (TurnSectionWidget *section : sections) + if (section && section->layout()) + section->layout()->setEnabled(true); + for (ConversationCard *container : turnContainers) + if (container && container->layout()) + container->layout()->setEnabled(true); + + for (ConversationCard *card : directCards) { + const int width = std::max( + 0, card->parentWidget() ? card->parentWidget()->contentsRect().width() + : card->width()); + static_cast(settleCardGeometry(card, width)); + } + for (ConversationCard *container : turnContainers) { + QWidget *nested = container->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly); + if (nested && nested->layout()) { + nested->layout()->setEnabled(true); + nested->layout()->invalidate(); + nested->layout()->activate(); + const int nestedHeight = + nested->isHidden() ? 0 : nested->layout()->minimumSize().height(); + nested->setFixedHeight(nestedHeight); + nested->layout()->setGeometry(nested->contentsRect()); + nested->layout()->activate(); + } + const int width = std::max( + 0, container->parentWidget() + ? container->parentWidget()->contentsRect().width() + : container->width()); + static_cast(settleCardGeometry(container, width)); + } + + int totalDelta = 0; + for (TurnSectionWidget *section : sections) { + if (!section || !section->layout()) + continue; + const int previousHeight = section->height(); + section->setMinimumHeight(0); + section->layout()->invalidate(); + section->layout()->activate(); + const int height = section->layout()->minimumSize().height(); + section->setMinimumHeight(height); + section->resize(section->width(), height); + section->layout()->setGeometry(section->contentsRect()); + section->layout()->activate(); + totalDelta += height - previousHeight; + } + + naturalContentHeight_ = std::max(0, naturalContentHeight_ + totalDelta); + contentHeight_ = std::max(viewport()->height(), + naturalContentHeight_ + trailingSpaceHeight_); + const int width = std::max(0, viewport()->width()); + content_->resize(width, contentHeight_); + contentLayout_->setGeometry(QRect(0, 0, width, contentHeight_)); + contentLayout_->activate(); + verticalScrollBar()->setPageStep(viewport()->height()); + verticalScrollBar()->setRange( + 0, std::max(0, contentHeight_ - viewport()->height())); + positionContent(); + + // Consume only the requests generated by the affected ancestry while the + // local transaction is still marked as applying. They must not escape as a + // later complete-conversation LayoutRequest. + for (ConversationCard *card : directCards) + QCoreApplication::sendPostedEvents(card, QEvent::LayoutRequest); + for (ConversationCard *container : turnContainers) + QCoreApplication::sendPostedEvents(container, QEvent::LayoutRequest); + for (TurnSectionWidget *section : sections) + QCoreApplication::sendPostedEvents(section, QEvent::LayoutRequest); + QCoreApplication::sendPostedEvents(content_, QEvent::LayoutRequest); +} + +int ConversationView::settleCardGeometry(ConversationCard *card, int width) { + if (!card || !card->layout()) + return 0; + width = std::max(0, width); + card->setMinimumHeight(0); + card->resize(width, card->height()); + if (QWidget *cardContent = card->findChild( + QStringLiteral("conversationCardContent"), + Qt::FindDirectChildrenOnly); + cardContent && cardContent->layout()) { + cardContent->layout()->invalidate(); + cardContent->layout()->setGeometry(cardContent->contentsRect()); + cardContent->layout()->activate(); + } + card->layout()->invalidate(); + card->layout()->setGeometry(card->contentsRect()); + card->layout()->activate(); + card->updateGeometry(); + const int height = card->layout()->hasHeightForWidth() + ? card->layout()->heightForWidth(width) + + 2 * card->frameWidth() + : card->sizeHint().height(); + card->setMinimumHeight(height); + card->resize(width, height); + card->layout()->setGeometry(card->contentsRect()); + card->layout()->activate(); + return height; +} + +void ConversationView::recomputeAppendedNestedCardGeometry( + ConversationCard *card, ConversationCard *turnContainer, + TurnSectionWidget *section, int previousNestedHeight, + bool previousNestedVisible, int previousContainerHeight, + int previousSectionHeight) { + if (!card || !turnContainer || !section) + return; + setProperty("conversationCachedAppendGeometryPasses", + property("conversationCachedAppendGeometryPasses") + .toULongLong() + + 1); + + QWidget *nested = turnContainer->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly); + if (!nested || !nested->layout() || !card->layout()) { + recomputeCardGeometries({card}); + return; + } + + const int cardWidth = std::max(0, nested->contentsRect().width()); + const int cardHeight = settleCardGeometry(card, cardWidth); + + const bool nestedVisible = !nested->isHidden(); + int nestedHeight = previousNestedHeight; + if (!nestedVisible) { + nestedHeight = 0; + } else if (!card->isHidden()) { + if (previousNestedVisible) { + nestedHeight += nested->layout()->spacing() + cardHeight; + } else { + const QMargins margins = nested->layout()->contentsMargins(); + nestedHeight = margins.top() + cardHeight + margins.bottom(); + } + } + QLayout *nestedLayout = nested->layout(); + nested->setFixedHeight(std::max(0, nestedHeight)); + + int containerDelta = nestedHeight - previousNestedHeight; + if (!previousNestedVisible && nestedVisible) + containerDelta += turnContainer->layout()->spacing(); + else if (previousNestedVisible && !nestedVisible) + containerDelta -= turnContainer->layout()->spacing(); + const int containerHeight = + std::max(0, previousContainerHeight + containerDelta); + turnContainer->setMinimumHeight(containerHeight); + turnContainer->resize(turnContainer->width(), containerHeight); + + const int sectionHeight = std::max(0, previousSectionHeight + containerDelta); + section->setMinimumHeight(sectionHeight); + section->resize(section->width(), sectionHeight); + + naturalContentHeight_ = std::max(0, naturalContentHeight_ + containerDelta); + contentHeight_ = std::max(viewport()->height(), + naturalContentHeight_ + trailingSpaceHeight_); + const int width = std::max(0, viewport()->width()); + content_->resize(width, contentHeight_); + verticalScrollBar()->setPageStep(viewport()->height()); + verticalScrollBar()->setRange( + 0, std::max(0, contentHeight_ - viewport()->height())); + positionContent(); + + if (!card->isHidden()) { + const QMargins margins = nestedLayout->contentsMargins(); + const int cardTop = previousNestedVisible + ? previousNestedHeight - margins.bottom() + + nestedLayout->spacing() + : margins.top(); + card->setGeometry(margins.left(), cardTop, + std::max(0, nested->width() - margins.left() - + margins.right()), + cardHeight); + } + + for (QWidget *descendant : card->findChildren()) + QCoreApplication::removePostedEvents(descendant, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(card, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(nested, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(turnContainer, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(section, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(content_, QEvent::LayoutRequest); +} + +void ConversationView::recomputeAppendedSectionGeometry( + ConversationCard *card, TurnSectionWidget *section, int sectionTop) { + if (!card || !section) + return; + setProperty("conversationCachedSectionAppendGeometryPasses", + property("conversationCachedSectionAppendGeometryPasses") + .toULongLong() + + 1); + + const int width = std::max(0, content_->width()); + const int cardHeight = settleCardGeometry(card, width); + + section->setMinimumHeight(cardHeight); + section->setGeometry(0, sectionTop, width, cardHeight); + card->setGeometry(0, 0, width, cardHeight); + naturalContentHeight_ = + std::max(0, naturalContentHeight_ + contentLayout_->spacing() + + cardHeight); + contentHeight_ = std::max(viewport()->height(), + naturalContentHeight_ + trailingSpaceHeight_); + content_->resize(width, contentHeight_); + verticalScrollBar()->setPageStep(viewport()->height()); + verticalScrollBar()->setRange( + 0, std::max(0, contentHeight_ - viewport()->height())); + positionContent(); + + for (QWidget *descendant : card->findChildren()) + QCoreApplication::removePostedEvents(descendant, QEvent::LayoutRequest); + for (QWidget *widget : {static_cast(card), + static_cast(section), content_}) + QCoreApplication::removePostedEvents(widget, QEvent::LayoutRequest); +} + +void ConversationView::settlePaintOnlyCard(ConversationCard *card) { + if (!card) + return; + + // Text and lifecycle setters can post LayoutRequest even when the card's + // measured height is unchanged. Settle the card's internal layout in its + // existing rectangle and discard only the now-redundant requests along its + // retained ancestry. Letting one escape to content_ would invoke the full + // conversation geometry fallback for a paint-only status transition. + if (QWidget *cardContent = card->findChild( + QStringLiteral("conversationCardContent"), + Qt::FindDirectChildrenOnly); + cardContent && cardContent->layout()) { + cardContent->layout()->setGeometry(cardContent->contentsRect()); + cardContent->layout()->activate(); + QCoreApplication::removePostedEvents(cardContent, + QEvent::LayoutRequest); + } + if (card->layout()) { + card->layout()->setGeometry(card->contentsRect()); + card->layout()->activate(); + } + + for (QWidget *widget = card; widget && widget != content_; + widget = widget->parentWidget()) + QCoreApplication::removePostedEvents(widget, QEvent::LayoutRequest); + QCoreApplication::removePostedEvents(content_, QEvent::LayoutRequest); +} + void ConversationView::recomputeGeometry() { if (!content_ || !viewport()) return; + contentLayout_->setEnabled(true); + setProperty("conversationGeometryPasses", + property("conversationGeometryPasses").toULongLong() + 1); const int width = std::max(0, viewport()->width()); trailingSpace_->changeSize(0, 0, QSizePolicy::Minimum, QSizePolicy::Fixed); contentLayout_->invalidate(); for (const auto &[key, section] : sections_) { static_cast(key); + if (section->layout()) + section->layout()->setEnabled(true); section->setMinimumHeight(0); } @@ -812,32 +1833,15 @@ void ConversationView::recomputeGeometry() { if (card->layout()) card->layout()->activate(); }; - const auto settleCardHeight = [&activateCard](ConversationCard *card, - int cardWidth) { - if (!card || !card->layout()) - return; - cardWidth = std::max(0, cardWidth); - card->setMinimumHeight(0); - // Retained rich text is created and nested in one transaction. Establish - // its real width before measuring so QLabel cannot reuse pre-nesting - // document geometry until a later streamed update. - card->resize(cardWidth, card->height()); - card->layout()->invalidate(); - card->layout()->setGeometry(card->contentsRect()); - activateCard(card); - card->updateGeometry(); - const int cardHeight = - card->layout()->hasHeightForWidth() - ? card->layout()->heightForWidth(cardWidth) + - 2 * card->frameWidth() - : card->sizeHint().height(); - card->setMinimumHeight(cardHeight); - card->resize(cardWidth, cardHeight); - card->layout()->setGeometry(card->contentsRect()); - activateCard(card); - }; for (const auto &[key, card] : cards_) { static_cast(key); + if (card->layout()) + card->layout()->setEnabled(true); + if (QWidget *nested = card->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly); + nested && nested->layout()) + nested->layout()->setEnabled(true); activateCard(card); } // Child/subagent threads may have no visible You root. Their cards live @@ -851,7 +1855,7 @@ void ConversationView::recomputeGeometry() { const int cardWidth = card->parentWidget() ? card->parentWidget()->contentsRect().width() : card->width(); - settleCardHeight(card, cardWidth); + static_cast(settleCardGeometry(card, cardWidth)); } // A You turn container adds one real layout depth. Settle that depth in // dependency order so newly nested cards reach their final height inside @@ -882,7 +1886,7 @@ void ConversationView::recomputeGeometry() { if (!nestedCard) continue; const int nestedWidth = nested->contentsRect().width(); - settleCardHeight(nestedCard, nestedWidth); + static_cast(settleCardGeometry(nestedCard, nestedWidth)); } nested->layout()->invalidate(); const int nestedHeight = @@ -892,7 +1896,7 @@ void ConversationView::recomputeGeometry() { nested->updateGeometry(); nested->layout()->invalidate(); nested->layout()->activate(); - settleCardHeight(card, cardWidth); + static_cast(settleCardGeometry(card, cardWidth)); } for (const auto &[key, section] : sections_) { static_cast(key); diff --git a/src/codex/middle/ConversationView.h b/src/codex/middle/ConversationView.h index 446b788..b16a146 100644 --- a/src/codex/middle/ConversationView.h +++ b/src/codex/middle/ConversationView.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -17,6 +18,7 @@ class QLabel; class QEvent; class QPushButton; class QSpacerItem; +class QTimer; class QVariantAnimation; class QVBoxLayout; class QWheelEvent; @@ -36,13 +38,18 @@ class ConversationView final : public QAbstractScrollArea { bool showCodexUpdates = true; bool commandsInitiallyExpanded = false; bool imagesInitiallyExpanded = false; + bool fileChangesInitiallyExpanded = false; bool operator==(const PresentationOptions &) const = default; }; explicit ConversationView(QWidget *parent = nullptr); + ~ConversationView() override; void setLoadMoreAction(std::function action); + void + setPromptMaterializedAction(std::function action); + void setPromptRecoveryAction(std::function action); void setEmptyMessage(QString message); void setPresentationOptions(PresentationOptions options); [[nodiscard]] PresentationOptions presentationOptions() const noexcept { @@ -53,6 +60,22 @@ class ConversationView final : public QAbstractScrollArea { // key; first render and later updates use this same reconciliation path. bool reconcile(const ConversationSnapshot &snapshot); + // Structural changes which introduce many rich cards are prepared under a + // hidden Qt parent in bounded event-loop slices, then committed through the + // ordinary reconcile contract in one visible transaction. Existing cards + // remain retained throughout Load-more staging. + void reconcileStaged(ConversationSnapshot snapshot); + [[nodiscard]] bool structuralStagingActive() const noexcept { + return pendingStructuralSnapshot_.has_value(); + } + + // Applies one already-materialized card without constructing or traversing + // a complete conversation snapshot. A disengaged result requests the + // structural reconcile path because the card is absent, hidden, or changed + // identity/kind. + [[nodiscard]] std::optional + applyCardPresentation(const VisibleCardData &card); + // Extra composer height is represented after the final card, while the // viewport itself keeps its canonical geometry. void setTrailingSpaceHeight(int height); @@ -95,8 +118,10 @@ class ConversationView final : public QAbstractScrollArea { class TurnSectionWidget; - bool reconcile(const ConversationSnapshot &snapshot, bool force, + bool reconcile(ConversationSnapshot snapshot, bool force, bool settleFollowImmediately); + [[nodiscard]] bool tryReconcileSingleInsertion( + ConversationSnapshot &snapshot, bool settleFollowImmediately); [[nodiscard]] bool cardVisible(const VisibleCardData &card) const noexcept; void setThread(const std::string &threadId); void setCardCollapsed(const std::string &key, ConversationCard *card, @@ -107,6 +132,24 @@ class ConversationView final : public QAbstractScrollArea { void setScrollValue(int value); void stopFollowingAnimation(); void animateToBottom(int previousValue); + void recomputeCardGeometries( + const std::vector &changedCards); + [[nodiscard]] int settleCardGeometry(ConversationCard *card, int width); + void recomputeAppendedNestedCardGeometry( + ConversationCard *card, ConversationCard *turnContainer, + TurnSectionWidget *section, int previousNestedHeight, + bool previousNestedVisible, int previousContainerHeight, + int previousSectionHeight); + void recomputeAppendedSectionGeometry(ConversationCard *card, + TurnSectionWidget *section, + int sectionTop); + void settlePaintOnlyCard(ConversationCard *card); + void scheduleStructuralStagePass(); + void runStructuralStagePass(); + void cancelStructuralStaging(); + [[nodiscard]] ConversationCard *createRetainedCard( + const VisibleCardData &data, QWidget *parent, const std::string &key); + [[nodiscard]] VisibleCardData *pendingCard(const std::string &key); void recomputeGeometry(); void positionContent(); void handleUserScrollValue(int value); @@ -115,17 +158,22 @@ class ConversationView final : public QAbstractScrollArea { cardForStableKey(const std::string &stableKey) const; QWidget *content_ = nullptr; + QWidget *stagingHost_ = nullptr; + QLabel *stagingOverlay_ = nullptr; QVBoxLayout *contentLayout_ = nullptr; QPushButton *loadMore_ = nullptr; QSpacerItem *trailingSpace_ = nullptr; QLabel *empty_ = nullptr; QVariantAnimation *followAnimation_ = nullptr; std::function loadMoreAction_; + std::function promptMaterializedAction_; + std::function promptRecoveryAction_; ConversationSnapshot snapshot_; std::string threadId_; std::unordered_map sections_; std::unordered_map cards_; + std::unordered_map stagedCards_; std::vector displayedSectionKeys_; std::vector displayedCardKeys_; std::unordered_map threadStates_; @@ -133,6 +181,9 @@ class ConversationView final : public QAbstractScrollArea { commandOutputStates_; std::unordered_map cardCollapsedStates_; PresentationOptions presentationOptions_; + std::optional pendingStructuralSnapshot_; + std::vector pendingStructuralCardKeys_; + std::size_t pendingStructuralCardIndex_ = 0; Mode mode_ = Mode::Following; int trailingSpaceHeight_ = 0; @@ -145,6 +196,8 @@ class ConversationView final : public QAbstractScrollArea { bool userActionPending_ = false; bool pausedByComposerGrowth_ = false; bool dispatchingNativeWheel_ = false; + bool structuralStagePassScheduled_ = false; + bool committingStructuralStage_ = false; }; } // namespace codexui::codex::middle diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index d05f2c2..da8e1d3 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -3,7 +3,8 @@ #include "codex/middle/InspectorPane.h" #include "codex/DiffViewer.h" -#include "codex/PresentationStatus.h" +#include "codex/UiStatus.h" +#include "codex/nodegraph/ProtocolUpdater.h" #include "codex/ui/UiStyle.h" #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +30,8 @@ #include #include +#include +#include #include #include @@ -51,12 +55,70 @@ QStringList texts(const std::vector &values) { return result; } -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) +const nodegraph::Value *graphField(const nodegraph::Value::Object &object, + std::string_view name) { + const auto found = object.find(name); + return found == object.end() ? nullptr : &found->second; +} + +std::string graphString(const nodegraph::Value *value) { + if (!value) + return {}; + if (const auto *string = value->asString()) + return *string; + if (const auto *number = value->asUInt64()) + return std::to_string(*number); + if (const auto *number = value->asInt64()) + return std::to_string(*number); + return {}; +} + +std::optional graphUnsigned(const nodegraph::Value *value) { + if (!value) + return std::nullopt; + if (const auto *number = value->asUInt64()) + return *number; + if (const auto *number = value->asInt64(); number && *number >= 0) + return static_cast(*number); + return std::nullopt; +} + +bool sensitiveDiagnosticText(std::string_view value) { + std::string lowered; + lowered.reserve(value.size()); + for (const unsigned char character : value) + lowered.push_back(static_cast(std::tolower(character))); + constexpr std::array markers{ + std::string_view("authorization"), std::string_view("bearer "), + std::string_view("password"), std::string_view("secret"), + std::string_view("token="), std::string_view("token:"), + std::string_view("credential"), std::string_view("cookie"), + std::string_view("-----begin"), std::string_view("github_pat_"), + std::string_view("ghp_"), std::string_view("xoxb-"), + std::string_view("xoxp-")}; + return std::ranges::any_of(markers, [&lowered](std::string_view marker) { + return lowered.find(marker) != std::string::npos; + }) || lowered.find("sk-") != std::string::npos; +} + +QString protocolMetadata(const nodegraph::Value *value, + qsizetype maximumCharacters = 240) { + const std::string raw = graphString(value); + if (raw.empty()) return {}; - const auto found = object.find(key); - return found != object.end() && found->is_string() ? found->get() - : std::string{}; + if (sensitiveDiagnosticText(raw)) + return QStringLiteral(""); + QString result = text(raw); + for (qsizetype index = 0; index < result.size(); ++index) { + const QChar character = result.at(index); + if (character.unicode() < 0x20U || character.unicode() == 0x7fU) + result[index] = QLatin1Char(' '); + } + if (result.size() > maximumCharacters) { + result.truncate(maximumCharacters); + result += QStringLiteral("..."); + } + return result; } bool supportsDirectAccept(std::string_view kind) { @@ -83,7 +145,7 @@ QLabel *makeLabel(QString value, const char *kind = "body") { } QLabel *statusLabel(const std::string &status) { - const PresentationStatus classified = classifyStatus(status); + const UiStatus classified = classifyStatus(status); auto *label = makeLabel(text(displayStatus(status)), "meta"); if (!classified.tone.empty()) label->setProperty("tone", classified.tone.data()); @@ -223,12 +285,13 @@ QString agentCopyText(const ui::InspectorAgentRow &agent) { void clearLayout(QLayout *layout) { while (QLayoutItem *item = layout->takeAt(0)) { - if (QWidget *widget = item->widget()) - delete widget; if (QLayout *child = item->layout()) { clearLayout(child); delete child; + continue; } + if (QWidget *widget = item->widget()) + delete widget; delete item; } } @@ -309,11 +372,53 @@ void restoreScrollPosition(QPlainTextEdit *view, } // namespace +QFrame *InspectorPane::planStepFrame(const ui::InspectorPlanStep &step) { + auto *frame = new QFrame; + patchPlanStepFrame(frame, step); + setProperty("planRowConstructions", + property("planRowConstructions").toULongLong() + 1); + return frame; +} + +void InspectorPane::patchPlanStepFrame(QFrame *frame, + const ui::InspectorPlanStep &step) { + frame->setObjectName(QStringLiteral("inspectorPlanStepFrame")); + frame->setProperty("planStep", text(step.step)); + frame->setProperty("kind", "raised"); + auto *layout = qobject_cast(frame->layout()); + if (layout) + clearLayout(layout); + else + layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + layout->addWidget(makeLabel(text(step.step))); + layout->addWidget(statusLabel(step.status)); + setProperty("planRowPatches", + property("planRowPatches").toULongLong() + 1); +} + QFrame *InspectorPane::agentFrame(const ui::InspectorAgentRow &agent) { auto *frame = new QFrame; + patchAgentFrame(frame, agent); + setProperty("agentRowConstructions", + property("agentRowConstructions").toULongLong() + 1); + return frame; +} + +void InspectorPane::patchAgentFrame(QFrame *frame, + const ui::InspectorAgentRow &agent) { + if (!frame) + return; + frame->setObjectName(QStringLiteral("inspectorAgentFrame")); + frame->setProperty("logicalAgentId", text(agent.id)); frame->setProperty("kind", "raised"); frame->setMinimumWidth(0); - auto *layout = new QVBoxLayout(frame); + auto *layout = qobject_cast(frame->layout()); + if (layout) + clearLayout(layout); + else + layout = new QVBoxLayout(frame); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); const QString agentPath = text(agent.agentPath); @@ -413,9 +518,99 @@ QFrame *InspectorPane::agentFrame(const ui::InspectorAgentRow &agent) { QApplication::clipboard()->setMimeData(mime); copy->showCopiedFeedback(); }); + setProperty("agentRowPatches", + property("agentRowPatches").toULongLong() + 1); +} + +QFrame *InspectorPane::requestFrame( + const ui::InspectorRequestRow &request) { + auto *frame = new QFrame; + patchRequestFrame(frame, request); + setProperty("requestRowConstructions", + property("requestRowConstructions").toULongLong() + 1); return frame; } +void InspectorPane::patchRequestFrame( + QFrame *frame, const ui::InspectorRequestRow &request) { + frame->setObjectName(QStringLiteral("inspectorRequestFrame")); + frame->setProperty("requestId", text(request.id)); + frame->setProperty("kind", "raised"); + frame->setProperty("tone", "warning"); + auto *layout = qobject_cast(frame->layout()); + if (layout) + clearLayout(layout); + else + layout = new QVBoxLayout(frame); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(6); + layout->addWidget( + makeLabel(UiStyle::humanizeLabel(text(request.kind)), "title")); + layout->addWidget( + makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") + .arg(text(request.threadContext)) + .arg(static_cast(request.generation)) + .arg(text(request.id)), + "meta")); + const auto addMetadata = [layout](const std::string &value, + const char *prefix) { + const QString displayed = text(value); + if (!displayed.isEmpty()) + layout->addWidget( + makeLabel(QString::fromLatin1(prefix) + displayed, "meta")); + }; + addMetadata(request.command, "Command: "); + addMetadata(request.reason, "Reason: "); + addMetadata(request.message, ""); + if (request.questionCount) + layout->addWidget( + makeLabel(QStringLiteral("%1 questions") + .arg(static_cast(*request.questionCount)), + "meta")); + if (request.command.empty() && request.reason.empty() && + request.message.empty() && !request.questionCount) + layout->addWidget( + makeLabel(QStringLiteral("Request %1 needs a decision.") + .arg(text(request.id)), + "meta")); + auto *actions = new QHBoxLayout; + actions->setContentsMargins(0, 2, 0, 0); + auto *reject = new QPushButton(QStringLiteral("Reject")); + reject->setProperty("kind", "destructive"); + reject->setFixedHeight(28); + reject->setEnabled(request.actionable); + connect(reject, &QPushButton::clicked, frame, [this, id = request.id] { + if (rejectRequest) + rejectRequest(id); + }); + actions->addStretch(); + actions->addWidget(reject); + if (supportsDirectAccept(request.kind)) { + auto *accept = new QPushButton(directAcceptText(request.kind)); + accept->setProperty("kind", "request"); + accept->setFixedHeight(28); + accept->setEnabled(request.actionable); + connect(accept, &QPushButton::clicked, frame, [this, id = request.id] { + if (acceptRequest) + acceptRequest(id); + }); + actions->addWidget(accept); + } else { + auto *review = new QPushButton(QStringLiteral("Review")); + review->setProperty("kind", "request"); + review->setFixedHeight(28); + review->setEnabled(request.actionable); + connect(review, &QPushButton::clicked, frame, [this, id = request.id] { + if (reviewRequest) + reviewRequest(id); + }); + actions->addWidget(review); + } + layout->addLayout(actions); + setProperty("requestRowPatches", + property("requestRowPatches").toULongLong() + 1); +} + InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { setObjectName(QStringLiteral("inspector")); setMinimumWidth(300); @@ -452,14 +647,17 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { planLayout = new QVBoxLayout(planContent); planLayout->setContentsMargins(12, 12, 12, 12); planLayout->setSpacing(8); + planLayout->addStretch(); agentsContent = new QWidget; agentsLayout = new QVBoxLayout(agentsContent); agentsLayout->setContentsMargins(12, 12, 12, 12); agentsLayout->setSpacing(8); + agentsLayout->addStretch(); requestsContent = new QWidget; requestsLayout = new QVBoxLayout(requestsContent); requestsLayout->setContentsMargins(12, 12, 12, 12); requestsLayout->setSpacing(8); + requestsLayout->addStretch(); diffViewer = new DiffViewer; const auto makeScroll = [](QWidget *content) { @@ -531,12 +729,10 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { &protocolBack)); connect(stateChoice, &QPushButton::clicked, this, [this] { infoStack->setCurrentIndex(StatePage); - refreshCurrentTab(); }); connect(protocolChoice, &QPushButton::clicked, this, [this] { infoStack->setCurrentIndex(ProtocolPage); showProtocolTail(); - refreshCurrentTab(); }); const auto showInfoChoices = [this] { infoStack->setCurrentIndex(InfoChoicePage); @@ -552,14 +748,27 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { inspectorTabs->addTab(infoStack, QStringLiteral("Info")); outer->addWidget(inspectorTabs, 1); + const auto requestCurrentPage = [this](int) { + if (refreshRequested) + refreshRequested(); + else + refreshCurrentTab(); + }; connect(inspectorTabs, &QTabWidget::currentChanged, this, - [this](int) { refreshCurrentTab(); }); + requestCurrentPage); + connect(infoStack, &QStackedWidget::currentChanged, this, + requestCurrentPage); } void InspectorPane::setHideAction(std::function hide) { hideAction = std::move(hide); } +void InspectorPane::setRefreshRequestedAction( + std::function refresh) { + refreshRequested = std::move(refresh); +} + void InspectorPane::setRequestActions(RequestAction review, RequestAction accept, RequestAction reject) { @@ -570,7 +779,45 @@ void InspectorPane::setRequestActions(RequestAction review, void InspectorPane::refresh(const ui::InspectorSnapshot &snapshot) { currentSnapshot = snapshot; - refreshCurrentTab(); + if (isVisible()) + refreshCurrentTab(); +} + +void InspectorPane::refresh(const ui::InspectorSnapshot &snapshot, + ui::InspectorProjection projection) { + if (projection == ui::InspectorProjection::All || !currentSnapshot) { + currentSnapshot = snapshot; + } else { + switch (projection) { + case ui::InspectorProjection::Plan: + currentSnapshot->plan = snapshot.plan; + break; + case ui::InspectorProjection::Agents: + currentSnapshot->agents = snapshot.agents; + break; + case ui::InspectorProjection::Changes: + currentSnapshot->changes = snapshot.changes; + break; + case ui::InspectorProjection::Requests: + currentSnapshot->requests = snapshot.requests; + break; + case ui::InspectorProjection::State: + currentSnapshot->state = snapshot.state; + break; + case ui::InspectorProjection::All: + break; + } + } + if (isVisible()) + refreshCurrentTab(); +} + +void InspectorPane::showEvent(QShowEvent *event) { + QFrame::showEvent(event); + if (refreshRequested) + refreshRequested(); + else + refreshCurrentTab(); } void InspectorPane::refreshCurrentTab() { @@ -606,60 +853,164 @@ void InspectorPane::refreshPlan() { const ui::InspectorPlanSnapshot &next = currentSnapshot->plan; if (planSnapshot && *planSnapshot == next) return; + const std::optional previous = planSnapshot; planSnapshot = next; const ui::InspectorPlanSnapshot &snapshot = *planSnapshot; - setUpdatesEnabled(false); - clearLayout(planLayout); - if (!snapshot.threadPresent) { - planLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - } else if (snapshot.plan) { - const QString explanation = text(snapshot.plan->explanation); - if (!explanation.isEmpty()) - planLayout->addWidget(makeMarkdownLabel(explanation)); + const std::string beforeExplanation = + previous && previous->plan ? previous->plan->explanation : std::string{}; + const std::string nextExplanation = + snapshot.plan ? snapshot.plan->explanation : std::string{}; + if (beforeExplanation != nextExplanation || + static_cast(planExplanation) != !nextExplanation.empty()) { + if (planExplanation) { + planLayout->removeWidget(planExplanation); + delete planExplanation; + planExplanation = nullptr; + } + if (!nextExplanation.empty()) { + planExplanation = makeMarkdownLabel(text(nextExplanation)); + planLayout->insertWidget(0, planExplanation); + } + } + + std::vector> rows; + if (snapshot.plan) { + std::unordered_map occurrences; + rows.reserve(snapshot.plan->steps.size()); for (const ui::InspectorPlanStep &step : snapshot.plan->steps) { - auto *row = new QFrame; - row->setProperty("kind", "raised"); - auto *layout = new QVBoxLayout(row); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - layout->addWidget(makeLabel(text(step.step))); - layout->addWidget(statusLabel(step.status)); - planLayout->addWidget(row); + const std::size_t occurrence = occurrences[step.step]++; + rows.emplace_back(step.step + '\n' + std::to_string(occurrence), &step); } - } else if (snapshot.planItem) { - const QString value = text(*snapshot.planItem); - planLayout->addWidget( - value.isEmpty() - ? makeLabel(QStringLiteral("Plan is being prepared."), "muted") - : makeMarkdownLabel(value)); - } else { - planLayout->addWidget( - makeLabel(QStringLiteral("No plan for this thread."), "muted")); } - planLayout->addStretch(); - setUpdatesEnabled(true); + std::unordered_set desired; + for (const auto &[key, step] : rows) { + static_cast(step); + desired.insert(key); + } + for (auto iterator = planFrames.begin(); iterator != planFrames.end();) { + if (desired.contains(iterator->first)) { + ++iterator; + continue; + } + planLayout->removeWidget(iterator->second); + delete iterator->second; + renderedPlanSteps.erase(iterator->first); + iterator = planFrames.erase(iterator); + } + + if (planMessage) { + planLayout->removeWidget(planMessage); + delete planMessage; + planMessage = nullptr; + } + if (!snapshot.plan) { + if (!snapshot.threadPresent) { + planMessage = makeLabel(QStringLiteral("No selected thread."), "muted"); + } else if (snapshot.planItem) { + const QString value = text(*snapshot.planItem); + planMessage = value.isEmpty() + ? static_cast(makeLabel( + QStringLiteral("Plan is being prepared."), + "muted")) + : static_cast(makeMarkdownLabel(value)); + } else { + planMessage = + makeLabel(QStringLiteral("No plan for this thread."), "muted"); + } + planLayout->insertWidget(0, planMessage); + } + + const int firstRow = planExplanation ? 1 : 0; + for (std::size_t index = 0; index < rows.size(); ++index) { + const auto &[key, step] = rows[index]; + QFrame *frame = nullptr; + const auto retained = planFrames.find(key); + if (retained == planFrames.end()) { + frame = planStepFrame(*step); + planFrames.emplace(key, frame); + } else { + frame = retained->second; + const auto rendered = renderedPlanSteps.find(key); + if (rendered == renderedPlanSteps.end() || rendered->second != *step) + patchPlanStepFrame(frame, *step); + } + renderedPlanSteps.insert_or_assign(key, *step); + const int position = firstRow + static_cast(index); + if (planLayout->indexOf(frame) != position) + planLayout->insertWidget(position, frame); + } + setProperty("planTabCommits", + property("planTabCommits").toULongLong() + 1); } void InspectorPane::refreshAgents() { const ui::InspectorAgentsSnapshot &next = currentSnapshot->agents; if (agentsSnapshot && *agentsSnapshot == next) return; + const bool changedThread = + agentsSnapshot && agentsSnapshot->threadId != next.threadId; agentsSnapshot = next; const ui::InspectorAgentsSnapshot &snapshot = *agentsSnapshot; - setUpdatesEnabled(false); - clearLayout(agentsLayout); - if (!snapshot.threadPresent) - agentsLayout->addWidget( - makeLabel(QStringLiteral("No selected thread."), "muted")); - else if (snapshot.agents.empty()) - agentsLayout->addWidget(makeLabel( - QStringLiteral("No agent activity for this thread."), "muted")); - else - for (const ui::InspectorAgentRow &agent : snapshot.agents) - agentsLayout->addWidget(agentFrame(agent)); - agentsLayout->addStretch(); - setUpdatesEnabled(true); + if (changedThread) { + for (auto &[id, frame] : agentFrames) { + static_cast(id); + agentsLayout->removeWidget(frame); + delete frame; + } + agentFrames.clear(); + renderedAgentRows.clear(); + } + + std::unordered_set desired; + desired.reserve(snapshot.agents.size()); + for (const ui::InspectorAgentRow &agent : snapshot.agents) + desired.insert(agent.id); + for (auto iterator = agentFrames.begin(); iterator != agentFrames.end();) { + if (desired.contains(iterator->first)) { + ++iterator; + continue; + } + agentsLayout->removeWidget(iterator->second); + delete iterator->second; + renderedAgentRows.erase(iterator->first); + iterator = agentFrames.erase(iterator); + setProperty("agentRowRemovals", + property("agentRowRemovals").toULongLong() + 1); + } + + if (agentsMessage) { + agentsLayout->removeWidget(agentsMessage); + delete agentsMessage; + agentsMessage = nullptr; + } + if (!snapshot.threadPresent) { + agentsMessage = makeLabel(QStringLiteral("No selected thread."), "muted"); + } else if (snapshot.agents.empty()) { + agentsMessage = makeLabel( + QStringLiteral("No agent activity for this thread."), "muted"); + } + if (agentsMessage) + agentsLayout->insertWidget(0, agentsMessage); + + for (std::size_t index = 0; index < snapshot.agents.size(); ++index) { + const ui::InspectorAgentRow &agent = snapshot.agents[index]; + QFrame *frame = nullptr; + const auto retained = agentFrames.find(agent.id); + if (retained == agentFrames.end()) { + frame = agentFrame(agent); + agentFrames.emplace(agent.id, frame); + } else { + frame = retained->second; + const auto rendered = renderedAgentRows.find(agent.id); + if (rendered == renderedAgentRows.end() || rendered->second != agent) + patchAgentFrame(frame, agent); + } + renderedAgentRows.insert_or_assign(agent.id, agent); + if (agentsLayout->indexOf(frame) != static_cast(index)) + agentsLayout->insertWidget(static_cast(index), frame); + } + setProperty("agentsTabCommits", + property("agentsTabCommits").toULongLong() + 1); } void InspectorPane::refreshChanges() { @@ -677,85 +1028,48 @@ void InspectorPane::refreshRequests() { requestsSnapshot = next; const std::vector &snapshot = requestsSnapshot->requests; - setUpdatesEnabled(false); - clearLayout(requestsLayout); - for (const ui::InspectorRequestRow &request : snapshot) { - auto *frame = new QFrame; - frame->setProperty("kind", "raised"); - frame->setProperty("tone", "warning"); - auto *layout = new QVBoxLayout(frame); - layout->setContentsMargins(12, 10, 12, 10); - layout->setSpacing(6); - layout->addWidget( - makeLabel(UiStyle::humanizeLabel(text(request.kind)), "title")); - layout->addWidget( - makeLabel(QStringLiteral("thread %1 | generation %2 | request %3") - .arg(text(request.threadContext)) - .arg(static_cast(request.generation)) - .arg(text(request.id)), - "meta")); - const auto addMetadata = [layout](const std::string &value, - const char *prefix) { - const QString displayed = text(value); - if (!displayed.isEmpty()) - layout->addWidget( - makeLabel(QString::fromLatin1(prefix) + displayed, "meta")); - }; - addMetadata(request.command, "Command: "); - addMetadata(request.reason, "Reason: "); - addMetadata(request.message, ""); - if (request.questionCount) - layout->addWidget( - makeLabel(QStringLiteral("%1 questions") - .arg(static_cast(*request.questionCount)), - "meta")); - if (request.command.empty() && request.reason.empty() && - request.message.empty() && !request.questionCount) - layout->addWidget( - makeLabel(QStringLiteral("Request %1 needs a decision.") - .arg(text(request.id)), - "meta")); - auto *actions = new QHBoxLayout; - actions->setContentsMargins(0, 2, 0, 0); - auto *reject = new QPushButton(QStringLiteral("Reject")); - reject->setProperty("kind", "destructive"); - reject->setFixedHeight(28); - reject->setEnabled(request.actionable); - connect(reject, &QPushButton::clicked, this, [this, id = request.id] { - if (rejectRequest) - rejectRequest(id); - }); - actions->addStretch(); - actions->addWidget(reject); - if (supportsDirectAccept(request.kind)) { - auto *accept = new QPushButton(directAcceptText(request.kind)); - accept->setProperty("kind", "request"); - accept->setFixedHeight(28); - accept->setEnabled(request.actionable); - connect(accept, &QPushButton::clicked, this, [this, id = request.id] { - if (acceptRequest) - acceptRequest(id); - }); - actions->addWidget(accept); + std::unordered_set desired; + for (const ui::InspectorRequestRow &request : snapshot) + desired.insert(request.id); + for (auto iterator = requestFrames.begin(); iterator != requestFrames.end();) { + if (desired.contains(iterator->first)) { + ++iterator; + continue; + } + requestsLayout->removeWidget(iterator->second); + delete iterator->second; + renderedRequests.erase(iterator->first); + iterator = requestFrames.erase(iterator); + } + if (requestsMessage) { + requestsLayout->removeWidget(requestsMessage); + delete requestsMessage; + requestsMessage = nullptr; + } + if (snapshot.empty()) { + requestsMessage = + makeLabel(QStringLiteral("No pending requests."), "muted"); + requestsLayout->insertWidget(0, requestsMessage); + } + for (std::size_t index = 0; index < snapshot.size(); ++index) { + const ui::InspectorRequestRow &request = snapshot[index]; + QFrame *frame = nullptr; + const auto retained = requestFrames.find(request.id); + if (retained == requestFrames.end()) { + frame = requestFrame(request); + requestFrames.emplace(request.id, frame); } else { - auto *review = new QPushButton(QStringLiteral("Review")); - review->setProperty("kind", "request"); - review->setFixedHeight(28); - review->setEnabled(request.actionable); - connect(review, &QPushButton::clicked, this, [this, id = request.id] { - if (reviewRequest) - reviewRequest(id); - }); - actions->addWidget(review); + frame = retained->second; + const auto rendered = renderedRequests.find(request.id); + if (rendered == renderedRequests.end() || rendered->second != request) + patchRequestFrame(frame, request); } - layout->addLayout(actions); - requestsLayout->addWidget(frame); + renderedRequests.insert_or_assign(request.id, request); + if (requestsLayout->indexOf(frame) != static_cast(index)) + requestsLayout->insertWidget(static_cast(index), frame); } - if (snapshot.empty()) - requestsLayout->addWidget( - makeLabel(QStringLiteral("No pending requests."), "muted")); - requestsLayout->addStretch(); - setUpdatesEnabled(true); + setProperty("requestsTabCommits", + property("requestsTabCommits").toULongLong() + 1); } void InspectorPane::refreshState() { @@ -786,7 +1100,8 @@ void InspectorPane::refreshProtocolStats() { .arg(static_cast(snapshot.selectedThreadTurnCount)) .arg(static_cast(snapshot.selectedThreadItemCount)) .arg(static_cast(snapshot.pendingRequestCount)) - .arg(static_cast(snapshot.telemetryCount)); + .arg(static_cast( + std::max(snapshot.telemetryCount, protocolTelemetryCount))); if (value.toUtf8() == protocolStatsSnapshot) return; protocolStatsSnapshot = value.toUtf8(); @@ -823,61 +1138,137 @@ void InspectorPane::restoreProtocolScroll(bool followsTail, int pausedValue) { } void InspectorPane::appendProtocolFrame(const nlohmann::json &frame) { - const auto record = [this](QString line) { - if (protocolLines.size() >= MaximumProtocolLines) + nodegraph::UiEffect effect; + effect.kind = nodegraph::UiEffectKind::ProtocolDiagnostic; + const auto copyUnsigned = [&frame, &effect](const char *from, + const char *to) { + const auto found = frame.find(from); + if (found != frame.end() && found->is_number_unsigned()) + effect.details.emplace(to, nodegraph::Value(found->get())); + }; + const auto copyString = [&frame, &effect](const char *from, + const char *to) { + const auto found = frame.find(from); + if (found != frame.end() && found->is_string()) + effect.details.emplace(to, nodegraph::Value(found->get())); + }; + copyUnsigned("sequence", "sequence"); + copyUnsigned("generation", "connectionGeneration"); + copyString("kind", "direction"); + copyString(frame.value("kind", std::string{}) == "result" ? "action" + : "type", + "subject"); + copyString("authority", "authority"); + copyString("correlationId", "correlation"); + const auto scope = frame.find("scope"); + if (scope != frame.end() && scope->is_object()) { + for (const char *key : + {"threadId", "turnId", "itemId", "requestId", "processId"}) { + const auto found = scope->find(key); + if (found != scope->end() && found->is_string()) + effect.details.emplace(key, + nodegraph::Value(found->get())); + } + } + if (frame.value("kind", std::string{}) == "result") { + effect.details.emplace("outcome", + nodegraph::Value(frame.value("ok", false) + ? "ok" + : "ERROR")); + const auto error = frame.find("error"); + if (error != frame.end() && error->is_object()) { + const auto message = error->find("message"); + if (message != error->end() && message->is_string()) + effect.details.emplace( + "error", nodegraph::Value(message->get())); + } + } + appendProtocolDiagnostic(effect); +} + +void InspectorPane::appendProtocolDiagnostic( + const nodegraph::UiEffect &effect) { + if (effect.kind != nodegraph::UiEffectKind::ProtocolDiagnostic) + return; + const QString timestamp = + QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")); + std::vector recorded; + const auto record = [this, &recorded](QString line) { + while (protocolLines.size() >= MaximumProtocolLines) protocolLines.pop_front(); protocolLines.push_back(line); + recorded.push_back(std::move(line)); + }; + const std::optional sequence = + graphUnsigned(graphField(effect.details, "sequence")); + if (sequence && *sequence != 0) { + if (observedSequence != 0 && *sequence != observedSequence + 1) { + record(QStringLiteral("[%1] %2 expected=%3 received=%4") + .arg(timestamp, *sequence <= observedSequence + ? QStringLiteral("NON-MONOTONIC") + : QStringLiteral("SEQUENCE GAP")) + .arg(observedSequence + 1) + .arg(*sequence)); + } + observedSequence = std::max(observedSequence, *sequence); + } + if (const auto dropped = + graphUnsigned(graphField(effect.details, "droppedBefore")); + dropped && *dropped != 0) + record(QStringLiteral("[%1] DROPPED %2 DIAGNOSTICS before #%3") + .arg(timestamp) + .arg(*dropped) + .arg(sequence.value_or(0))); + + QStringList parts{QStringLiteral("[%1]").arg(timestamp)}; + if (sequence && *sequence != 0) + parts << QStringLiteral("#%1").arg(*sequence); + if (const auto connection = + graphUnsigned(graphField(effect.details, "connectionGeneration"))) + parts << QStringLiteral("g%1").arg(*connection); + if (const auto provider = + graphUnsigned(graphField(effect.details, "providerGeneration"))) + parts << QStringLiteral("p%1").arg(*provider); + for (std::string_view key : {"direction", "subject", "source"}) { + const QString value = protocolMetadata(graphField(effect.details, key)); + if (!value.isEmpty()) + parts << value; + } + for (std::string_view key : + {"authority", "outcome", "threadId", "turnId", "itemId", + "requestId", "processId", "connectionId", "targetId", "role", + "state", "event", "correlation", "error", "errorCategory", + "errorCode"}) { + const QString value = protocolMetadata(graphField(effect.details, key)); + if (!value.isEmpty()) + parts << QStringLiteral("%1=%2").arg(text(key), value); + } + record(parts.join(QStringLiteral(" "))); + + const std::string direction = + graphString(graphField(effect.details, "direction")); + const std::string authority = + graphString(graphField(effect.details, "authority")); + if (authority == "none" && + (direction.find("notification") != std::string::npos || + direction.find("event") != std::string::npos || + direction.ends_with("frame"))) + protocolTelemetryCount = std::min( + 256, protocolTelemetryCount + 1); + + const bool visibleProtocol = + isVisible() && inspectorTabs->currentIndex() == 4 && + infoStack->currentIndex() == ProtocolPage; + if (visibleProtocol) { const ScrollPosition position{protocolFollowsTail, protocolPausedScrollValue}; mutatingProtocolLog = true; - protocolLog->appendPlainText(line); + for (const QString &line : recorded) + protocolLog->appendPlainText(line); restoreProtocolScroll(position.followsTail, position.value); - }; - const std::uint64_t sequence = frame.value("sequence", 0ULL); - if (sequence != 0) { - if (observedSequence != 0 && sequence != observedSequence + 1) { - record(QStringLiteral("[%1] %2 expected=%3 received=%4") - .arg(QDateTime::currentDateTime().toString( - QStringLiteral("HH:mm:ss.zzz")), - sequence <= observedSequence - ? QStringLiteral("NON-MONOTONIC") - : QStringLiteral("SEQUENCE GAP")) - .arg(static_cast(observedSequence + 1)) - .arg(static_cast(sequence))); - } - observedSequence = std::max(observedSequence, sequence); - } - const std::string kind = stringValue(frame, "kind"); - const std::string subject = kind == "result" ? stringValue(frame, "action") - : stringValue(frame, "type"); - const nlohmann::json scope = frame.value("scope", nlohmann::json::object()); - QStringList parts{QStringLiteral("[%1]").arg( - QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")))}; - if (sequence != 0) - parts << QStringLiteral("#%1").arg(static_cast(sequence)); - parts << QStringLiteral("g%1").arg( - static_cast(frame.value("generation", 0ULL))) - << text(kind) << text(subject) << text(stringValue(frame, "authority")); - if (kind == "result") - parts << (frame.value("ok", false) ? QStringLiteral("ok") - : QStringLiteral("ERROR")); - for (const char *key : - {"threadId", "turnId", "itemId", "requestId", "processId"}) { - const std::string value = stringValue(scope, key); - if (!value.empty()) - parts << QStringLiteral("%1=%2").arg(QString::fromLatin1(key), - text(value)); - } - const std::string correlation = stringValue(frame, "correlationId"); - if (!correlation.empty()) - parts << QStringLiteral("correlation=%1").arg(text(correlation)); - if (kind == "result" && !frame.value("ok", false)) { - const std::string message = - stringValue(frame.value("error", nlohmann::json::object()), "message"); - if (!message.empty()) - parts << text(message); + if (currentSnapshot) + refreshProtocolStats(); } - record(parts.join(QStringLiteral(" "))); } } // namespace codexui::codex::middle diff --git a/src/codex/middle/InspectorPane.h b/src/codex/middle/InspectorPane.h index 480a746..9d6c31b 100644 --- a/src/codex/middle/InspectorPane.h +++ b/src/codex/middle/InspectorPane.h @@ -4,6 +4,7 @@ #define CODEXUI_CODEX_MIDDLE_INSPECTORPANE_H #include "codex/ui/UiViewState.h" +#include "codex/nodegraph/Messages.h" #include #include @@ -17,11 +18,13 @@ #include #include #include +#include #include #include class QLabel; class QPlainTextEdit; +class QShowEvent; class QStackedWidget; class QTabWidget; class QVBoxLayout; @@ -42,15 +45,29 @@ class InspectorPane final : public QFrame { explicit InspectorPane(QWidget *parent = nullptr); void setHideAction(std::function hide); + void setRefreshRequestedAction(std::function refresh); void setRequestActions(RequestAction review, RequestAction accept, RequestAction reject); void refresh(const ui::InspectorSnapshot &snapshot); + void refresh(const ui::InspectorSnapshot &snapshot, + ui::InspectorProjection projection); void appendProtocolFrame(const nlohmann::json &frame); + void appendProtocolDiagnostic(const nodegraph::UiEffect &effect); [[nodiscard]] QTabWidget *tabs() const noexcept { return inspectorTabs; } +protected: + void showEvent(QShowEvent *event) override; + private: QFrame *agentFrame(const ui::InspectorAgentRow &agent); + void patchAgentFrame(QFrame *frame, const ui::InspectorAgentRow &agent); + QFrame *planStepFrame(const ui::InspectorPlanStep &step); + void patchPlanStepFrame(QFrame *frame, + const ui::InspectorPlanStep &step); + QFrame *requestFrame(const ui::InspectorRequestRow &request); + void patchRequestFrame(QFrame *frame, + const ui::InspectorRequestRow &request); void refreshCurrentTab(); void refreshPlan(); void refreshAgents(); @@ -66,6 +83,7 @@ class InspectorPane final : public QFrame { RequestAction acceptRequest; RequestAction rejectRequest; std::function hideAction; + std::function refreshRequested; QTabWidget *inspectorTabs = nullptr; QStackedWidget *infoStack = nullptr; @@ -81,13 +99,24 @@ class InspectorPane final : public QFrame { QLabel *protocolStats = nullptr; std::optional planSnapshot; + std::unordered_map planFrames; + std::unordered_map renderedPlanSteps; + QWidget *planExplanation = nullptr; + QWidget *planMessage = nullptr; std::optional agentsSnapshot; + std::unordered_map agentFrames; + std::unordered_map renderedAgentRows; + QWidget *agentsMessage = nullptr; std::unordered_set expandedAgents; std::optional requestsSnapshot; + std::unordered_map requestFrames; + std::unordered_map renderedRequests; + QWidget *requestsMessage = nullptr; QByteArray stateSnapshot; QByteArray protocolStatsSnapshot; std::deque protocolLines; std::uint64_t observedSequence = 0; + std::size_t protocolTelemetryCount = 0; bool protocolFollowsTail = true; bool mutatingProtocolLog = false; int protocolPausedScrollValue = 0; diff --git a/src/codex/middle/MiddleRegionWidget.cpp b/src/codex/middle/MiddleRegionWidget.cpp index b88405b..6b56ff9 100644 --- a/src/codex/middle/MiddleRegionWidget.cpp +++ b/src/codex/middle/MiddleRegionWidget.cpp @@ -370,6 +370,14 @@ QSplitter *MiddleRegionWidget::splitterWidget() const noexcept { void MiddleRegionWidget::setThreadHeading(QString title, QString metadata, QString trailingMetadata, QString state, QString stateTone) { + const bool separatorVisible = !state.isEmpty(); + if (conversationTitle->text() == title && + conversationMetadata->text() == metadata && + conversationTrailingMetadata->text() == trailingMetadata && + conversationState->text() == state && + conversationState->property("tone").toString() == stateTone && + conversationStateSeparator->isVisible() == separatorVisible) + return; if (conversationTitle->text() != title) conversationTitle->setText(std::move(title)); if (conversationMetadata->text() != metadata) @@ -378,7 +386,7 @@ void MiddleRegionWidget::setThreadHeading(QString title, QString metadata, conversationTrailingMetadata->setText(std::move(trailingMetadata)); if (conversationState->text() != state) conversationState->setText(std::move(state)); - conversationStateSeparator->setVisible(!conversationState->text().isEmpty()); + conversationStateSeparator->setVisible(separatorVisible); if (conversationState->property("tone").toString() != stateTone) { conversationState->setProperty("tone", std::move(stateTone)); conversationState->style()->unpolish(conversationState); diff --git a/src/codex/middle/MiddleRegionWidget.h b/src/codex/middle/MiddleRegionWidget.h index 7185716..ba0b1c9 100644 --- a/src/codex/middle/MiddleRegionWidget.h +++ b/src/codex/middle/MiddleRegionWidget.h @@ -21,9 +21,9 @@ class ConversationView; class InspectorPane; class ThreadPane; -// The sole geometry owner for the three-pane workspace. Protocol and domain -// decisions remain behind UiSession; this class owns only visible layout and -// wheel routing across the complete center strip. +// The sole geometry owner for the three-pane workspace. Protocol and graph +// updates remain on the worker; this class owns only visible layout and wheel +// routing across the complete center strip. class MiddleRegionWidget final : public QWidget { public: explicit MiddleRegionWidget(QWidget *parent = nullptr); @@ -35,8 +35,8 @@ class MiddleRegionWidget final : public QWidget { [[nodiscard]] QSplitter *splitterWidget() const noexcept; void setThreadHeading(QString title, QString metadata, - QString trailingMetadata = {}, - QString state = {}, QString stateTone = {}); + QString trailingMetadata = {}, QString state = {}, + QString stateTone = {}); void showNotice(QString message, bool error = true); void showSidebar(bool visible); void showInspector(bool visible); @@ -65,6 +65,7 @@ class MiddleRegionWidget final : public QWidget { QToolButton *updateVisibility = nullptr; QToolButton *commandInitialFolding = nullptr; QToolButton *imageInitialFolding = nullptr; + QToolButton *fileChangesInitialFolding = nullptr; QFrame *noticeBar = nullptr; QLabel *noticeLabel = nullptr; QTimer *noticeTimer = nullptr; diff --git a/src/codex/middle/MiddleTypes.h b/src/codex/middle/MiddleTypes.h index 8d42253..1edc0fb 100644 --- a/src/codex/middle/MiddleTypes.h +++ b/src/codex/middle/MiddleTypes.h @@ -3,6 +3,8 @@ #ifndef CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H #define CODEXUI_CODEX_MIDDLE_MIDDLETYPES_H +#include "codex/nodegraph/NodeGraph.h" + #include #include @@ -123,6 +125,9 @@ struct FileChangeData { struct FileChangesData { std::string status; std::vector changes; + // Relative provider paths are resolved against the owning thread's current + // workspace only when the user explicitly asks the desktop to open them. + std::string cwd; bool operator==(const FileChangesData &) const = default; }; @@ -154,6 +159,7 @@ struct GenericActivityData { std::string type; nlohmann::json raw = nlohmann::json::object(); std::string status; + std::string displayDetail; bool operator==(const GenericActivityData &) const = default; }; @@ -165,6 +171,8 @@ struct LocalPromptData { bool showPendingAnimation = false; std::string error; std::vector imagePaths; + std::optional admittedAtMs; + bool requiresExplicitRecovery = false; bool operator==(const LocalPromptData &) const = default; }; @@ -181,6 +189,10 @@ struct VisibleCardData { std::string turnId; std::string itemId; CardPayload payload = GenericActivityData{}; + std::optional activeWork; + // Stable action/lifetime identity supplied by the adapter. Widgets retain + // it but never inspect graph state through it. + nodegraph::NodeRef target; bool operator==(const VisibleCardData &) const = default; }; diff --git a/src/codex/middle/PromptCoordinator.cpp b/src/codex/middle/PromptCoordinator.cpp deleted file mode 100644 index f982a1e..0000000 --- a/src/codex/middle/PromptCoordinator.cpp +++ /dev/null @@ -1,487 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/middle/PromptCoordinator.h" - -#include -#include -#include -#include - -namespace codexui::codex::middle { -namespace { - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto value = object.find(key); - return value != object.end() && value->is_string() ? value->get() - : std::string{}; -} - -std::string userMessageText(const nlohmann::json &item) { - std::string result; - const auto content = item.find("content"); - if (content != item.end() && content->is_array()) { - for (const nlohmann::json &entry : *content) { - const std::string value = stringValue(entry, "text"); - if (value.empty()) - continue; - if (!result.empty()) - result.push_back('\n'); - result += value; - } - } - if (result.empty()) { - const std::string value = stringValue(item, "text"); - if (!value.empty()) - result = value; - } - return result; -} - -std::string markdownLinkLabel(std::string_view label) { - std::string result; - result.reserve(label.size()); - for (const char character : label) { - if (character == '\\' || character == '[' || character == ']') - result.push_back('\\'); - result.push_back(character == '\r' || character == '\n' ? ' ' : character); - } - return result; -} - -bool urlPathByteAllowed(unsigned char byte) noexcept { - const bool alphanumeric = (byte >= 'a' && byte <= 'z') || - (byte >= 'A' && byte <= 'Z') || - (byte >= '0' && byte <= '9'); - return alphanumeric || byte == '-' || byte == '.' || byte == '_' || - byte == '~' || byte == '/' || byte == ':' || byte == '@' || - byte == '!' || byte == '$' || byte == '&' || byte == '\'' || - byte == '*' || byte == '+' || byte == ',' || byte == ';' || - byte == '='; -} - -std::string localFileUrl(std::string_view path) { - static constexpr char Hex[] = "0123456789ABCDEF"; - std::string result = path.starts_with('/') ? "file://" : "file:"; - result.reserve(result.size() + path.size()); - for (const unsigned char byte : path) { - if (urlPathByteAllowed(byte)) { - result.push_back(static_cast(byte)); - continue; - } - result.push_back('%'); - result.push_back(Hex[byte >> 4]); - result.push_back(Hex[byte & 0x0f]); - } - return result; -} - -} // namespace - -std::optional AuthoritativeItemIndex::position( - const AuthoritativeItemKey &key) const noexcept { - const auto found = positions.find(key); - return found == positions.end() ? std::nullopt - : std::optional{found->second}; -} - -AuthoritativeItemIndex -indexAuthoritativeItems(const std::string &threadId, - const ThreadPresentation *thread) { - AuthoritativeItemIndex result; - result.threadId = threadId; - if (!thread) - return result; - for (const std::string &turnId : thread->turnOrder) { - const auto turn = thread->turns.find(turnId); - if (turn == thread->turns.end()) - continue; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); - if (item == turn->second.items.end()) - continue; - const std::size_t position = result.ordered.size(); - result.ordered.push_back( - {AuthoritativeItemKey{threadId, turnId, itemId}, &item->second}); - result.positions.emplace(result.ordered.back().key, position); - if (stringValue(item->second.raw, "type") == "userMessage") { - result.turnRootUserMessagePositions.try_emplace(turnId, position); - const std::string clientId = stringValue(item->second.raw, "clientId"); - if (!clientId.empty()) - result.userMessagesByClientId.try_emplace(clientId, position); - const std::string content = - trimUnicodeWhitespace(userMessageText(item->second.raw)); - result.userMessagesByText.emplace(std::string{}, content, position); - result.userMessagesByText.emplace(turnId, content, position); - } - } - } - return result; -} - -std::string promptWithFileLinks(std::string prompt, - std::span attachments) { - std::vector links; - for (const AttachmentDraft &attachment : attachments) { - if (attachment.mimeType.starts_with("image/") || - attachment.mimeType.starts_with("audio/")) - continue; - links.push_back("- [" + markdownLinkLabel(attachment.name) + "](" + - localFileUrl(attachment.path) + ')'); - } - if (links.empty()) - return prompt; - prompt += "\n\nAttached files:\n"; - for (std::size_t index = 0; index < links.size(); ++index) { - if (index != 0) - prompt.push_back('\n'); - prompt += links[index]; - } - return prompt; -} - -bool PromptSubmission::localCardVisible() const noexcept { - return state == PromptState::Queued || state == PromptState::InFlight || - state == PromptState::Failed || !materializedItem; -} - -std::uint64_t PromptCoordinator::admit( - std::string threadId, std::string prompt, - std::vector attachments, nlohmann::json turnOptions, - const ThreadPresentation *authoritativeThread, - std::optional activeTurnId, std::int64_t nowMilliseconds) { - PromptSubmission submission; - submission.id = nextSubmissionId++; - submission.admissionOrdinal = nextAdmissionOrdinal++; - submission.threadId = std::move(threadId); - submission.clientUserMessageId = "codexui-" + - std::to_string(nowMilliseconds) + '-' + - std::to_string(submission.id); - submission.prompt = std::move(prompt); - submission.attachments = std::move(attachments); - submission.turnOptions = std::move(turnOptions); - submission.admittedAtMilliseconds = nowMilliseconds; - submission.expectedTurnId = std::move(activeTurnId); - - if (authoritativeThread) { - const auto items = - indexAuthoritativeItems(submission.threadId, authoritativeThread); - if (!items.ordered.empty()) - submission.admissionAnchor = items.ordered.back().key; - } - - const std::uint64_t id = submission.id; - byThread[submission.threadId].push_back(std::move(submission)); - return id; -} - -std::optional -PromptCoordinator::beginNext(const std::string &threadId, - std::optional activeTurnId) { - auto found = byThread.find(threadId); - if (found == byThread.end()) - return std::nullopt; - if (std::any_of(found->second.begin(), found->second.end(), - [](const PromptSubmission &submission) { - return submission.state == PromptState::InFlight; - })) - return std::nullopt; - auto next = std::find_if(found->second.begin(), found->second.end(), - [](const PromptSubmission &submission) { - return submission.state == PromptState::Queued; - }); - if (next == found->second.end()) - return std::nullopt; - next->admissionAtStart = !next->admissionAnchor; - next->startsTurn = !activeTurnId; - next->state = PromptState::InFlight; - // Start versus steer is an operation-time fact. A turn which was active - // when the prompt entered the local queue may have completed meanwhile. - next->expectedTurnId = std::move(activeTurnId); - return PromptDispatch{next->id, - next->threadId, - next->clientUserMessageId, - next->prompt, - next->attachments, - next->turnOptions, - next->expectedTurnId}; -} - -bool PromptCoordinator::acknowledge( - const std::string &threadId, std::uint64_t submissionId, - std::optional authoritativeTurnId) { - PromptSubmission *pending = find(threadId, submissionId); - if (!pending || pending->state != PromptState::InFlight) - return false; - pending->state = PromptState::Accepted; - pending->error.clear(); - if (authoritativeTurnId) - pending->expectedTurnId = std::move(authoritativeTurnId); - return true; -} - -bool PromptCoordinator::fail(const std::string &threadId, - std::uint64_t submissionId, std::string error) { - PromptSubmission *pending = find(threadId, submissionId); - if (!pending || (pending->state != PromptState::InFlight && - pending->state != PromptState::Queued)) - return false; - pending->state = PromptState::Failed; - pending->error = std::move(error); - return true; -} - -bool PromptCoordinator::requeue(const std::string &threadId, - std::uint64_t submissionId) { - PromptSubmission *pending = find(threadId, submissionId); - if (!pending || pending->state != PromptState::InFlight) - return false; - pending->state = PromptState::Queued; - pending->admissionAtStart = false; - return true; -} - -std::size_t PromptCoordinator::failQueued(const std::string &threadId, - const std::string &error) { - auto found = byThread.find(threadId); - if (found == byThread.end()) - return 0; - std::size_t count = 0; - for (PromptSubmission &submission : found->second) { - if (submission.state != PromptState::Queued) - continue; - submission.state = PromptState::Failed; - submission.error = error; - ++count; - } - return count; -} - -bool PromptCoordinator::reassignThread(const std::string &fromThreadId, - const std::string &toThreadId) { - if (fromThreadId == toThreadId) - return true; - const auto reassignAliases = [this, &fromThreadId, &toThreadId] { - auto aliases = visualAliasesByThread.find(fromThreadId); - if (aliases == visualAliasesByThread.end()) - return; - auto moved = std::move(aliases->second); - visualAliasesByThread.erase(aliases); - auto &target = visualAliasesByThread[toThreadId]; - for (auto &[key, alias] : moved) { - AuthoritativeItemKey reassigned = key; - reassigned.threadId = toThreadId; - if (alias.admissionAnchor) - alias.admissionAnchor->threadId = toThreadId; - target.insert_or_assign(std::move(reassigned), std::move(alias)); - } - }; - auto source = byThread.find(fromThreadId); - if (source == byThread.end()) { - reassignAliases(); - return true; - } - auto destination = byThread.find(toThreadId); - const bool sourceInFlight = - std::any_of(source->second.begin(), source->second.end(), - [](const PromptSubmission &submission) { - return submission.state == PromptState::InFlight; - }); - const bool destinationInFlight = - destination != byThread.end() && - std::any_of(destination->second.begin(), destination->second.end(), - [](const PromptSubmission &submission) { - return submission.state == PromptState::InFlight; - }); - if (sourceInFlight && destinationInFlight) - return false; - - std::vector moved = std::move(source->second); - byThread.erase(source); - for (PromptSubmission &submission : moved) { - submission.threadId = toThreadId; - if (submission.admissionAnchor) - submission.admissionAnchor->threadId = toThreadId; - if (submission.materializedItem) - submission.materializedItem->threadId = toThreadId; - } - auto &target = byThread[toThreadId]; - target.insert(target.end(), std::make_move_iterator(moved.begin()), - std::make_move_iterator(moved.end())); - std::ranges::sort(target, {}, &PromptSubmission::admissionOrdinal); - reassignAliases(); - return true; -} - -void PromptCoordinator::reconcile(const std::string &threadId, - const ThreadPresentation &authoritativeThread) { - auto authoritativeItems = - indexAuthoritativeItems(threadId, &authoritativeThread); - reconcile(threadId, authoritativeItems); -} - -void PromptCoordinator::reconcile(const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems) { - applyVisualAliases(threadId, authoritativeItems); - auto found = byThread.find(threadId); - if (found == byThread.end()) - return; - std::vector claimed(authoritativeItems.ordered.size()); - for (std::size_t index = 0; index < authoritativeItems.ordered.size(); - ++index) - if (authoritativeItems.ordered[index].promptAlias) - claimed[index] = true; - for (const PromptSubmission &submission : found->second) - if (submission.materializedItem) { - const auto position = - authoritativeItems.position(*submission.materializedItem); - if (position) - claimed[*position] = true; - } - - for (PromptSubmission &submission : found->second) { - if (submission.materializedItem) - continue; - if (!submission.admissionAnchor && - submission.state == PromptState::Queued && - !authoritativeItems.ordered.empty()) { - submission.admissionAnchor = authoritativeItems.ordered.back().key; - submission.admissionAtStart = false; - } - - const auto exact = authoritativeItems.userMessagesByClientId.find( - submission.clientUserMessageId); - if (exact == authoritativeItems.userMessagesByClientId.end() || - claimed[exact->second]) - continue; - const AuthoritativeItemKey &key = - authoritativeItems.ordered[exact->second].key; - submission.materializedItem = key; - submission.expectedTurnId = key.turnId; - claimed[exact->second] = true; - } - - for (PromptSubmission &submission : found->second) { - if (submission.materializedItem) - continue; - - // Semantic acknowledgement remains callback-only. Without protocol - // client-id support, do not guess from text before that callback arrives. - if (submission.state != PromptState::Accepted) - continue; - - std::size_t firstCandidate = 0; - if (submission.admissionAnchor) { - const auto anchor = - authoritativeItems.position(*submission.admissionAnchor); - if (anchor) - firstCandidate = *anchor + 1; - } - - const std::string turnId = - submission.expectedTurnId.value_or(std::string{}); - const std::string prompt = trimUnicodeWhitespace(submission.prompt); - auto candidate = authoritativeItems.userMessagesByText.lower_bound( - {turnId, prompt, firstCandidate}); - while (candidate != authoritativeItems.userMessagesByText.end() && - std::get<0>(*candidate) == turnId && - std::get<1>(*candidate) == prompt && - claimed[std::get<2>(*candidate)]) - candidate = authoritativeItems.userMessagesByText.erase(candidate); - if (candidate == authoritativeItems.userMessagesByText.end() || - std::get<0>(*candidate) != turnId || std::get<1>(*candidate) != prompt) - continue; - const std::size_t index = std::get<2>(*candidate); - const AuthoritativeItemKey &key = authoritativeItems.ordered[index].key; - submission.materializedItem = key; - if (!submission.expectedTurnId) - submission.expectedTurnId = key.turnId; - claimed[index] = true; - authoritativeItems.userMessagesByText.erase(candidate); - } - for (const PromptSubmission &submission : found->second) { - if (submission.state != PromptState::Accepted || - !submission.materializedItem) - continue; - visualAliasesByThread[threadId].insert_or_assign( - *submission.materializedItem, - PromptVisualAlias{LocalPromptKey{submission.id}, - submission.admissionAnchor, - submission.admissionOrdinal}); - } - std::erase_if(found->second, [](const auto &submission) { - return submission.state == PromptState::Accepted && - submission.materializedItem; - }); - applyVisualAliases(threadId, authoritativeItems); -} - -std::span -PromptCoordinator::submissions(const std::string &threadId) const noexcept { - const auto found = byThread.find(threadId); - if (found == byThread.end()) - return {}; - return found->second; -} - -const PromptSubmission * -PromptCoordinator::submission(const std::string &threadId, - std::uint64_t submissionId) const noexcept { - const auto found = byThread.find(threadId); - if (found == byThread.end()) - return nullptr; - const auto candidate = - std::ranges::find(found->second, submissionId, &PromptSubmission::id); - return candidate == found->second.end() ? nullptr : &*candidate; -} - -bool PromptCoordinator::hasInFlight( - const std::string &threadId) const noexcept { - const auto pending = submissions(threadId); - return std::ranges::any_of(pending, [](const PromptSubmission &submission) { - return submission.state == PromptState::InFlight; - }); -} - -std::vector PromptCoordinator::queuedThreadIds() const { - std::vector result; - for (const auto &[threadId, submissions] : byThread) { - if (std::ranges::any_of(submissions, - [](const PromptSubmission &submission) { - return submission.state == PromptState::Queued; - })) - result.push_back(threadId); - } - return result; -} - -void PromptCoordinator::clearThread(const std::string &threadId) { - byThread.erase(threadId); - visualAliasesByThread.erase(threadId); -} - -void PromptCoordinator::applyVisualAliases( - const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems) const { - const auto aliases = visualAliasesByThread.find(threadId); - if (aliases == visualAliasesByThread.end()) - return; - for (const auto &[key, alias] : aliases->second) { - const auto position = authoritativeItems.position(key); - if (position) - authoritativeItems.ordered[*position].promptAlias = alias; - } -} - -PromptSubmission *PromptCoordinator::find(const std::string &threadId, - std::uint64_t submissionId) noexcept { - auto found = byThread.find(threadId); - if (found == byThread.end()) - return nullptr; - auto candidate = - std::ranges::find(found->second, submissionId, &PromptSubmission::id); - return candidate == found->second.end() ? nullptr : &*candidate; -} - -} // namespace codexui::codex::middle diff --git a/src/codex/middle/PromptCoordinator.h b/src/codex/middle/PromptCoordinator.h deleted file mode 100644 index 5fc00b8..0000000 --- a/src/codex/middle/PromptCoordinator.h +++ /dev/null @@ -1,154 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H -#define CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H - -#include "codex/AttachmentDraft.h" -#include "codex/PresentationModel.h" -#include "codex/middle/MiddleTypes.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::codex::middle { - -[[nodiscard]] std::string -promptWithFileLinks(std::string prompt, - std::span attachments); - -struct PromptSubmission { - std::uint64_t id = 0; - std::uint64_t admissionOrdinal = 0; - std::string threadId; - std::string clientUserMessageId; - std::string prompt; - std::vector attachments; - nlohmann::json turnOptions = nlohmann::json::object(); - PromptState state = PromptState::Queued; - std::int64_t admittedAtMilliseconds = 0; - std::string error; - std::optional admissionAnchor; - bool admissionAtStart = false; - bool startsTurn = false; - std::optional expectedTurnId; - std::optional materializedItem; - - [[nodiscard]] bool localCardVisible() const noexcept; -}; - -struct PromptDispatch { - std::uint64_t id = 0; - std::string threadId; - std::string clientUserMessageId; - std::string prompt; - std::vector attachments; - nlohmann::json turnOptions = nlohmann::json::object(); - std::optional expectedTurnId; -}; - -struct PromptVisualAlias { - LocalPromptKey key; - std::optional admissionAnchor; - std::uint64_t admissionOrdinal = 0; -}; - -struct AuthoritativeItem { - AuthoritativeItemKey key; - const ItemPresentation *presentation = nullptr; - std::optional promptAlias; -}; - -struct AuthoritativeItemIndex { - std::string threadId; - std::vector ordered; - std::map positions; - std::unordered_map userMessagesByClientId; - std::set> - userMessagesByText; - std::unordered_map turnRootUserMessagePositions; - - [[nodiscard]] std::optional - position(const AuthoritativeItemKey &key) const noexcept; -}; - -[[nodiscard]] AuthoritativeItemIndex -indexAuthoritativeItems(const std::string &threadId, - const ThreadPresentation *thread); - -// Owns only local submission state. It does not schedule timers and cannot -// infer acknowledgement from presentation events: acknowledge() is intended -// to be called exclusively by the matching turn.start/turn.steer completion. -class PromptCoordinator final { -public: - [[nodiscard]] std::uint64_t - admit(std::string threadId, std::string prompt, - std::vector attachments, nlohmann::json turnOptions, - const ThreadPresentation *authoritativeThread, - std::optional activeTurnId, std::int64_t nowMilliseconds); - - // Starts at most one queued submission for a thread. The active turn is - // sampled at dispatch time because earlier queued submissions may have - // created a turn since admission. - [[nodiscard]] std::optional - beginNext(const std::string &threadId, - std::optional activeTurnId = std::nullopt); - - [[nodiscard]] bool acknowledge(const std::string &threadId, - std::uint64_t submissionId, - std::optional authoritativeTurnId); - [[nodiscard]] bool fail(const std::string &threadId, - std::uint64_t submissionId, std::string error); - [[nodiscard]] bool requeue(const std::string &threadId, - std::uint64_t submissionId); - std::size_t failQueued(const std::string &threadId, const std::string &error); - - // Used when the app-server assigns an id to an explicit New Thread draft. - // LocalPromptKey is unaffected by this move. - [[nodiscard]] bool reassignThread(const std::string &fromThreadId, - const std::string &toThreadId); - - // Correlates prompts with authoritative userMessage items. Exact client ids - // may bind before acknowledgement so the awaiting card is never duplicated; - // the content fallback is used only after the real operation callback. Fully - // resolved submissions are removed immediately while a compact visual alias - // retains the admitted card identity and boundary. - void reconcile(const std::string &threadId, - const ThreadPresentation &authoritativeThread); - void reconcile(const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems); - - [[nodiscard]] std::span - submissions(const std::string &threadId) const noexcept; - [[nodiscard]] const PromptSubmission * - submission(const std::string &threadId, - std::uint64_t submissionId) const noexcept; - [[nodiscard]] bool hasInFlight(const std::string &threadId) const noexcept; - [[nodiscard]] std::vector queuedThreadIds() const; - - void clearThread(const std::string &threadId); - -private: - [[nodiscard]] PromptSubmission *find(const std::string &threadId, - std::uint64_t submissionId) noexcept; - void applyVisualAliases(const std::string &threadId, - AuthoritativeItemIndex &authoritativeItems) const; - - std::map> byThread; - std::map> - visualAliasesByThread; - std::uint64_t nextSubmissionId = 1; - std::uint64_t nextAdmissionOrdinal = 1; -}; - -} // namespace codexui::codex::middle - -#endif // CODEXUI_CODEX_MIDDLE_PROMPTCOORDINATOR_H diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index e1a1843..5ef4f74 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -2,7 +2,7 @@ #include "codex/middle/ThreadPane.h" -#include "codex/PresentationStatus.h" +#include "codex/UiStatus.h" #include "codex/ui/UiStyle.h" #include @@ -214,15 +214,18 @@ void updateRow(QWidget *row, const std::string &threadId, auto *indent = row->findChild(QStringLiteral("threadIndent")); auto *indicator = static_cast( row->findChild(QStringLiteral("threadExpansionIndicator"))); - indent->setFixedWidth(static_cast(depth) * ChildIndent); + const int indentWidth = static_cast(depth) * ChildIndent; + if (indent->width() != indentWidth) + indent->setFixedWidth(indentWidth); indicator->setState(hasChildren, expanded); QString titleText = text(threadTitle); if (titleText.isEmpty()) titleText = text(threadId.substr(0, 12)); if (requestCount != 0) titleText.prepend(QStringLiteral("! ")); - title->setText(titleText); - const PresentationStatus classified = classifyStatus(threadStatus); + if (title->text() != titleText) + title->setText(titleText); + const UiStatus classified = classifyStatus(threadStatus); QString color = QString::fromLatin1(UiStyle::threadInactive); if (optimistic) color = optimisticFailed ? QStringLiteral("#c43d4d") @@ -235,8 +238,10 @@ void updateRow(QWidget *row, const std::string &threadId, color = QString::fromLatin1(UiStyle::green); else if (classified.kind == StatusKind::Failed) color = QString::fromLatin1(UiStyle::red); - dot->setStyleSheet( - QStringLiteral("background:%1;border-radius:5px;").arg(color)); + const QString style = + QStringLiteral("background:%1;border-radius:5px;").arg(color); + if (dot->styleSheet() != style) + dot->setStyleSheet(style); } QWidget *createRow() { @@ -304,6 +309,17 @@ const ui::ThreadListRow *findThread( return nullptr; } +ui::ThreadListRow *findThread(std::vector &roots, + std::string_view id) { + for (ui::ThreadListRow &root : roots) { + if (root.id == id) + return &root; + if (ui::ThreadListRow *found = findThread(root.children, id)) + return found; + } + return nullptr; +} + bool expandAncestors(const ui::ThreadListRow &row, std::string_view id, std::unordered_set &expanded) { if (row.id == id) @@ -663,6 +679,78 @@ void ThreadPane::setContextHighlight(const std::string &threadId, found->second->setData(ContextMenuRole, highlighted); } +bool ThreadPane::applyRowPresentation(const ui::ThreadListRow &row) { + if (!currentSnapshot || !visibleSnapshot || row.id.empty()) + return false; + ui::ThreadListRow *retained = findThread(currentSnapshot->roots, row.id); + if (!retained) + return false; + retained->title = row.title; + retained->cwd = row.cwd; + retained->status = row.status; + retained->createdAt = row.createdAt; + retained->updatedAt = row.updatedAt; + retained->recencyAt = row.recencyAt; + retained->lastActivityAt = row.lastActivityAt; + retained->pending = row.pending; + retained->archived = row.archived; + + const auto visible = std::ranges::find_if( + visibleSnapshot->rows, + [&row](const RenderedThreadRow &candidate) { return candidate.id == row.id; }); + if (visible == visibleSnapshot->rows.end()) + return true; + RenderedThreadRow next = *visible; + next.title = row.title; + next.cwd = row.cwd; + next.status = row.status; + next.lastActivityAt = row.lastActivityAt; + next.pending = row.pending; + if (*visible == next) + return true; + *visible = next; + + const auto found = rows.find(row.id); + if (found == rows.end()) + return false; + QListWidgetItem *item = found->second; + const QString title = text(next.title); + const QString status = text(displayStatus(next.status)); + QStringList accessibleParts{ + title, status, QStringLiteral("level %1").arg(next.depth + 1)}; + if (next.hasChildren) + accessibleParts.push_back(next.expanded ? QStringLiteral("expanded") + : QStringLiteral("collapsed")); + const QString accessible = accessibleParts.join(", "); + if (item->data(Qt::AccessibleTextRole).toString() != accessible) + item->setData(Qt::AccessibleTextRole, accessible); + QStringList details{title, + QStringLiteral("Workspace: %1").arg( + next.cwd.empty() ? QStringLiteral("Unknown") + : text(next.cwd)), + QStringLiteral("Status: %1").arg(status), + QStringLiteral("Last activity: %1") + .arg(activityText(next.lastActivityAt))}; + if (!next.parentId.empty()) { + const ui::ThreadListRow *parent = + findThread(currentSnapshot->roots, next.parentId); + details.push_back(QStringLiteral("Parent: %1").arg( + parent && !parent->title.empty() ? text(parent->title) + : text(next.parentId))); + } + const QString tooltip = details.join(QLatin1Char('\n')); + if (item->toolTip() != tooltip) + item->setToolTip(tooltip); + updateRow(list->itemWidget(item), next.id, next.title, next.status, + next.pending, next.depth, next.hasChildren, next.expanded, + next.optimistic, next.optimisticFailed); + if (QWidget *rowWidget = list->itemWidget(item)) + rowWidget->update(); + setProperty("targetedRowPresentationUpdates", + property("targetedRowPresentationUpdates").toULongLong() + 1); + return true; +} + void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { currentSnapshot = input; const ui::ThreadListSnapshot &view = *currentSnapshot; @@ -719,6 +807,82 @@ void ThreadPane::refresh(const ui::ThreadListSnapshot &input) { appendVisibleThread(next, row, {}, 0, visited); if (visibleSnapshot && *visibleSnapshot == next) return; + + const bool retainedOrder = + visibleSnapshot && visibleSnapshot->rows.size() == next.rows.size() && + std::equal(visibleSnapshot->rows.begin(), visibleSnapshot->rows.end(), + next.rows.begin(), [](const RenderedThreadRow &before, + const RenderedThreadRow &after) { + return before.id == after.id; + }); + if (retainedOrder) { + const RenderedThreadList previous = *visibleSnapshot; + visibleSnapshot = std::move(next); + const RenderedThreadList &snapshot = *visibleSnapshot; + list->blockSignals(true); + for (std::size_t index = 0; index < snapshot.rows.size(); ++index) { + const RenderedThreadRow &before = previous.rows[index]; + const RenderedThreadRow &row = snapshot.rows[index]; + if (before == row) + continue; + const auto found = rows.find(row.id); + if (found == rows.end()) + continue; + QListWidgetItem *item = found->second; + const QString title = text(row.title); + const QString status = text(displayStatus(row.status)); + QStringList accessibleParts{ + title, status, QStringLiteral("level %1").arg(row.depth + 1)}; + if (row.hasChildren) + accessibleParts.push_back(row.expanded ? QStringLiteral("expanded") + : QStringLiteral("collapsed")); + const QString accessible = accessibleParts.join(", "); + if (item->data(Qt::AccessibleTextRole).toString() != accessible) + item->setData(Qt::AccessibleTextRole, accessible); + QStringList details{title, + QStringLiteral("Workspace: %1").arg( + row.cwd.empty() ? QStringLiteral("Unknown") + : text(row.cwd)), + QStringLiteral("Status: %1").arg(status), + QStringLiteral("Last activity: %1").arg( + activityText(row.lastActivityAt))}; + if (!row.parentId.empty()) { + const ui::ThreadListRow *parent = + findThread(currentSnapshot->roots, row.parentId); + details.push_back(QStringLiteral("Parent: %1").arg( + parent && !parent->title.empty() ? text(parent->title) + : text(row.parentId))); + } + const QString tooltip = details.join(QLatin1Char('\n')); + if (item->toolTip() != tooltip) + item->setToolTip(tooltip); + item->setData(DepthRole, static_cast(row.depth)); + item->setData(HasChildrenRole, row.hasChildren); + item->setData(ExpandedRole, row.expanded); + item->setData(ParentIdRole, text(row.parentId)); + item->setData(OptimisticRole, row.optimistic); + item->setData(OptimisticFailedRole, row.optimisticFailed); + updateRow(list->itemWidget(item), row.id, row.title, row.status, + row.pending, row.depth, row.hasChildren, row.expanded, + row.optimistic, row.optimisticFailed); + setProperty("rowPresentationUpdates", + property("rowPresentationUpdates").toULongLong() + 1); + } + if (previous.selectedThreadId != snapshot.selectedThreadId) { + if (snapshot.selectedThreadId.empty()) { + list->clearSelection(); + list->setCurrentRow(-1); + } else if (const auto selected = rows.find(snapshot.selectedThreadId); + selected != rows.end()) { + list->setCurrentItem(selected->second); + } + } + list->blockSignals(false); + return; + } + + setProperty("graphTopologyScansStarted", + property("graphTopologyScansStarted").toULongLong() + 1); visibleSnapshot = std::move(next); const RenderedThreadList &snapshot = *visibleSnapshot; list->blockSignals(true); diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index 38b2c0b..aeed13b 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -44,6 +44,7 @@ class ThreadPane final : public QFrame { void setActions(Actions actions); void refresh(const ui::ThreadListSnapshot &snapshot); + [[nodiscard]] bool applyRowPresentation(const ui::ThreadListRow &row); void beginOptimisticThread(std::string id, std::string title, std::string cwd); void promoteOptimisticThread(const std::string &draftId, diff --git a/src/codex/nodegraph/CMakeLists.txt b/src/codex/nodegraph/CMakeLists.txt new file mode 100644 index 0000000..eb4efac --- /dev/null +++ b/src/codex/nodegraph/CMakeLists.txt @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +add_library( + codexui-nodegraph + Value.cpp + NodeGraph.cpp + ProtocolCatalog.cpp + ProtocolUpdater.cpp + PromptText.cpp + EventFd.cpp + ThreadChannels.cpp + WorkerLogic.cpp +) + +target_compile_features(codexui-nodegraph PUBLIC cxx_std_20) +target_include_directories( + codexui-nodegraph + PUBLIC ${PROJECT_SOURCE_DIR}/src +) diff --git a/src/codex/nodegraph/EventFd.cpp b/src/codex/nodegraph/EventFd.cpp new file mode 100644 index 0000000..fd6ebe0 --- /dev/null +++ b/src/codex/nodegraph/EventFd.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/EventFd.h" + +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { +namespace { + +int currentError() noexcept { return errno != 0 ? errno : EIO; } + +} // namespace + +bool EventFd::NotifyResult::accepted() const noexcept { + return status == NotifyStatus::Notified || + status == NotifyStatus::AlreadySignaled; +} + +bool EventFd::DrainResult::accepted() const noexcept { + return status == DrainStatus::Drained || status == DrainStatus::Empty; +} + +EventFd::EventFd() noexcept { + do { + descriptor_ = ::eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + } while (descriptor_ < 0 && errno == EINTR); + + if (descriptor_ < 0) + creationError_ = currentError(); +} + +EventFd::~EventFd() { close(); } + +EventFd::EventFd(EventFd &&other) noexcept + : descriptor_(std::exchange(other.descriptor_, -1)), + creationError_(std::exchange(other.creationError_, 0)) {} + +EventFd &EventFd::operator=(EventFd &&other) noexcept { + if (this != &other) { + close(); + descriptor_ = std::exchange(other.descriptor_, -1); + creationError_ = std::exchange(other.creationError_, 0); + } + return *this; +} + +int EventFd::descriptor() const noexcept { return descriptor_; } + +bool EventFd::valid() const noexcept { return descriptor_ >= 0; } + +int EventFd::creationError() const noexcept { return creationError_; } + +EventFd::NotifyResult EventFd::notify() const noexcept { + if (!valid()) + return {NotifyStatus::Closed, EBADF}; + + constexpr std::uint64_t Wake = 1; + for (;;) { + const ssize_t written = ::write(descriptor_, &Wake, sizeof(Wake)); + if (written == static_cast(sizeof(Wake))) + return {NotifyStatus::Notified, 0}; + if (written < 0 && errno == EINTR) + continue; + if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return {NotifyStatus::AlreadySignaled, 0}; + return {NotifyStatus::Error, written >= 0 ? EIO : currentError()}; + } +} + +EventFd::DrainResult EventFd::drain() const noexcept { + if (!valid()) + return {DrainStatus::Closed, 0, EBADF}; + + std::uint64_t count = 0; + for (;;) { + const ssize_t received = ::read(descriptor_, &count, sizeof(count)); + if (received == static_cast(sizeof(count))) + return {DrainStatus::Drained, count, 0}; + if (received < 0 && errno == EINTR) + continue; + if (received < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + return {DrainStatus::Empty, 0, 0}; + return {DrainStatus::Error, 0, received >= 0 ? EIO : currentError()}; + } +} + +void EventFd::close() noexcept { + const int descriptor = std::exchange(descriptor_, -1); + if (descriptor >= 0) + static_cast(::close(descriptor)); +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/EventFd.h b/src/codex/nodegraph/EventFd.h new file mode 100644 index 0000000..538604f --- /dev/null +++ b/src/codex/nodegraph/EventFd.h @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_EVENTFD_H +#define CODEXUI_CODEX_NODEGRAPH_EVENTFD_H + +#include + +namespace codexui::nodegraph { + +// Owns one Linux eventfd used only as a cross-thread wake-up counter. +class EventFd final { +public: + enum class NotifyStatus : std::uint8_t { + Notified, + AlreadySignaled, + Closed, + Error, + }; + + struct NotifyResult final { + NotifyStatus status = NotifyStatus::Error; + int errorNumber = 0; + + [[nodiscard]] bool accepted() const noexcept; + bool operator==(const NotifyResult &) const = default; + }; + + enum class DrainStatus : std::uint8_t { + Drained, + Empty, + Closed, + Error, + }; + + struct DrainResult final { + DrainStatus status = DrainStatus::Error; + std::uint64_t count = 0; + int errorNumber = 0; + + [[nodiscard]] bool accepted() const noexcept; + bool operator==(const DrainResult &) const = default; + }; + + EventFd() noexcept; + ~EventFd(); + + EventFd(const EventFd &) = delete; + EventFd &operator=(const EventFd &) = delete; + EventFd(EventFd &&other) noexcept; + EventFd &operator=(EventFd &&other) noexcept; + + [[nodiscard]] int descriptor() const noexcept; + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] int creationError() const noexcept; + + [[nodiscard]] NotifyResult notify() const noexcept; + [[nodiscard]] DrainResult drain() const noexcept; + + void close() noexcept; + +private: + int descriptor_ = -1; + int creationError_ = 0; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_EVENTFD_H diff --git a/src/codex/nodegraph/Messages.h b/src/codex/nodegraph/Messages.h new file mode 100644 index 0000000..6a86223 --- /dev/null +++ b/src/codex/nodegraph/Messages.h @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_MESSAGES_H +#define CODEXUI_CODEX_NODEGRAPH_MESSAGES_H + +#include "codex/nodegraph/NodeGraph.h" + +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +struct GraphChanged final { + std::uint64_t revision = 0; + std::vector affected; + std::vector removed; + bool rescanRequired = false; + + bool operator==(const GraphChanged &) const = default; +}; + +enum class UiEffectKind : std::uint8_t { + ShowNotice, + SelectThread, + // Bounded, metadata-only protocol diagnostics for the existing Inspector. + // This is explicitly non-authoritative UI history; current state remains in + // NodeGraph and raw protocol payloads never cross the worker/Qt boundary. + ProtocolDiagnostic, +}; + +struct UiEffect final { + UiEffectKind kind = UiEffectKind::ShowNotice; + std::optional target; + std::string text; + Value::Object details; + + bool operator==(const UiEffect &) const = default; +}; + +struct WorkerStopped final { + std::string reason; + + bool operator==(const WorkerStopped &) const = default; +}; + +using WorkerToQtMessage = std::variant; + +struct Attachment final { + std::string path; + std::string displayName; + std::string mimeType; + std::optional> bytes; + + bool operator==(const Attachment &) const = default; +}; + +enum class NodeActionKind : std::uint8_t { + Hydrate, + Reload, + LoadHistory, + Rename, + Fork, + Archive, + Unarchive, + Delete, + SubmitPrompt, + InterruptTurn, + ResolveInteraction, + PromptMaterialized, + UiDetached, +}; + +// Only newly authored data crosses from Qt. Existing protocol-derived state is +// read from target on the worker and is never copied into an action. +struct NodeAction final { + NodeRef target; + NodeActionKind kind = NodeActionKind::Hydrate; + std::string promptText; + std::vector attachments; + Value::Object payload; + std::string correlation; + + bool operator==(const NodeAction &) const = default; +}; + +enum class RuntimeActionKind : std::uint8_t { + RefreshThreads, + CreateThread, + Connect, + Disconnect, + Reconnect, + ConfigureConnection, + ClaimController, + ReleaseController, + RefreshCatalogs, +}; + +struct RuntimeAction final { + RuntimeActionKind kind = RuntimeActionKind::RefreshThreads; + Value::Object payload; + std::string correlation; + // CreateThread owns its first prompt until the worker admits and dispatches + // it. Other runtime actions leave these fields empty. + std::string promptText; + std::vector attachments; + + bool operator==(const RuntimeAction &) const = default; +}; + +struct ShutdownRequest final { + bool operator==(const ShutdownRequest &) const = default; +}; + +using QtToWorkerMessage = + std::variant; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_MESSAGES_H diff --git a/src/codex/nodegraph/NodeGraph.cpp b/src/codex/nodegraph/NodeGraph.cpp new file mode 100644 index 0000000..e8d211b --- /dev/null +++ b/src/codex/nodegraph/NodeGraph.cpp @@ -0,0 +1,974 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/NodeGraph.h" + +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { +namespace { + +template +bool contains(const Range &range, const ValueType &value) { + return std::find(range.begin(), range.end(), value) != range.end(); +} + +template +void eraseValue(Range &range, const ValueType &value) { + range.erase(std::remove(range.begin(), range.end(), value), range.end()); +} + +NodeRef pin(Node *node) { return node ? node->shared_from_this() : NodeRef{}; } + +template +void ensureAppendCapacity(std::vector &values, std::size_t additional) { + if (additional <= values.capacity() - values.size()) + return; + const std::size_t required = values.size() + additional; + const std::size_t grown = + values.capacity() <= values.max_size() / 2 + ? std::max(1, values.capacity() * 2) + : values.max_size(); + values.reserve(std::max(required, grown)); +} + +} // namespace + +std::size_t NodeIdHash::operator()(const NodeId &id) const noexcept { + const std::size_t kind = static_cast(id.kind); + const std::size_t value = std::hash{}(id.canonical); + return value ^ (kind + 0x9e3779b9U + (value << 6U) + (value >> 2U)); +} + +Node::Node(NodeId id, NodeState state, std::uint64_t insertionOrder) + : id_(std::move(id)), + state_(std::make_shared(std::move(state))), + insertionOrder_(insertionOrder) {} + +const NodeId &Node::id() const noexcept { return id_; } + +void *Node::uiAttachment() const noexcept { + return uiAttachment_.load(std::memory_order_acquire); +} + +void Node::setUiAttachment(void *attachment) noexcept { + uiAttachment_.store(attachment, std::memory_order_release); +} + +bool GraphChange::empty() const noexcept { + return affected.empty() && removed.empty(); +} + +NodeGraph::WriteAccess NodeGraph::write() { + return WriteAccess(*this, std::unique_lock(mutex_)); +} + +std::optional NodeGraph::tryRead() const { + std::shared_lock lock(mutex_, std::try_to_lock); + if (!lock.owns_lock()) + return std::nullopt; + return ReadAccess(*this, std::move(lock)); +} + +std::uint64_t NodeGraph::publishedRevision() const noexcept { + return publishedRevision_.load(std::memory_order_acquire); +} + +std::uint64_t +NodeGraph::publishedStructureRevision(NodeKind kind) const noexcept { + const std::size_t index = static_cast(kind); + if (index >= publishedStructureRevisions_.size()) + return 0; + return publishedStructureRevisions_[index].load(std::memory_order_acquire); +} + +NodeGraph::ReadAccess::ReadAccess( + const NodeGraph &graph, std::shared_lock lock) noexcept + : graph_(&graph), lock_(std::move(lock)) {} + +std::uint64_t NodeGraph::ReadAccess::revision() const noexcept { + return graph_->revision_; +} + +std::uint64_t +NodeGraph::ReadAccess::structureRevision(NodeKind kind) const noexcept { + const std::size_t index = static_cast(kind); + return index < graph_->structureRevisions_.size() + ? graph_->structureRevisions_[index] + : 0; +} + +NodeRef NodeGraph::ReadAccess::find(const NodeId &id) const { + const auto found = graph_->nodes_.find(id); + return found == graph_->nodes_.end() ? NodeRef{} : found->second; +} + +const std::vector & +NodeGraph::ReadAccess::orderedNodes() const noexcept { + return graph_->orderedNodes_; +} + +std::uint64_t NodeGraph::ReadAccess::insertionOrder(const NodeRef &node) const { + if (!node) + return 0; + requireMember(node); + return node->insertionOrder_; +} + +const std::vector & +NodeGraph::ReadAccess::retiredNodes() const noexcept { + return graph_->retiredNodes_; +} + +std::size_t NodeGraph::ReadAccess::retiredCount() const noexcept { + return graph_->retiredNodes_.size(); +} + +NodeRef NodeGraph::ReadAccess::retiredAt(std::size_t index) const { + return index < graph_->retiredNodes_.size() ? graph_->retiredNodes_[index] + : NodeRef{}; +} + +std::uint64_t NodeGraph::ReadAccess::retiredOrderGeneration() const noexcept { + return graph_->retiredOrderGeneration_; +} + +bool NodeGraph::ReadAccess::contains(const NodeRef &node) const noexcept { + if (!node) + return false; + const auto active = graph_->nodes_.find(node->id_); + return (active != graph_->nodes_.end() && active->second == node) || + graph_->retiredIndex_.contains(node.get()); +} + +std::shared_ptr +NodeGraph::ReadAccess::state(const NodeRef &node) const { + if (!node) + return {}; + requireMember(node); + return node->state_; +} + +std::uint64_t +NodeGraph::ReadAccess::changedRevision(const NodeRef &node) const { + if (!node) + return 0; + requireMember(node); + return node->changedRevision_; +} + +std::uint64_t +NodeGraph::ReadAccess::fieldChangedRevision(const NodeRef &node, + std::string_view field) const { + if (!node) + return 0; + requireMember(node); + const auto found = node->fieldChangedRevisions_.find(std::string(field)); + return found == node->fieldChangedRevisions_.end() ? 0 : found->second; +} + +bool NodeGraph::ReadAccess::fieldsChangedAt(const NodeRef &node, + std::uint64_t revision) const { + if (!node) + return false; + requireMember(node); + return std::ranges::any_of(node->fieldChangedRevisions_, + [revision](const auto &field) { + return field.second == revision; + }); +} + +std::uint64_t +NodeGraph::ReadAccess::statusChangedRevision(const NodeRef &node) const { + if (!node) + return 0; + requireMember(node); + return node->statusChangedRevision_; +} + +std::uint64_t +NodeGraph::ReadAccess::structureChangedRevision(const NodeRef &node) const { + if (!node) + return 0; + requireMember(node); + return node->structureChangedRevision_; +} + +bool NodeGraph::ReadAccess::removed(const NodeRef &node) const { + if (!node) + return true; + requireMember(node); + return node->removed_; +} + +NodeRef NodeGraph::ReadAccess::parent(const NodeRef &node) const { + if (!node) + return {}; + requireMember(node); + return pin(node->parent_); +} + +std::size_t NodeGraph::ReadAccess::childCount(const NodeRef &node) const { + if (!node) + return 0; + requireMember(node); + return node->children_.size(); +} + +NodeRef NodeGraph::ReadAccess::childAt(const NodeRef &node, + std::size_t index) const { + if (!node) + return {}; + requireMember(node); + return index < node->children_.size() ? pin(node->children_[index]) + : NodeRef{}; +} + +std::vector +NodeGraph::ReadAccess::children(const NodeRef &node) const { + std::vector result; + if (!node) + return result; + requireMember(node); + result.reserve(node->children_.size()); + for (Node *child : node->children_) + result.emplace_back(pin(child)); + return result; +} + +std::size_t NodeGraph::ReadAccess::relatedCount(const NodeRef &node, + RelationKind kind) const { + if (!node) + return 0; + requireMember(node); + const auto found = node->relations_.find(kind); + return found == node->relations_.end() ? 0 : found->second.size(); +} + +NodeRef NodeGraph::ReadAccess::relatedAt(const NodeRef &node, RelationKind kind, + std::size_t index) const { + if (!node) + return {}; + requireMember(node); + const auto found = node->relations_.find(kind); + if (found == node->relations_.end() || index >= found->second.size()) + return {}; + return pin(found->second[index]); +} + +std::vector NodeGraph::ReadAccess::related(const NodeRef &node, + RelationKind kind) const { + std::vector result; + if (!node) + return result; + requireMember(node); + const auto found = node->relations_.find(kind); + if (found == node->relations_.end()) + return result; + result.reserve(found->second.size()); + for (Node *target : found->second) + result.emplace_back(pin(target)); + return result; +} + +void NodeGraph::ReadAccess::requireMember(const NodeRef &node) const { + if (contains(node)) + return; + throw std::invalid_argument("node does not belong to this graph"); +} + +NodeGraph::WriteAccess::WriteAccess( + NodeGraph &graph, std::unique_lock lock) noexcept + : graph_(&graph), lock_(std::move(lock)) {} + +NodeGraph::WriteAccess::WriteAccess(WriteAccess &&other) noexcept + : graph_(std::exchange(other.graph_, nullptr)), + lock_(std::move(other.lock_)), affected_(std::move(other.affected_)), + affectedIndex_(std::move(other.affectedIndex_)), + revisionTouches_(std::move(other.revisionTouches_)), + revisionTouchIndex_(std::move(other.revisionTouchIndex_)), + pendingStateRevisions_(std::move(other.pendingStateRevisions_)), + pendingStructureRevisions_(std::move(other.pendingStructureRevisions_)), + removed_(std::move(other.removed_)), + removedIndex_(std::move(other.removedIndex_)), dirty_(other.dirty_), + finished_(other.finished_) { + other.dirty_ = false; + other.finished_ = true; +} + +NodeGraph::WriteAccess::~WriteAccess() { + if (graph_ && lock_.owns_lock() && !finished_ && dirty_) + std::terminate(); +} + +std::uint64_t NodeGraph::WriteAccess::revision() const noexcept { + return graph_ ? graph_->revision_ : 0; +} + +NodeRef NodeGraph::WriteAccess::find(const NodeId &id) const { + const auto found = graph_->nodes_.find(id); + return found == graph_->nodes_.end() ? NodeRef{} : found->second; +} + +const std::vector & +NodeGraph::WriteAccess::orderedNodes() const noexcept { + return graph_->orderedNodes_; +} + +std::uint64_t +NodeGraph::WriteAccess::changedRevision(const NodeRef &node) const { + requireLive(node); + return node->changedRevision_; +} + +std::uint64_t +NodeGraph::WriteAccess::fieldChangedRevision(const NodeRef &node, + std::string_view field) const { + requireLive(node); + const auto found = node->fieldChangedRevisions_.find(std::string(field)); + return found == node->fieldChangedRevisions_.end() ? 0 : found->second; +} + +std::uint64_t +NodeGraph::WriteAccess::statusChangedRevision(const NodeRef &node) const { + requireLive(node); + return node->statusChangedRevision_; +} + +std::uint64_t +NodeGraph::WriteAccess::structureChangedRevision(const NodeRef &node) const { + requireLive(node); + return node->structureChangedRevision_; +} + +bool NodeGraph::WriteAccess::hasPendingChanges() const noexcept { + return dirty_; +} + +NodeRef NodeGraph::WriteAccess::upsert(NodeId id, NodeState initial) { + if (NodeRef existing = find(id)) + return existing; + NodeRef node( + new Node(std::move(id), std::move(initial), graph_->nextInsertionOrder_)); + PendingStateRevision pending; + pending.status = node->state_->status != NodeStatus::Unknown; + for (const auto &[field, value] : node->state_->fields) { + static_cast(value); + pending.fields.insert(field); + node->fieldChangedRevisions_.emplace(field, 0); + } + + // Allocate every auxiliary slot first. If canonical insertion then fails, + // roll these transaction-local entries back before propagating the error. + graph_->nodes_.reserve(graph_->nodes_.size() + 1); + ensureAppendCapacity(graph_->orderedNodes_, 1); + pendingStateRevisions_.reserve(pendingStateRevisions_.size() + 1); + affectedIndex_.reserve(affectedIndex_.size() + 1); + ensureAppendCapacity(affected_, 1); + pendingStateRevisions_.emplace(node.get(), std::move(pending)); + try { + affectedIndex_.insert(node.get()); + try { + affected_.emplace_back(node); + } catch (...) { + affectedIndex_.erase(node.get()); + throw; + } + try { + graph_->nodes_.emplace(node->id_, node); + } catch (...) { + affected_.pop_back(); + affectedIndex_.erase(node.get()); + pendingStateRevisions_.erase(node.get()); + throw; + } + } catch (...) { + pendingStateRevisions_.erase(node.get()); + throw; + } + graph_->orderedNodes_.emplace_back(node); + ++graph_->nextInsertionOrder_; + dirty_ = true; + return node; +} + +std::shared_ptr +NodeGraph::WriteAccess::state(const NodeRef &node) const { + requireLive(node); + return node->state_; +} + +NodeRef NodeGraph::WriteAccess::parent(const NodeRef &node) const { + requireLive(node); + return pin(node->parent_); +} + +std::vector +NodeGraph::WriteAccess::children(const NodeRef &node) const { + requireLive(node); + std::vector result; + result.reserve(node->children_.size()); + for (Node *child : node->children_) + result.emplace_back(pin(child)); + return result; +} + +std::vector NodeGraph::WriteAccess::related(const NodeRef &node, + RelationKind kind) const { + requireLive(node); + std::vector result; + const auto found = node->relations_.find(kind); + if (found == node->relations_.end()) + return result; + result.reserve(found->second.size()); + for (Node *target : found->second) + result.emplace_back(pin(target)); + return result; +} + +void NodeGraph::WriteAccess::replaceState(const NodeRef &node, + NodeState state) { + requireLive(node); + if (*node->state_ == state) + return; + const NodeState &before = *node->state_; + std::shared_ptr storage = + std::make_shared(std::move(state)); + const NodeState &after = *storage; + + const auto pendingPosition = pendingStateRevisions_.find(node.get()); + const bool hadPending = pendingPosition != pendingStateRevisions_.end(); + PendingStateRevision pending = + hadPending ? pendingPosition->second : PendingStateRevision{}; + pending.status = pending.status || before.status != after.status; + for (const auto &[field, value] : before.fields) { + const auto found = after.fields.find(field); + if (found == after.fields.end() || found->second != value) + pending.fields.insert(field); + } + for (const auto &[field, value] : after.fields) { + const auto found = before.fields.find(field); + if (found == before.fields.end() || found->second != value) + pending.fields.insert(field); + } + auto fieldRevisions = node->fieldChangedRevisions_; + for (const std::string &field : pending.fields) + fieldRevisions.try_emplace(field, 0); + + if (hadPending) { + std::swap(pendingPosition->second, pending); + } else { + pendingStateRevisions_.emplace(node.get(), std::move(pending)); + } + const std::array affected{node}; + try { + prepareChanges(affected, {}); + } catch (...) { + if (hadPending) + std::swap(pendingPosition->second, pending); + else + pendingStateRevisions_.erase(node.get()); + throw; + } + node->fieldChangedRevisions_.swap(fieldRevisions); + node->state_ = std::move(storage); +} + +void NodeGraph::WriteAccess::setField(const NodeRef &node, std::string key, + Value value) { + requireLive(node); + const auto found = node->state_->fields.find(key); + if (found != node->state_->fields.end() && found->second == value) + return; + NodeState next = *node->state_; + next.fields.insert_or_assign(std::move(key), std::move(value)); + replaceState(node, std::move(next)); +} + +void NodeGraph::WriteAccess::eraseField(const NodeRef &node, + std::string_view key) { + requireLive(node); + if (!node->state_->fields.contains(key)) + return; + NodeState next = *node->state_; + next.fields.erase(std::string(key)); + replaceState(node, std::move(next)); +} + +void NodeGraph::WriteAccess::setStatus(const NodeRef &node, NodeStatus status) { + requireLive(node); + if (node->state_->status == status) + return; + NodeState next = *node->state_; + next.status = status; + replaceState(node, std::move(next)); +} + +void NodeGraph::WriteAccess::touchRevision(const NodeRef &node) { + requireLive(node); + if (affectedIndex_.contains(node.get())) + return; + const auto [position, inserted] = revisionTouchIndex_.insert(node.get()); + if (!inserted) + return; + try { + revisionTouches_.emplace_back(node); + } catch (...) { + revisionTouchIndex_.erase(position); + throw; + } +} + +void NodeGraph::WriteAccess::setParent(const NodeRef &parent, + const NodeRef &child) { + requireLive(parent); + requireLive(child); + if (parent == child) + throw std::invalid_argument("a node cannot parent itself"); + for (Node *ancestor = parent.get(); ancestor; ancestor = ancestor->parent_) { + if (ancestor == child.get()) + throw std::invalid_argument("a parent relation cannot form a cycle"); + } + if (child->parent_ == parent.get()) + return; + parent->children_.reserve(parent->children_.size() + 1); + NodeRef previousParent = pin(child->parent_); + const std::array changed{parent, child, previousParent}; + prepareChanges(changed, changed); + if (previousParent) + eraseValue(previousParent->children_, child.get()); + child->parent_ = parent.get(); + parent->children_.emplace_back(child.get()); +} + +void NodeGraph::WriteAccess::clearParent(const NodeRef &child) { + requireLive(child); + if (!child->parent_) + return; + NodeRef parent = pin(child->parent_); + const std::array changed{parent, child}; + prepareChanges(changed, changed); + eraseValue(parent->children_, child.get()); + child->parent_ = nullptr; +} + +void NodeGraph::WriteAccess::replaceChildren( + const NodeRef &parent, std::span children) { + requireLive(parent); + std::vector next; + next.reserve(children.size()); + std::unordered_set seen; + seen.reserve(children.size()); + for (const NodeRef &child : children) { + requireLive(child); + if (parent == child) + throw std::invalid_argument("a node cannot parent itself"); + for (Node *ancestor = parent.get(); ancestor; + ancestor = ancestor->parent_) { + if (ancestor == child.get()) + throw std::invalid_argument("a parent relation cannot form a cycle"); + } + if (seen.insert(child.get()).second) + next.emplace_back(child); + } + + bool unchanged = parent->children_.size() == next.size(); + if (unchanged) { + for (std::size_t index = 0; index < next.size(); ++index) { + if (parent->children_[index] != next[index].get() || + next[index]->parent_ != parent.get()) { + unchanged = false; + break; + } + } + } + if (unchanged) + return; + + const std::vector previous = parent->children_; + std::vector affected{parent}; + std::vector structureChanged{parent}; + affected.reserve(1 + previous.size() + next.size() * 2); + structureChanged.reserve(1 + previous.size() + next.size() * 2); + for (Node *oldChildPointer : previous) { + if (!seen.contains(oldChildPointer)) { + NodeRef oldChild = pin(oldChildPointer); + affected.emplace_back(oldChild); + structureChanged.emplace_back(std::move(oldChild)); + } + } + + for (const NodeRef &child : next) { + const bool parentChanged = child->parent_ != parent.get(); + if (child->parent_ && child->parent_ != parent.get()) { + NodeRef previousParent = pin(child->parent_); + affected.emplace_back(previousParent); + structureChanged.emplace_back(std::move(previousParent)); + } + affected.emplace_back(child); + if (parentChanged) + structureChanged.emplace_back(child); + } + + parent->children_.reserve(next.size()); + prepareChanges(affected, structureChanged); + for (Node *oldChildPointer : previous) + if (!seen.contains(oldChildPointer)) + oldChildPointer->parent_ = nullptr; + for (const NodeRef &child : next) { + if (child->parent_ && child->parent_ != parent.get()) + eraseValue(child->parent_->children_, child.get()); + child->parent_ = parent.get(); + } + parent->children_.clear(); + for (const NodeRef &child : next) + parent->children_.emplace_back(child.get()); +} + +void NodeGraph::WriteAccess::relate(const NodeRef &source, RelationKind kind, + const NodeRef &target) { + requireLive(source); + requireLive(target); + const auto current = source->relations_.find(kind); + if (current != source->relations_.end() && + contains(current->second, target.get())) + return; + auto nextRelations = source->relations_; + nextRelations[kind].emplace_back(target.get()); + const std::array affected{source, target}; + const std::array structureChanged{source}; + prepareChanges(affected, structureChanged); + source->relations_.swap(nextRelations); +} + +void NodeGraph::WriteAccess::unrelate(const NodeRef &source, RelationKind kind, + const NodeRef &target) { + requireLive(source); + requireLive(target); + const auto found = source->relations_.find(kind); + if (found == source->relations_.end() || + !contains(found->second, target.get())) + return; + auto nextRelations = source->relations_; + auto next = nextRelations.find(kind); + eraseValue(next->second, target.get()); + if (next->second.empty()) + nextRelations.erase(next); + const std::array affected{source, target}; + const std::array structureChanged{source}; + prepareChanges(affected, structureChanged); + source->relations_.swap(nextRelations); +} + +void NodeGraph::WriteAccess::replaceRelated(const NodeRef &source, + RelationKind kind, + std::span targets) { + requireLive(source); + std::vector next; + next.reserve(targets.size()); + std::unordered_set seen; + seen.reserve(targets.size()); + for (const NodeRef &target : targets) { + requireLive(target); + if (seen.insert(target.get()).second) + next.emplace_back(target); + } + + const auto found = source->relations_.find(kind); + const std::vector previous = + found == source->relations_.end() ? std::vector{} : found->second; + bool unchanged = previous.size() == next.size(); + if (unchanged) { + for (std::size_t index = 0; index < next.size(); ++index) { + if (previous[index] != next[index].get()) { + unchanged = false; + break; + } + } + } + if (unchanged) + return; + + auto nextRelations = source->relations_; + if (next.empty()) + nextRelations.erase(kind); + else { + std::vector ordered; + ordered.reserve(next.size()); + for (const NodeRef &target : next) + ordered.emplace_back(target.get()); + nextRelations.insert_or_assign(kind, std::move(ordered)); + } + std::vector affected; + affected.reserve(1 + previous.size() + next.size()); + affected.emplace_back(source); + for (Node *target : previous) + affected.emplace_back(pin(target)); + for (const NodeRef &target : next) + affected.emplace_back(target); + const std::array structureChanged{source}; + prepareChanges(affected, structureChanged); + source->relations_.swap(nextRelations); +} + +void NodeGraph::WriteAccess::remove(const NodeRef &node) { + const std::array nodes{node}; + removeMany(nodes); +} + +void NodeGraph::WriteAccess::removeMany(std::span nodes) { + if (nodes.empty()) + return; + + // Validate and allocate every replacement container before changing a node. + // Once topology mutation starts, the remainder of this function consists + // only of erases, pointer assignments, and noexcept container swaps. + std::vector removalOrder; + removalOrder.reserve(nodes.size()); + std::unordered_set removalSet; + removalSet.reserve(nodes.size()); + for (const NodeRef &node : nodes) { + requireLive(node); + if (removalSet.insert(node.get()).second) + removalOrder.emplace_back(node); + } + if (removalOrder.empty()) + return; + + std::vector survivingOrder; + survivingOrder.reserve(graph_->orderedNodes_.size() - removalOrder.size()); + std::vector topologyChanged; + topologyChanged.reserve(graph_->orderedNodes_.size()); + std::unordered_set topologyChangedSet; + topologyChangedSet.reserve(graph_->orderedNodes_.size()); + std::unordered_set removedStructureSet; + removedStructureSet.reserve(removalOrder.size()); + + for (const NodeRef &candidate : graph_->orderedNodes_) { + const bool removing = removalSet.contains(candidate.get()); + if (!removing) + survivingOrder.emplace_back(candidate); + + bool structureChanged = + candidate->parent_ && removalSet.contains(candidate->parent_); + structureChanged = + structureChanged || + std::ranges::any_of(candidate->children_, [&removalSet](Node *child) { + return removalSet.contains(child); + }); + structureChanged = + structureChanged || + std::ranges::any_of(candidate->relations_, [&removalSet]( + const auto &entry) { + return std::ranges::any_of(entry.second, [&removalSet](Node *target) { + return removalSet.contains(target); + }); + }); + if (removing) { + if (candidate->parent_ || !candidate->children_.empty() || + !candidate->relations_.empty()) + removedStructureSet.insert(candidate.get()); + } else if (structureChanged && + topologyChangedSet.insert(candidate.get()).second) { + topologyChanged.emplace_back(candidate); + } + } + + auto nextNodes = graph_->nodes_; + for (const NodeRef &node : removalOrder) + nextNodes.erase(node->id_); + + auto nextRetiredNodes = graph_->retiredNodes_; + auto nextRetiredIndex = graph_->retiredIndex_; + nextRetiredNodes.reserve(nextRetiredNodes.size() + removalOrder.size()); + nextRetiredIndex.reserve(nextRetiredIndex.size() + removalOrder.size()); + for (const NodeRef &node : removalOrder) { + nextRetiredIndex.emplace(node.get(), nextRetiredNodes.size()); + nextRetiredNodes.emplace_back(node); + } + + auto nextAffected = affected_; + auto nextAffectedIndex = affectedIndex_; + nextAffected.reserve(nextAffected.size() + topologyChanged.size()); + nextAffectedIndex.reserve(nextAffectedIndex.size() + topologyChanged.size()); + for (const NodeRef &node : topologyChanged) + if (nextAffectedIndex.insert(node.get()).second) + nextAffected.emplace_back(node); + + auto nextStructureRevisions = pendingStructureRevisions_; + nextStructureRevisions.reserve(nextStructureRevisions.size() + + topologyChanged.size() + + removedStructureSet.size()); + for (const NodeRef &node : topologyChanged) + nextStructureRevisions.insert(node.get()); + for (Node *node : removedStructureSet) + nextStructureRevisions.insert(node); + + auto nextRemoved = removed_; + auto nextRemovedIndex = removedIndex_; + nextRemoved.reserve(nextRemoved.size() + removalOrder.size()); + nextRemovedIndex.reserve(nextRemovedIndex.size() + removalOrder.size()); + for (const NodeRef &node : removalOrder) + if (nextRemovedIndex.insert(node.get()).second) + nextRemoved.emplace_back(node); + + for (const NodeRef &candidate : graph_->orderedNodes_) { + if (removalSet.contains(candidate.get())) { + candidate->parent_ = nullptr; + candidate->children_.clear(); + candidate->relations_.clear(); + candidate->removed_ = true; + continue; + } + if (candidate->parent_ && removalSet.contains(candidate->parent_)) + candidate->parent_ = nullptr; + std::erase_if(candidate->children_, [&removalSet](Node *child) { + return removalSet.contains(child); + }); + for (auto relation = candidate->relations_.begin(); + relation != candidate->relations_.end();) { + std::erase_if(relation->second, [&removalSet](Node *target) { + return removalSet.contains(target); + }); + if (relation->second.empty()) + relation = candidate->relations_.erase(relation); + else + ++relation; + } + } + + graph_->nodes_.swap(nextNodes); + graph_->orderedNodes_.swap(survivingOrder); + graph_->retiredNodes_.swap(nextRetiredNodes); + graph_->retiredIndex_.swap(nextRetiredIndex); + affected_.swap(nextAffected); + affectedIndex_.swap(nextAffectedIndex); + pendingStructureRevisions_.swap(nextStructureRevisions); + removed_.swap(nextRemoved); + removedIndex_.swap(nextRemovedIndex); + dirty_ = true; +} + +void NodeGraph::WriteAccess::releaseRetired(std::span nodes) { + for (const NodeRef &node : nodes) { + if (!node) + continue; + const auto found = graph_->retiredIndex_.find(node.get()); + if (found == graph_->retiredIndex_.end()) + continue; + const std::size_t index = found->second; + const std::size_t last = graph_->retiredNodes_.size() - 1; + if (index != last) { + graph_->retiredNodes_[index] = std::move(graph_->retiredNodes_.back()); + graph_->retiredIndex_.at(graph_->retiredNodes_[index].get()) = index; + } + graph_->retiredNodes_.pop_back(); + graph_->retiredIndex_.erase(found); + ++graph_->retiredOrderGeneration_; + } +} + +GraphChange NodeGraph::WriteAccess::finish() { + if (finished_) + return GraphChange{graph_ ? graph_->revision_ : 0, {}, {}}; + GraphChange change = publish(); + if (lock_.owns_lock()) + lock_.unlock(); + return change; +} + +void NodeGraph::WriteAccess::requireLive(const NodeRef &node) const { + if (!graph_ || !lock_.owns_lock() || finished_) + throw std::logic_error("graph write access is not active"); + if (!node || node->removed_) + throw std::invalid_argument("node is not live"); + const auto found = graph_->nodes_.find(node->id_); + if (found == graph_->nodes_.end() || found->second != node) + throw std::invalid_argument("node does not belong to this graph"); +} + +void NodeGraph::WriteAccess::prepareChanges( + std::span affected, + std::span structureChanged) { + const bool wasDirty = dirty_; + const std::size_t affectedSize = affected_.size(); + std::vector addedAffected; + std::vector addedStructure; + addedAffected.reserve(affected.size()); + addedStructure.reserve(structureChanged.size()); + ensureAppendCapacity(affected_, affected.size()); + affectedIndex_.reserve(affectedIndex_.size() + affected.size()); + pendingStructureRevisions_.reserve(pendingStructureRevisions_.size() + + structureChanged.size()); + + try { + for (const NodeRef &node : affected) { + if (!node) + continue; + const auto [position, inserted] = affectedIndex_.insert(node.get()); + static_cast(position); + if (!inserted) + continue; + try { + affected_.emplace_back(node); + } catch (...) { + affectedIndex_.erase(node.get()); + throw; + } + addedAffected.emplace_back(node.get()); + } + for (const NodeRef &node : structureChanged) { + if (node && pendingStructureRevisions_.insert(node.get()).second) + addedStructure.emplace_back(node.get()); + } + } catch (...) { + affected_.resize(affectedSize); + for (Node *node : addedAffected) + affectedIndex_.erase(node); + for (Node *node : addedStructure) + pendingStructureRevisions_.erase(node); + dirty_ = wasDirty; + throw; + } + dirty_ = true; +} + +GraphChange NodeGraph::WriteAccess::publish() { + finished_ = true; + if (!dirty_) + return GraphChange{graph_->revision_, {}, {}}; + ++graph_->revision_; + for (auto &[node, pending] : pendingStateRevisions_) { + if (pending.status) + node->statusChangedRevision_ = graph_->revision_; + for (const std::string &field : pending.fields) + node->fieldChangedRevisions_.insert_or_assign(field, graph_->revision_); + } + std::array changedStructureKinds{}; + for (Node *node : pendingStructureRevisions_) { + node->structureChangedRevision_ = graph_->revision_; + changedStructureKinds[static_cast(node->id_.kind)] = true; + } + for (std::size_t index = 0; index < changedStructureKinds.size(); ++index) { + if (!changedStructureKinds[index]) + continue; + graph_->structureRevisions_[index] = graph_->revision_; + graph_->publishedStructureRevisions_[index].store( + graph_->revision_, std::memory_order_release); + } + for (const NodeRef &node : affected_) + node->changedRevision_ = graph_->revision_; + for (const NodeRef &node : revisionTouches_) + node->changedRevision_ = graph_->revision_; + for (const NodeRef &node : removed_) + node->changedRevision_ = graph_->revision_; + graph_->publishedRevision_.store(graph_->revision_, + std::memory_order_release); + return GraphChange{graph_->revision_, std::move(affected_), + std::move(removed_)}; +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/NodeGraph.h b/src/codex/nodegraph/NodeGraph.h new file mode 100644 index 0000000..0eff506 --- /dev/null +++ b/src/codex/nodegraph/NodeGraph.h @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_NODEGRAPH_H +#define CODEXUI_CODEX_NODEGRAPH_NODEGRAPH_H + +#include "codex/nodegraph/Value.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +enum class NodeKind : std::uint8_t { + Runtime, + Connection, + Thread, + Turn, + Item, + Interaction, + Operation, + Catalog, + CatalogEntry, + Account, + Configuration, + PermissionProfile, + Skill, + Hook, + Plugin, + App, + McpServer, + Project, + ThreadSection, + Process, + RealtimeSession, + FilesystemWatch, + ExternalAgentImport, + FuzzyFileSearchSession, + LoginAttempt, + Notice, + UnknownProtocol, +}; + +struct NodeId final { + NodeKind kind = NodeKind::UnknownProtocol; + std::string canonical; + + bool operator==(const NodeId &) const = default; +}; + +struct NodeIdHash final { + [[nodiscard]] std::size_t operator()(const NodeId &id) const noexcept; +}; + +enum class NodeStatus : std::uint8_t { + Unknown, + Pending, + Running, + Completed, + Failed, + Interrupted, + NotLoaded, + Connected, + Disconnected, +}; + +// Protocol facts are retained directly here. The immutable storage is replaced +// as one unit by the sole writer and can be pinned briefly by Qt after a +// successful try-read. +struct NodeState final { + NodeStatus status = NodeStatus::Unknown; + Value::Object fields; + + bool operator==(const NodeState &) const = default; +}; + +enum class RelationKind : std::uint8_t { + RootThread, + StructuralChildThread, + AgentChildThread, + ThreadOwner, + ForkChildThread, + ProjectMembership, + SectionMembership, + TurnRootItem, + OperationTarget, + InteractionTarget, + PendingInteraction, + PendingPrompt, + PromptMaterialization, + ProcessOwner, + ReviewTarget, + ActiveTurn, + UiSelectionTarget, +}; + +class Node final : public std::enable_shared_from_this { +public: + Node(const Node &) = delete; + Node &operator=(const Node &) = delete; + + [[nodiscard]] const NodeId &id() const noexcept; + + // The slot is intentionally untyped and non-owning. Only the Qt main thread + // may set, clear, or dereference it; the worker never examines it. + [[nodiscard]] void *uiAttachment() const noexcept; + void setUiAttachment(void *attachment) noexcept; + +private: + friend class NodeGraph; + + explicit Node(NodeId id, NodeState state, std::uint64_t insertionOrder); + + NodeId id_; + std::shared_ptr state_; + Node *parent_ = nullptr; + std::vector children_; + std::unordered_map> relations_; + std::unordered_map fieldChangedRevisions_; + std::uint64_t statusChangedRevision_ = 0; + // Changes only when this node's parent, ordered children, or an outgoing + // relation changes. UI scans use the stamp to validate bounded structural + // reads without treating ordinary streaming fields as topology changes. + std::uint64_t structureChangedRevision_ = 0; + std::uint64_t changedRevision_ = 0; + // Immutable position in the graph's append-only insertion order. Qt uses + // this only to resume bounded scans after unrelated removals shift the + // ordered-node vector. + std::uint64_t insertionOrder_ = 0; + bool removed_ = false; + std::atomic uiAttachment_{nullptr}; +}; + +using NodeRef = std::shared_ptr; + +struct GraphChange final { + std::uint64_t revision = 0; + std::vector affected; + std::vector removed; + + [[nodiscard]] bool empty() const noexcept; +}; + +class NodeGraph final { +public: + class ReadAccess; + class WriteAccess; + + NodeGraph() = default; + NodeGraph(const NodeGraph &) = delete; + NodeGraph &operator=(const NodeGraph &) = delete; + + [[nodiscard]] WriteAccess write(); + [[nodiscard]] std::optional tryRead() const; + [[nodiscard]] std::uint64_t publishedRevision() const noexcept; + [[nodiscard]] std::uint64_t + publishedStructureRevision(NodeKind kind) const noexcept; + + class ReadAccess final { + public: + ReadAccess(ReadAccess &&) noexcept = default; + ReadAccess &operator=(ReadAccess &&) noexcept = default; + ReadAccess(const ReadAccess &) = delete; + ReadAccess &operator=(const ReadAccess &) = delete; + + [[nodiscard]] std::uint64_t revision() const noexcept; + [[nodiscard]] std::uint64_t structureRevision(NodeKind kind) const noexcept; + [[nodiscard]] NodeRef find(const NodeId &id) const; + [[nodiscard]] const std::vector &orderedNodes() const noexcept; + [[nodiscard]] std::uint64_t insertionOrder(const NodeRef &node) const; + [[nodiscard]] const std::vector &retiredNodes() const noexcept; + [[nodiscard]] std::size_t retiredCount() const noexcept; + [[nodiscard]] NodeRef retiredAt(std::size_t index) const; + // Changes only when an existing retirement is released. Appending a + // newly removed node preserves every earlier index. + [[nodiscard]] std::uint64_t retiredOrderGeneration() const noexcept; + // Retained UI NodeRefs may outlive retirement acknowledgement. This + // non-throwing probe lets a deferred Qt pass discard such a reference + // before asking for graph-owned state. + [[nodiscard]] bool contains(const NodeRef &node) const noexcept; + [[nodiscard]] std::shared_ptr + state(const NodeRef &node) const; + [[nodiscard]] std::uint64_t changedRevision(const NodeRef &node) const; + [[nodiscard]] std::uint64_t + fieldChangedRevision(const NodeRef &node, std::string_view field) const; + [[nodiscard]] bool fieldsChangedAt(const NodeRef &node, + std::uint64_t revision) const; + [[nodiscard]] std::uint64_t + statusChangedRevision(const NodeRef &node) const; + [[nodiscard]] std::uint64_t + structureChangedRevision(const NodeRef &node) const; + [[nodiscard]] bool removed(const NodeRef &node) const; + [[nodiscard]] NodeRef parent(const NodeRef &node) const; + [[nodiscard]] std::size_t childCount(const NodeRef &node) const; + [[nodiscard]] NodeRef childAt(const NodeRef &node, std::size_t index) const; + [[nodiscard]] std::vector children(const NodeRef &node) const; + [[nodiscard]] std::size_t relatedCount(const NodeRef &node, + RelationKind kind) const; + [[nodiscard]] NodeRef relatedAt(const NodeRef &node, RelationKind kind, + std::size_t index) const; + [[nodiscard]] std::vector related(const NodeRef &node, + RelationKind kind) const; + + private: + friend class NodeGraph; + ReadAccess(const NodeGraph &graph, + std::shared_lock lock) noexcept; + void requireMember(const NodeRef &node) const; + + const NodeGraph *graph_; + std::shared_lock lock_; + }; + + class WriteAccess final { + public: + WriteAccess(WriteAccess &&other) noexcept; + WriteAccess &operator=(WriteAccess &&) = delete; + WriteAccess(const WriteAccess &) = delete; + WriteAccess &operator=(const WriteAccess &) = delete; + ~WriteAccess(); + + [[nodiscard]] std::uint64_t revision() const noexcept; + [[nodiscard]] NodeRef find(const NodeId &id) const; + [[nodiscard]] const std::vector &orderedNodes() const noexcept; + [[nodiscard]] std::uint64_t changedRevision(const NodeRef &node) const; + [[nodiscard]] std::uint64_t + fieldChangedRevision(const NodeRef &node, std::string_view field) const; + [[nodiscard]] std::uint64_t + statusChangedRevision(const NodeRef &node) const; + [[nodiscard]] std::uint64_t + structureChangedRevision(const NodeRef &node) const; + [[nodiscard]] bool hasPendingChanges() const noexcept; + [[nodiscard]] NodeRef upsert(NodeId id, NodeState initial = {}); + [[nodiscard]] std::shared_ptr + state(const NodeRef &node) const; + [[nodiscard]] NodeRef parent(const NodeRef &node) const; + [[nodiscard]] std::vector children(const NodeRef &node) const; + [[nodiscard]] std::vector related(const NodeRef &node, + RelationKind kind) const; + + void replaceState(const NodeRef &node, NodeState state); + void setField(const NodeRef &node, std::string key, Value value); + void eraseField(const NodeRef &node, std::string_view key); + void setStatus(const NodeRef &node, NodeStatus status); + // Advance an owning aggregate's changed revision after a concrete + // descendant mutation without adding redundant render work to the + // transaction's affected-node notification. + void touchRevision(const NodeRef &node); + void setParent(const NodeRef &parent, const NodeRef &child); + void clearParent(const NodeRef &child); + void replaceChildren(const NodeRef &parent, + std::span children); + void relate(const NodeRef &source, RelationKind kind, + const NodeRef &target); + void unrelate(const NodeRef &source, RelationKind kind, + const NodeRef &target); + void replaceRelated(const NodeRef &source, RelationKind kind, + std::span targets); + void remove(const NodeRef &node); + void removeMany(std::span nodes); + + // Removed nodes remain reachable only for UI-detachment recovery when a + // graph notification saturates. The sole writer releases them after Qt has + // acknowledged detachment through the typed command mailbox. + void releaseRetired(std::span nodes); + + // Publishes one revision for the complete mutation and unlocks before + // returning, so callers can notify Qt without holding graph + // synchronization. + [[nodiscard]] GraphChange finish(); + + private: + friend class NodeGraph; + struct PendingStateRevision final { + bool status = false; + std::unordered_set fields; + }; + + WriteAccess(NodeGraph &graph, + std::unique_lock lock) noexcept; + + void requireLive(const NodeRef &node) const; + void prepareChanges(std::span affected, + std::span structureChanged); + [[nodiscard]] GraphChange publish(); + + NodeGraph *graph_; + std::unique_lock lock_; + std::vector affected_; + std::unordered_set affectedIndex_; + std::vector revisionTouches_; + std::unordered_set revisionTouchIndex_; + std::unordered_map pendingStateRevisions_; + std::unordered_set pendingStructureRevisions_; + std::vector removed_; + std::unordered_set removedIndex_; + bool dirty_ = false; + bool finished_ = false; + }; + +private: + static constexpr std::size_t NodeKindCount = + static_cast(NodeKind::UnknownProtocol) + 1; + + mutable std::shared_mutex mutex_; + std::unordered_map nodes_; + std::vector orderedNodes_; + std::vector retiredNodes_; + std::unordered_map retiredIndex_; + std::uint64_t retiredOrderGeneration_ = 0; + std::uint64_t nextInsertionOrder_ = 1; + std::uint64_t revision_ = 0; + std::array structureRevisions_{}; + std::atomic publishedRevision_{0}; + std::array, NodeKindCount> + publishedStructureRevisions_{}; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_NODEGRAPH_H diff --git a/src/codex/nodegraph/PromptText.cpp b/src/codex/nodegraph/PromptText.cpp new file mode 100644 index 0000000..b28f156 --- /dev/null +++ b/src/codex/nodegraph/PromptText.cpp @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/PromptText.h" + +#include +#include + +namespace codexui::nodegraph { +namespace { + +std::string markdownLabel(std::string_view label) { + std::string escaped; + escaped.reserve(label.size()); + for (const char character : label) { + if (character == '\\' || character == '[' || character == ']') + escaped.push_back('\\'); + escaped.push_back(character == '\r' || character == '\n' ? ' ' : character); + } + return escaped; +} + +bool fileUrlByteAllowed(unsigned char byte) noexcept { + const bool alphanumeric = (byte >= 'a' && byte <= 'z') || + (byte >= 'A' && byte <= 'Z') || + (byte >= '0' && byte <= '9'); + return alphanumeric || byte == '-' || byte == '.' || byte == '_' || + byte == '~' || byte == '/' || byte == ':' || byte == '@' || + byte == '!' || byte == '$' || byte == '&' || byte == '\'' || + byte == '*' || byte == '+' || byte == ',' || byte == ';' || + byte == '='; +} + +std::string localFileUrl(std::string_view path) { + static constexpr char Hex[] = "0123456789ABCDEF"; + std::string result = path.starts_with('/') ? "file://" : "file:"; + for (const unsigned char byte : path) { + if (fileUrlByteAllowed(byte)) { + result.push_back(static_cast(byte)); + continue; + } + result.push_back('%'); + result.push_back(Hex[byte >> 4]); + result.push_back(Hex[byte & 0x0f]); + } + return result; +} + +} // namespace + +std::string composePromptMarkdown(std::string prompt, + std::span attachments) { + std::vector links; + links.reserve(attachments.size()); + for (const Attachment &attachment : attachments) { + if (attachment.mimeType.starts_with("image/") || + attachment.mimeType.starts_with("audio/")) + continue; + const std::string &label = attachment.displayName.empty() + ? attachment.path + : attachment.displayName; + links.emplace_back("- [" + markdownLabel(label) + "](" + + localFileUrl(attachment.path) + ')'); + } + if (links.empty()) + return prompt; + prompt += "\n\nAttached files:\n"; + for (std::size_t index = 0; index < links.size(); ++index) { + if (index != 0) + prompt.push_back('\n'); + prompt += links[index]; + } + return prompt; +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/PromptText.h b/src/codex/nodegraph/PromptText.h new file mode 100644 index 0000000..571f16d --- /dev/null +++ b/src/codex/nodegraph/PromptText.h @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_PROMPTTEXT_H +#define CODEXUI_CODEX_NODEGRAPH_PROMPTTEXT_H + +#include "codex/nodegraph/Messages.h" + +#include +#include + +namespace codexui::nodegraph { + +// Produces the one text value used by both the local pending card and the +// app-server input. Binary image/audio attachments remain separate input +// items; ordinary files are safe local-file Markdown links. +[[nodiscard]] std::string +composePromptMarkdown(std::string prompt, + std::span attachments); + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_PROMPTTEXT_H diff --git a/src/codex/nodegraph/ProtocolCatalog.cpp b/src/codex/nodegraph/ProtocolCatalog.cpp new file mode 100644 index 0000000..e4a413b --- /dev/null +++ b/src/codex/nodegraph/ProtocolCatalog.cpp @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/ProtocolCatalog.h" + +#include + +namespace codexui::nodegraph { +namespace { + +#define CODEXUI_CLIENT_REQUEST(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ClientRequest, \ + MessageDisposition::WorkerOperationResult \ + } +#define CODEXUI_SERVER_REQUEST(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ServerRequest, \ + MessageDisposition::ReverseInteraction \ + } +#define CODEXUI_GRAPH_NOTIFICATION(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ServerNotification, \ + MessageDisposition::GraphUpdate \ + } +#define CODEXUI_NEUTRAL_NOTIFICATION(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ServerNotification, \ + MessageDisposition::IntentionallyStateNeutral \ + } +#define CODEXUI_UI_NOTIFICATION(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ServerNotification, \ + MessageDisposition::TypedUiEffect \ + } +#define CODEXUI_CLIENT_NOTIFICATION(methodName) \ + MethodDescriptor { \ + methodName, ProtocolDirection::ClientNotification, \ + MessageDisposition::WorkerOperationResult \ + } + +constexpr std::array Methods{{ + CODEXUI_CLIENT_REQUEST("initialize"), + CODEXUI_CLIENT_REQUEST("server/diagnostics"), + CODEXUI_CLIENT_REQUEST("thread/start"), + CODEXUI_CLIENT_REQUEST("thread/resume"), + CODEXUI_CLIENT_REQUEST("thread/fork"), + CODEXUI_CLIENT_REQUEST("thread/archive"), + CODEXUI_CLIENT_REQUEST("thread/delete"), + CODEXUI_CLIENT_REQUEST("thread/unsubscribe"), + CODEXUI_CLIENT_REQUEST("thread/increment_elicitation"), + CODEXUI_CLIENT_REQUEST("thread/decrement_elicitation"), + CODEXUI_CLIENT_REQUEST("thread/name/set"), + CODEXUI_CLIENT_REQUEST("thread/goal/set"), + CODEXUI_CLIENT_REQUEST("thread/goal/get"), + CODEXUI_CLIENT_REQUEST("thread/goal/clear"), + CODEXUI_CLIENT_REQUEST("thread/queue/add"), + CODEXUI_CLIENT_REQUEST("thread/queue/list"), + CODEXUI_CLIENT_REQUEST("thread/queue/update"), + CODEXUI_CLIENT_REQUEST("thread/queue/delete"), + CODEXUI_CLIENT_REQUEST("thread/queue/reorder"), + CODEXUI_CLIENT_REQUEST("thread/queue/start"), + CODEXUI_CLIENT_REQUEST("thread/metadata/update"), + CODEXUI_CLIENT_REQUEST("thread/section/move"), + CODEXUI_CLIENT_REQUEST("thread/settings/update"), + CODEXUI_CLIENT_REQUEST("thread/memoryMode/set"), + CODEXUI_CLIENT_REQUEST("memory/reset"), + CODEXUI_CLIENT_REQUEST("thread/unarchive"), + CODEXUI_CLIENT_REQUEST("thread/compact/start"), + CODEXUI_CLIENT_REQUEST("thread/shellCommand"), + CODEXUI_CLIENT_REQUEST("thread/approveGuardianDeniedAction"), + CODEXUI_CLIENT_REQUEST("thread/backgroundTerminals/clean"), + CODEXUI_CLIENT_REQUEST("thread/backgroundTerminals/list"), + CODEXUI_CLIENT_REQUEST("thread/backgroundTerminals/terminate"), + CODEXUI_CLIENT_REQUEST("thread/rollback"), + CODEXUI_CLIENT_REQUEST("thread/revert"), + CODEXUI_CLIENT_REQUEST("thread/list"), + CODEXUI_CLIENT_REQUEST("project/list"), + CODEXUI_CLIENT_REQUEST("project/read"), + CODEXUI_CLIENT_REQUEST("project/create"), + CODEXUI_CLIENT_REQUEST("project/import"), + CODEXUI_CLIENT_REQUEST("project/update"), + CODEXUI_CLIENT_REQUEST("project/move"), + CODEXUI_CLIENT_REQUEST("project/delete"), + CODEXUI_CLIENT_REQUEST("threadSection/list"), + CODEXUI_CLIENT_REQUEST("threadSection/create"), + CODEXUI_CLIENT_REQUEST("threadSection/update"), + CODEXUI_CLIENT_REQUEST("threadSection/delete"), + CODEXUI_CLIENT_REQUEST("thread/search"), + CODEXUI_CLIENT_REQUEST("thread/searchOccurrences"), + CODEXUI_CLIENT_REQUEST("thread/loaded/list"), + CODEXUI_CLIENT_REQUEST("thread/read"), + CODEXUI_CLIENT_REQUEST("thread/turns/list"), + CODEXUI_CLIENT_REQUEST("thread/items/list"), + CODEXUI_CLIENT_REQUEST("thread/inject_items"), + CODEXUI_CLIENT_REQUEST("skills/list"), + CODEXUI_CLIENT_REQUEST("skills/extraRoots/set"), + CODEXUI_CLIENT_REQUEST("hooks/list"), + CODEXUI_CLIENT_REQUEST("marketplace/add"), + CODEXUI_CLIENT_REQUEST("marketplace/remove"), + CODEXUI_CLIENT_REQUEST("marketplace/upgrade"), + CODEXUI_CLIENT_REQUEST("plugin/list"), + CODEXUI_CLIENT_REQUEST("plugin/search"), + CODEXUI_CLIENT_REQUEST("plugin/installed"), + CODEXUI_CLIENT_REQUEST("plugin/read"), + CODEXUI_CLIENT_REQUEST("plugin/skill/read"), + CODEXUI_CLIENT_REQUEST("plugin/share/save"), + CODEXUI_CLIENT_REQUEST("plugin/share/updateTargets"), + CODEXUI_CLIENT_REQUEST("plugin/share/list"), + CODEXUI_CLIENT_REQUEST("plugin/share/checkout"), + CODEXUI_CLIENT_REQUEST("plugin/share/delete"), + CODEXUI_CLIENT_REQUEST("app/read"), + CODEXUI_CLIENT_REQUEST("app/list"), + CODEXUI_CLIENT_REQUEST("app/installed"), + CODEXUI_CLIENT_REQUEST("fs/readFile"), + CODEXUI_CLIENT_REQUEST("fs/writeFile"), + CODEXUI_CLIENT_REQUEST("fs/createDirectory"), + CODEXUI_CLIENT_REQUEST("fs/getMetadata"), + CODEXUI_CLIENT_REQUEST("fs/readDirectory"), + CODEXUI_CLIENT_REQUEST("fs/remove"), + CODEXUI_CLIENT_REQUEST("fs/copy"), + CODEXUI_CLIENT_REQUEST("fs/watch"), + CODEXUI_CLIENT_REQUEST("fs/unwatch"), + CODEXUI_CLIENT_REQUEST("skills/config/write"), + CODEXUI_CLIENT_REQUEST("plugin/install"), + CODEXUI_CLIENT_REQUEST("plugin/uninstall"), + CODEXUI_CLIENT_REQUEST("turn/start"), + CODEXUI_CLIENT_REQUEST("turn/settings/update"), + CODEXUI_CLIENT_REQUEST("turn/steer"), + CODEXUI_CLIENT_REQUEST("turn/interrupt"), + CODEXUI_CLIENT_REQUEST("thread/realtime/start"), + CODEXUI_CLIENT_REQUEST("thread/realtime/appendAudio"), + CODEXUI_CLIENT_REQUEST("thread/realtime/appendText"), + CODEXUI_CLIENT_REQUEST("thread/realtime/appendSpeech"), + CODEXUI_CLIENT_REQUEST("thread/realtime/stop"), + CODEXUI_CLIENT_REQUEST("thread/timeline/list"), + CODEXUI_CLIENT_REQUEST("thread/realtime/listVoices"), + CODEXUI_CLIENT_REQUEST("review/start"), + CODEXUI_CLIENT_REQUEST("model/list"), + CODEXUI_CLIENT_REQUEST("modelProvider/capabilities/read"), + CODEXUI_CLIENT_REQUEST("experimentalFeature/list"), + CODEXUI_CLIENT_REQUEST("permissionProfile/list"), + CODEXUI_CLIENT_REQUEST("experimentalFeature/enablement/set"), + CODEXUI_CLIENT_REQUEST("remoteControl/enable"), + CODEXUI_CLIENT_REQUEST("remoteControl/disable"), + CODEXUI_CLIENT_REQUEST("remoteControl/status/read"), + CODEXUI_CLIENT_REQUEST("remoteControl/pairing/start"), + CODEXUI_CLIENT_REQUEST("remoteControl/pairing/status"), + CODEXUI_CLIENT_REQUEST("remoteControl/client/list"), + CODEXUI_CLIENT_REQUEST("remoteControl/client/revoke"), + CODEXUI_CLIENT_REQUEST("collaborationMode/list"), + CODEXUI_CLIENT_REQUEST("mock/experimentalMethod"), + CODEXUI_CLIENT_REQUEST("environment/add"), + CODEXUI_CLIENT_REQUEST("environment/info"), + CODEXUI_CLIENT_REQUEST("environment/status"), + CODEXUI_CLIENT_REQUEST("mcpServer/oauth/login"), + CODEXUI_CLIENT_REQUEST("config/mcpServer/reload"), + CODEXUI_CLIENT_REQUEST("mcpServerStatus/list"), + CODEXUI_CLIENT_REQUEST("mcpServer/resource/read"), + CODEXUI_CLIENT_REQUEST("mcpServer/event/stream/start"), + CODEXUI_CLIENT_REQUEST("mcpServer/event/stream/stop"), + CODEXUI_CLIENT_REQUEST("mcpServer/tool/call"), + CODEXUI_CLIENT_REQUEST("windowsSandbox/setupStart"), + CODEXUI_CLIENT_REQUEST("windowsSandbox/readiness"), + CODEXUI_CLIENT_REQUEST("account/login/start"), + CODEXUI_CLIENT_REQUEST("account/bedrock/discover"), + CODEXUI_CLIENT_REQUEST("account/bedrock/setup"), + CODEXUI_CLIENT_REQUEST("account/login/cancel"), + CODEXUI_CLIENT_REQUEST("account/logout"), + CODEXUI_CLIENT_REQUEST("account/rateLimits/read"), + CODEXUI_CLIENT_REQUEST("account/rateLimitResetCredit/consume"), + CODEXUI_CLIENT_REQUEST("account/usage/read"), + CODEXUI_CLIENT_REQUEST("account/workspaceMessages/read"), + CODEXUI_CLIENT_REQUEST("account/sendAddCreditsNudgeEmail"), + CODEXUI_CLIENT_REQUEST("feedback/upload"), + CODEXUI_CLIENT_REQUEST("command/exec"), + CODEXUI_CLIENT_REQUEST("command/exec/write"), + CODEXUI_CLIENT_REQUEST("command/exec/terminate"), + CODEXUI_CLIENT_REQUEST("command/exec/resize"), + CODEXUI_CLIENT_REQUEST("process/spawn"), + CODEXUI_CLIENT_REQUEST("process/writeStdin"), + CODEXUI_CLIENT_REQUEST("process/kill"), + CODEXUI_CLIENT_REQUEST("process/resizePty"), + CODEXUI_CLIENT_REQUEST("config/read"), + CODEXUI_CLIENT_REQUEST("externalAgentConfig/detect"), + CODEXUI_CLIENT_REQUEST("externalAgentConfig/import"), + CODEXUI_CLIENT_REQUEST("externalAgentConfig/import/recordHistory"), + CODEXUI_CLIENT_REQUEST("externalAgentConfig/import/readHistories"), + CODEXUI_CLIENT_REQUEST("config/value/write"), + CODEXUI_CLIENT_REQUEST("config/batchWrite"), + CODEXUI_CLIENT_REQUEST("configRequirements/read"), + CODEXUI_CLIENT_REQUEST("account/read"), + CODEXUI_CLIENT_REQUEST("getConversationSummary"), + CODEXUI_CLIENT_REQUEST("gitDiffToRemote"), + CODEXUI_CLIENT_REQUEST("getAuthStatus"), + CODEXUI_CLIENT_REQUEST("fuzzyFileSearch"), + CODEXUI_CLIENT_REQUEST("fuzzyFileSearch/sessionStart"), + CODEXUI_CLIENT_REQUEST("fuzzyFileSearch/sessionUpdate"), + CODEXUI_CLIENT_REQUEST("fuzzyFileSearch/sessionStop"), + + CODEXUI_SERVER_REQUEST("item/commandExecution/requestApproval"), + CODEXUI_SERVER_REQUEST("item/fileChange/requestApproval"), + CODEXUI_SERVER_REQUEST("item/tool/requestUserInput"), + CODEXUI_SERVER_REQUEST("mcpServer/elicitation/request"), + CODEXUI_SERVER_REQUEST("item/permissions/requestApproval"), + CODEXUI_SERVER_REQUEST("item/tool/call"), + CODEXUI_SERVER_REQUEST("account/chatgptAuthTokens/refresh"), + CODEXUI_SERVER_REQUEST("attestation/generate"), + CODEXUI_SERVER_REQUEST("currentTime/read"), + CODEXUI_SERVER_REQUEST("applyPatchApproval"), + CODEXUI_SERVER_REQUEST("execCommandApproval"), + + CODEXUI_UI_NOTIFICATION("error"), + CODEXUI_GRAPH_NOTIFICATION("thread/started"), + CODEXUI_GRAPH_NOTIFICATION("thread/status/changed"), + CODEXUI_GRAPH_NOTIFICATION("thread/archived"), + CODEXUI_GRAPH_NOTIFICATION("thread/deleted"), + CODEXUI_GRAPH_NOTIFICATION("thread/unarchived"), + CODEXUI_GRAPH_NOTIFICATION("thread/closed"), + CODEXUI_GRAPH_NOTIFICATION("thread/reverted"), + CODEXUI_GRAPH_NOTIFICATION("skills/changed"), + CODEXUI_GRAPH_NOTIFICATION("thread/name/updated"), + CODEXUI_GRAPH_NOTIFICATION("thread/goal/updated"), + CODEXUI_GRAPH_NOTIFICATION("thread/goal/cleared"), + CODEXUI_GRAPH_NOTIFICATION("thread/queue/changed"), + CODEXUI_GRAPH_NOTIFICATION("project/changed"), + CODEXUI_GRAPH_NOTIFICATION("thread/project/updated"), + CODEXUI_GRAPH_NOTIFICATION("thread/environment/connected"), + CODEXUI_GRAPH_NOTIFICATION("thread/environment/disconnected"), + CODEXUI_GRAPH_NOTIFICATION("thread/settings/updated"), + CODEXUI_GRAPH_NOTIFICATION("thread/tokenUsage/updated"), + CODEXUI_GRAPH_NOTIFICATION("turn/started"), + CODEXUI_GRAPH_NOTIFICATION("hook/started"), + CODEXUI_GRAPH_NOTIFICATION("turn/completed"), + CODEXUI_GRAPH_NOTIFICATION("hook/completed"), + CODEXUI_GRAPH_NOTIFICATION("turn/diff/updated"), + CODEXUI_GRAPH_NOTIFICATION("turn/plan/updated"), + CODEXUI_GRAPH_NOTIFICATION("item/started"), + CODEXUI_GRAPH_NOTIFICATION("item/autoApprovalReview/started"), + CODEXUI_GRAPH_NOTIFICATION("item/autoApprovalReview/completed"), + CODEXUI_GRAPH_NOTIFICATION("autoApprovalReview/strictReviewRequired"), + CODEXUI_GRAPH_NOTIFICATION("item/completed"), + CODEXUI_NEUTRAL_NOTIFICATION("rawResponseItem/completed"), + CODEXUI_NEUTRAL_NOTIFICATION("rawResponse/completed"), + CODEXUI_GRAPH_NOTIFICATION("item/agentMessage/delta"), + CODEXUI_GRAPH_NOTIFICATION("item/plan/delta"), + CODEXUI_GRAPH_NOTIFICATION("command/exec/outputDelta"), + CODEXUI_GRAPH_NOTIFICATION("process/outputDelta"), + CODEXUI_GRAPH_NOTIFICATION("process/exited"), + CODEXUI_GRAPH_NOTIFICATION("item/commandExecution/outputDelta"), + CODEXUI_GRAPH_NOTIFICATION("item/commandExecution/terminalInteraction"), + CODEXUI_GRAPH_NOTIFICATION("item/fileChange/outputDelta"), + CODEXUI_GRAPH_NOTIFICATION("item/fileChange/patchUpdated"), + CODEXUI_GRAPH_NOTIFICATION("serverRequest/resolved"), + CODEXUI_GRAPH_NOTIFICATION("item/mcpToolCall/progress"), + CODEXUI_GRAPH_NOTIFICATION("mcpServer/oauthLogin/completed"), + CODEXUI_GRAPH_NOTIFICATION("mcpServer/startupStatus/updated"), + CODEXUI_GRAPH_NOTIFICATION("mcpServer/event/stream/notification"), + CODEXUI_GRAPH_NOTIFICATION("account/updated"), + CODEXUI_GRAPH_NOTIFICATION("account/rateLimits/updated"), + CODEXUI_GRAPH_NOTIFICATION("app/list/updated"), + CODEXUI_GRAPH_NOTIFICATION("remoteControl/status/changed"), + CODEXUI_GRAPH_NOTIFICATION("externalAgentConfig/import/progress"), + CODEXUI_GRAPH_NOTIFICATION("externalAgentConfig/import/completed"), + CODEXUI_GRAPH_NOTIFICATION("fs/changed"), + CODEXUI_GRAPH_NOTIFICATION("item/reasoning/summaryTextDelta"), + CODEXUI_GRAPH_NOTIFICATION("item/reasoning/summaryPartAdded"), + CODEXUI_GRAPH_NOTIFICATION("item/reasoning/textDelta"), + CODEXUI_GRAPH_NOTIFICATION("thread/compacted"), + CODEXUI_GRAPH_NOTIFICATION("model/rerouted"), + CODEXUI_GRAPH_NOTIFICATION("model/verification"), + CODEXUI_GRAPH_NOTIFICATION("modelProvider/authRecoveryStarted"), + CODEXUI_GRAPH_NOTIFICATION("modelProvider/authRecoveryCompleted"), + CODEXUI_GRAPH_NOTIFICATION("turn/moderationMetadata"), + CODEXUI_GRAPH_NOTIFICATION("model/safetyBuffering/updated"), + CODEXUI_UI_NOTIFICATION("warning"), + CODEXUI_UI_NOTIFICATION("guardianWarning"), + CODEXUI_UI_NOTIFICATION("deprecationNotice"), + CODEXUI_UI_NOTIFICATION("configWarning"), + CODEXUI_GRAPH_NOTIFICATION("fuzzyFileSearch/sessionUpdated"), + CODEXUI_GRAPH_NOTIFICATION("fuzzyFileSearch/sessionCompleted"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/started"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/itemAdded"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/item/started"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/item/transcript/delta"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/item/completed"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/transcript/delta"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/transcript/done"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/outputAudio/delta"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/sdp"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/error"), + CODEXUI_GRAPH_NOTIFICATION("thread/realtime/closed"), + CODEXUI_UI_NOTIFICATION("windows/worldWritableWarning"), + CODEXUI_GRAPH_NOTIFICATION("windowsSandbox/setupCompleted"), + CODEXUI_GRAPH_NOTIFICATION("account/login/completed"), + + CODEXUI_CLIENT_NOTIFICATION("initialized"), +}}; + +#undef CODEXUI_CLIENT_REQUEST +#undef CODEXUI_SERVER_REQUEST +#undef CODEXUI_UI_NOTIFICATION +#undef CODEXUI_GRAPH_NOTIFICATION +#undef CODEXUI_NEUTRAL_NOTIFICATION +#undef CODEXUI_CLIENT_NOTIFICATION + +constexpr std::size_t countDirection(ProtocolDirection direction) noexcept { + std::size_t count = 0; + for (const MethodDescriptor &descriptor : Methods) { + if (descriptor.direction == direction) + ++count; + } + return count; +} + +constexpr std::size_t +countDisposition(MessageDisposition disposition) noexcept { + std::size_t count = 0; + for (const MethodDescriptor &descriptor : Methods) { + if (descriptor.disposition == disposition) + ++count; + } + return count; +} + +constexpr bool hasUniqueKeys() noexcept { + for (std::size_t left = 0; left < Methods.size(); ++left) { + if (Methods[left].method.empty()) + return false; + for (std::size_t right = left + 1; right < Methods.size(); ++right) { + if (Methods[left].direction == Methods[right].direction && + Methods[left].method == Methods[right].method) + return false; + } + } + return true; +} + +static_assert(Methods.size() == 252); +static_assert(countDirection(ProtocolDirection::ClientRequest) == 157); +static_assert(countDirection(ProtocolDirection::ServerRequest) == 11); +static_assert(countDirection(ProtocolDirection::ServerNotification) == 83); +static_assert(countDirection(ProtocolDirection::ClientNotification) == 1); +static_assert(countDisposition(MessageDisposition::WorkerOperationResult) == + 158); +static_assert(countDisposition(MessageDisposition::ReverseInteraction) == 11); +static_assert(countDisposition(MessageDisposition::GraphUpdate) == 75); +static_assert(countDisposition(MessageDisposition::IntentionallyStateNeutral) == + 2); +static_assert(countDisposition(MessageDisposition::TypedUiEffect) == 6); +static_assert(hasUniqueKeys()); + +} // namespace + +std::span protocolMethods() noexcept { return Methods; } + +std::optional> +findProtocolMethod(ProtocolDirection direction, + std::string_view method) noexcept { + for (const MethodDescriptor &descriptor : Methods) { + if (descriptor.direction == direction && descriptor.method == method) + return std::cref(descriptor); + } + return std::nullopt; +} + +std::size_t protocolMethodCount(ProtocolDirection direction) noexcept { + return countDirection(direction); +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/ProtocolCatalog.h b/src/codex/nodegraph/ProtocolCatalog.h new file mode 100644 index 0000000..9e5c26a --- /dev/null +++ b/src/codex/nodegraph/ProtocolCatalog.h @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_PROTOCOLCATALOG_H +#define CODEXUI_CODEX_NODEGRAPH_PROTOCOLCATALOG_H + +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +enum class ProtocolDirection { + ClientRequest, + ServerRequest, + ServerNotification, + ClientNotification, +}; + +enum class MessageDisposition { + GraphUpdate, + WorkerOperationResult, + ReverseInteraction, + TypedUiEffect, + IntentionallyStateNeutral, +}; + +struct MethodDescriptor { + std::string_view method; + ProtocolDirection direction; + MessageDisposition disposition; +}; + +[[nodiscard]] std::span protocolMethods() noexcept; + +[[nodiscard]] std::optional> +findProtocolMethod(ProtocolDirection direction, + std::string_view method) noexcept; + +[[nodiscard]] std::size_t +protocolMethodCount(ProtocolDirection direction) noexcept; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_PROTOCOLCATALOG_H diff --git a/src/codex/nodegraph/ProtocolUpdater.cpp b/src/codex/nodegraph/ProtocolUpdater.cpp new file mode 100644 index 0000000..a078a05 --- /dev/null +++ b/src/codex/nodegraph/ProtocolUpdater.cpp @@ -0,0 +1,3435 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/ProtocolUpdater.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { +namespace { + +constexpr std::size_t MaximumRetainedStreamBytes = 256 * 1024; +constexpr std::size_t RetainedStreamTailBytes = 192 * 1024; +constexpr std::size_t MaximumIndexedTextParts = 4096; + +const Value *member(const Value::Object &object, std::string_view name) { + const auto found = object.find(name); + return found == object.end() ? nullptr : &found->second; +} + +const Value::Object *objectMember(const Value::Object &object, + std::string_view name) { + const Value *value = member(object, name); + return value ? value->asObject() : nullptr; +} + +const Value::Array *arrayMember(const Value::Object &object, + std::string_view name) { + const Value *value = member(object, name); + return value ? value->asArray() : nullptr; +} + +std::string canonicalValue(const Value *value) { + if (!value) + return {}; + if (const std::string *text = value->asString()) + return *text; + if (const std::int64_t *number = value->asInt64()) + return std::to_string(*number); + if (const std::uint64_t *number = value->asUInt64()) + return std::to_string(*number); + return {}; +} + +bool isLocalPrompt(NodeGraph::WriteAccess &write, const NodeRef &node) { + if (!node || node->id().kind != NodeKind::Item) + return false; + const std::shared_ptr state = write.state(node); + const Value *type = member(state->fields, "type"); + return type && type->asString() && *type->asString() == "localPrompt"; +} + +std::string firstId(const Value::Object &object, + std::span names) { + for (const std::string_view name : names) { + std::string value = canonicalValue(member(object, name)); + if (!value.empty()) + return value; + } + return {}; +} + +std::string scopedCanonical(std::string_view owner, + std::string_view protocolId) { + std::string result = "scope:"; + result += std::to_string(owner.size()); + result += ':'; + result += owner; + result += ':'; + result += std::to_string(protocolId.size()); + result += ':'; + result += protocolId; + return result; +} + +NodeStatus statusFromValue(const Value *value) { + if (const Value::Object *object = value ? value->asObject() : nullptr) + value = member(*object, "type"); + const std::string *status = value ? value->asString() : nullptr; + if (!status) + return NodeStatus::Unknown; + if (*status == "pending" || *status == "queued") + return NodeStatus::Pending; + if (*status == "running" || *status == "inProgress" || *status == "active") + return NodeStatus::Running; + if (*status == "completed" || *status == "complete" || + *status == "succeeded" || *status == "idle") + return NodeStatus::Completed; + if (*status == "failed" || *status == "error" || *status == "systemError") + return NodeStatus::Failed; + if (*status == "blocked") + return NodeStatus::Failed; + if (*status == "interrupted" || *status == "cancelled" || + *status == "stopped") + return NodeStatus::Interrupted; + if (*status == "notLoaded") + return NodeStatus::NotLoaded; + if (*status == "connected") + return NodeStatus::Connected; + if (*status == "disconnected") + return NodeStatus::Disconnected; + return NodeStatus::Unknown; +} + +void mergeObject(NodeGraph::WriteAccess &write, const NodeRef &node, + const Value::Object &object, + std::string_view omittedChildField = {}, + std::optional preserveChangesAfter = {}) { + NodeState next = *write.state(node); + for (const auto &[key, value] : object) { + if (!omittedChildField.empty() && key == omittedChildField) + continue; + if (preserveChangesAfter && + write.fieldChangedRevision(node, key) > *preserveChangesAfter) + continue; + next.fields.insert_or_assign(key, value); + } + if (const Value *status = member(object, "status"); + status && (!preserveChangesAfter || + write.statusChangedRevision(node) <= *preserveChangesAfter)) + next.status = statusFromValue(status); + write.replaceState(node, std::move(next)); +} + +std::uint64_t unsignedValue(const Value *value) noexcept { + if (const std::uint64_t *number = value ? value->asUInt64() : nullptr) + return *number; + if (const std::int64_t *number = value ? value->asInt64() : nullptr; + number && *number >= 0) + return static_cast(*number); + return 0; +} + +std::size_t utf8TailStart(std::string_view value, + std::size_t retainedBytes) noexcept { + if (value.size() <= retainedBytes) + return 0; + std::size_t start = value.size() - retainedBytes; + while (start < value.size() && + (static_cast(value[start]) & 0xc0U) == 0x80U) + ++start; + return start; +} + +Value::Object *textRetention(NodeState &state) { + Value &retention = state.fields["textRetention"]; + if (!retention.isObject()) + retention = Value::Object{}; + return retention.asObject(); +} + +const Value::Object *textRetention(const NodeState &state) { + const Value *retention = member(state.fields, "textRetention"); + return retention ? retention->asObject() : nullptr; +} + +bool hasTextRetention(const NodeState &state, std::string_view field) { + const Value::Object *retention = textRetention(state); + return retention && member(*retention, field); +} + +void clearTextRetention(NodeState &state, std::string_view field) { + Value *retentionValue = nullptr; + if (auto found = state.fields.find("textRetention"); + found != state.fields.end()) + retentionValue = &found->second; + Value::Object *retention = + retentionValue ? retentionValue->asObject() : nullptr; + if (!retention) + return; + retention->erase(std::string(field)); + if (retention->empty()) + state.fields.erase("textRetention"); +} + +void updateTextRetention(NodeState &state, std::string_view field, + std::size_t retainedBytes, + std::size_t newlyDiscardedBytes) { + Value::Object *retention = textRetention(state); + Value &entryValue = (*retention)[std::string(field)]; + if (!entryValue.isObject()) + entryValue = Value::Object{}; + Value::Object &entry = *entryValue.asObject(); + const std::uint64_t previous = unsignedValue(member(entry, "discardedBytes")); + const std::uint64_t increment = + static_cast(newlyDiscardedBytes); + const std::uint64_t discarded = + increment > std::numeric_limits::max() - previous + ? std::numeric_limits::max() + : previous + increment; + entry.insert_or_assign("discardedBytes", Value(discarded)); + entry.insert_or_assign("retainedBytes", + Value(static_cast(retainedBytes))); +} + +void boundScalarText(NodeState &state, std::string_view field) { + const auto found = state.fields.find(field); + std::string *value = + found == state.fields.end() ? nullptr : found->second.asString(); + if (!value) + return; + std::size_t discarded = 0; + if (value->size() > MaximumRetainedStreamBytes) { + discarded = utf8TailStart(*value, RetainedStreamTailBytes); + value->erase(0, discarded); + } + if (discarded != 0 || hasTextRetention(state, field)) + updateTextRetention(state, field, value->size(), discarded); +} + +void boundIndexedText(NodeState &state, std::string_view field) { + const auto found = state.fields.find(field); + Value::Array *parts = + found == state.fields.end() ? nullptr : found->second.asArray(); + if (!parts) + return; + + std::uint64_t total = 0; + for (const Value &part : *parts) { + const std::string *text = part.asString(); + if (!text) + continue; + const std::uint64_t bytes = static_cast(text->size()); + total = bytes > std::numeric_limits::max() - total + ? std::numeric_limits::max() + : total + bytes; + } + + std::size_t discarded = 0; + if (total > MaximumRetainedStreamBytes) { + std::uint64_t toDiscard = total - RetainedStreamTailBytes; + for (Value &part : *parts) { + std::string *text = part.asString(); + if (!text || text->empty() || toDiscard == 0) + continue; + std::size_t count = 0; + if (toDiscard >= text->size()) { + count = text->size(); + } else { + count = utf8TailStart(*text, text->size() - + static_cast(toDiscard)); + } + text->erase(0, count); + toDiscard -= std::min(toDiscard, count); + discarded += count; + } + } + + std::size_t retained = 0; + for (const Value &part : *parts) + if (const std::string *text = part.asString()) + retained += text->size(); + if (discarded != 0 || hasTextRetention(state, field)) + updateTextRetention(state, field, retained, discarded); +} + +void boundRetainedItemText( + NodeGraph::WriteAccess &write, const NodeRef &item, + const Value::Object &incoming, + std::optional preserveChangesAfter = {}) { + NodeState next = *write.state(item); + for (const std::string_view field : + {std::string_view("text"), std::string_view("output"), + std::string_view("aggregatedOutput"), std::string_view("summary"), + std::string_view("content")}) { + if (incoming.contains(field) && + (!preserveChangesAfter || + write.fieldChangedRevision(item, field) <= *preserveChangesAfter)) + clearTextRetention(next, field); + } + + const std::string type = canonicalValue(member(next.fields, "type")); + if (type == "commandExecution") { + boundScalarText(next, "aggregatedOutput"); + boundScalarText(next, "output"); + } else if (type == "agentMessage" || type == "plan") { + boundScalarText(next, "text"); + } else if (type == "reasoning") { + boundIndexedText(next, "summary"); + boundIndexedText(next, "content"); + } else if (type == "fileChange") { + boundScalarText(next, "output"); + } else if (type != "userMessage") { + boundScalarText(next, "text"); + boundScalarText(next, "output"); + boundScalarText(next, "aggregatedOutput"); + boundIndexedText(next, "summary"); + boundIndexedText(next, "content"); + } + write.replaceState(item, std::move(next)); +} + +void appendBoundedStringField(NodeGraph::WriteAccess &write, + const NodeRef &node, std::string field, + std::string_view suffix) { + NodeState next = *write.state(node); + Value &stored = next.fields[field]; + if (!stored.isString()) + stored = std::string{}; + std::string &existing = *stored.asString(); + + std::size_t discarded = 0; + if (suffix.size() > MaximumRetainedStreamBytes) { + const std::size_t start = utf8TailStart(suffix, RetainedStreamTailBytes); + discarded = existing.size() + start; + existing.assign(suffix.substr(start)); + } else { + existing.append(suffix); + if (existing.size() > MaximumRetainedStreamBytes) { + discarded = utf8TailStart(existing, RetainedStreamTailBytes); + existing.erase(0, discarded); + } + } + const std::size_t retained = existing.size(); + if (discarded != 0 || hasTextRetention(next, field)) + updateTextRetention(next, field, retained, discarded); + write.replaceState(node, std::move(next)); +} + +void mergeSparseRateLimitFields(Value::Object ¤t, + const Value::Object &patch) { + for (const auto &[key, value] : patch) { + const auto found = current.find(key); + const Value::Object *patchObject = value.asObject(); + if (found != current.end() && patchObject) { + if (Value::Object *currentObject = found->second.asObject()) { + mergeSparseRateLimitFields(*currentObject, *patchObject); + continue; + } + } + current.insert_or_assign(key, value); + } +} + +std::string retainedProtocolId(NodeGraph::WriteAccess &write, + const NodeRef &node) { + if (!node) + return {}; + const std::shared_ptr state = write.state(node); + const auto found = state->fields.find("protocolId"); + return found == state->fields.end() ? node->id().canonical + : canonicalValue(&found->second); +} + +NodeRef ensureTurn(NodeGraph::WriteAccess &write, const NodeRef &thread, + std::string_view rawTurnId) { + if (!thread || thread->id().kind != NodeKind::Thread || rawTurnId.empty()) + return {}; + NodeId id = scopedTurnNodeId(thread->id().canonical, rawTurnId); + NodeRef turn = write.find(id); + if (turn) { + const std::shared_ptr current = write.state(turn); + if (canonicalValue(member(current->fields, "protocolId")) != rawTurnId || + canonicalValue(member(current->fields, "protocolThreadId")) != + thread->id().canonical) { + NodeState next = *current; + next.fields.insert_or_assign("protocolId", Value(rawTurnId)); + next.fields.insert_or_assign("protocolThreadId", + Value(thread->id().canonical)); + write.replaceState(turn, std::move(next)); + } + } else { + NodeState next; + next.fields = {{"protocolId", Value(rawTurnId)}, + {"protocolThreadId", Value(thread->id().canonical)}}; + turn = write.upsert(std::move(id), std::move(next)); + } + write.setParent(thread, turn); + return turn; +} + +bool isActiveTurn(const NodeState &state) { + const std::string status = canonicalValue(member(state.fields, "status")); + return state.status == NodeStatus::Running || status == "active" || + status == "running" || status == "inProgress"; +} + +void updateActiveTurn(NodeGraph::WriteAccess &write, const NodeRef &thread, + const NodeRef &turn) { + if (!thread || !turn) + return; + if (isActiveTurn(*write.state(turn))) { + const std::array active{turn}; + write.replaceRelated(thread, RelationKind::ActiveTurn, active); + return; + } + write.unrelate(thread, RelationKind::ActiveTurn, turn); +} + +void refreshActiveTurn(NodeGraph::WriteAccess &write, const NodeRef &thread) { + NodeRef active; + const std::vector turns = write.children(thread); + for (auto iterator = turns.rbegin(); iterator != turns.rend(); ++iterator) { + if (*iterator && (*iterator)->id().kind == NodeKind::Turn && + isActiveTurn(*write.state(*iterator))) { + active = *iterator; + break; + } + } + if (active) { + const std::array current{std::move(active)}; + write.replaceRelated(thread, RelationKind::ActiveTurn, current); + } else { + write.replaceRelated(thread, RelationKind::ActiveTurn, + std::span{}); + } +} + +void mergeEffectiveThreadSettings(NodeGraph::WriteAccess &write, + const NodeRef &thread, + const Value::Object &settings) { + if (!thread) + return; + if (settings.contains("effort")) + write.eraseField(thread, "reasoningEffort"); + if (settings.contains("reasoningEffort")) + write.eraseField(thread, "effort"); + if (settings.contains("sandboxPolicy")) + write.eraseField(thread, "sandbox"); + if (settings.contains("sandbox")) + write.eraseField(thread, "sandboxPolicy"); + for (const auto &[key, value] : settings) { + if (value.isNull()) + write.eraseField(thread, key); + else + write.setField(thread, key, value); + } +} + +void mergeThreadResultSettings(NodeGraph::WriteAccess &write, + const NodeRef &thread, + const Value::Object &result) { + Value::Object settings; + for (const std::string_view key : + {std::string_view("approvalPolicy"), + std::string_view("approvalsReviewer"), std::string_view("cwd"), + std::string_view("instructionSources"), std::string_view("model"), + std::string_view("modelProvider"), std::string_view("reasoningEffort"), + std::string_view("sandbox"), std::string_view("serviceTier"), + std::string_view("activePermissionProfile")}) { + if (const Value *value = member(result, key)) + settings.emplace(key, *value); + } + mergeEffectiveThreadSettings(write, thread, settings); +} + +NodeRef ensureItem(NodeGraph::WriteAccess &write, const NodeRef &turn, + std::string_view rawItemId) { + if (!turn || turn->id().kind != NodeKind::Turn || rawItemId.empty()) + return {}; + NodeId id = scopedItemNodeId(turn->id(), rawItemId); + NodeRef item = write.find(id); + const std::string turnId = retainedProtocolId(write, turn); + const NodeRef thread = write.parent(turn); + const std::string threadId = thread ? thread->id().canonical : std::string{}; + if (item) { + const std::shared_ptr current = write.state(item); + const bool protocolChanged = + canonicalValue(member(current->fields, "protocolId")) != rawItemId || + canonicalValue(member(current->fields, "protocolTurnId")) != turnId || + (!threadId.empty() && + canonicalValue(member(current->fields, "protocolThreadId")) != + threadId); + if (protocolChanged) { + NodeState next = *current; + next.fields.insert_or_assign("protocolId", Value(rawItemId)); + next.fields.insert_or_assign("protocolTurnId", Value(turnId)); + if (!threadId.empty()) + next.fields.insert_or_assign("protocolThreadId", Value(threadId)); + write.replaceState(item, std::move(next)); + } + } else { + NodeState next; + next.fields = {{"protocolId", Value(rawItemId)}, + {"protocolTurnId", Value(turnId)}}; + if (!threadId.empty()) + next.fields.emplace("protocolThreadId", Value(threadId)); + item = write.upsert(std::move(id), std::move(next)); + } + write.setParent(turn, item); + return item; +} + +NodeRef applyHookNotification(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const Value::Object *run = objectMember(message.payload, "run"); + const std::string runId = + run ? canonicalValue(member(*run, "id")) : std::string{}; + if (runId.empty()) + return {}; + + NodeRef hook = write.upsert({NodeKind::Hook, runId}); + mergeObject(write, hook, *run); + write.setField(hook, "protocolId", Value(runId)); + write.setField(hook, "lastMethod", Value(message.method)); + + const Value *threadValue = member(message.payload, "threadId"); + const Value *turnValue = member(message.payload, "turnId"); + const std::string threadId = canonicalValue(threadValue); + const std::string turnId = canonicalValue(turnValue); + if (threadId.empty()) { + write.eraseField(hook, "threadId"); + write.eraseField(hook, "protocolThreadId"); + } else { + write.setField(hook, "threadId", Value(threadId)); + write.setField(hook, "protocolThreadId", Value(threadId)); + } + if (turnId.empty()) { + write.eraseField(hook, "turnId"); + write.eraseField(hook, "protocolTurnId"); + } else { + write.setField(hook, "turnId", Value(turnId)); + write.setField(hook, "protocolTurnId", Value(turnId)); + } + + if (!threadId.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + NodeRef owner = thread; + if (!turnId.empty()) + if (NodeRef turn = ensureTurn(write, thread, turnId)) + owner = std::move(turn); + write.setParent(owner, hook); + } + + const Value *rawStatus = member(*run, "status"); + NodeStatus status = statusFromValue(rawStatus); + if (status == NodeStatus::Unknown) { + const bool started = message.method == "hook/started"; + status = started ? NodeStatus::Running : NodeStatus::Completed; + if (!rawStatus) + write.setField(hook, "status", Value(started ? "running" : "completed")); + } + write.setStatus(hook, status); + return hook; +} + +std::optional indexValue(const Value *value) { + if (!value) + return std::nullopt; + if (const std::uint64_t *index = value->asUInt64()) + return static_cast(*index); + if (const std::int64_t *index = value->asInt64(); index && *index >= 0) + return static_cast(*index); + return std::nullopt; +} + +void appendIndexedField(NodeGraph::WriteAccess &write, const NodeRef &node, + std::string field, std::size_t index, + std::string_view suffix) { + if (index >= MaximumIndexedTextParts) + return; + NodeState next = *write.state(node); + Value &stored = next.fields[field]; + if (!stored.isArray()) + stored = Value::Array{}; + Value::Array &parts = *stored.asArray(); + if (parts.size() <= index) + parts.resize(index + 1); + if (!parts[index].isString()) + parts[index] = std::string{}; + parts[index].asString()->append(suffix); + boundIndexedText(next, field); + write.replaceState(node, std::move(next)); +} + +void appendSemanticDelta(NodeGraph::WriteAccess &write, const NodeRef &item, + std::string_view method, + const Value::Object &payload) { + if (method == "item/reasoning/summaryPartAdded") { + appendIndexedField(write, item, "summary", + indexValue(member(payload, "summaryIndex")).value_or(0), + {}); + return; + } + const std::string delta = canonicalValue(member(payload, "delta")); + if (delta.empty()) + return; + if (method == "item/reasoning/summaryTextDelta") { + appendIndexedField(write, item, "summary", + indexValue(member(payload, "summaryIndex")).value_or(0), + delta); + return; + } + if (method == "item/reasoning/textDelta") { + appendIndexedField(write, item, "content", + indexValue(member(payload, "contentIndex")).value_or(0), + delta); + return; + } + if (method == "item/commandExecution/outputDelta") { + appendBoundedStringField(write, item, "aggregatedOutput", delta); + return; + } + if (method == "item/fileChange/outputDelta") { + appendBoundedStringField(write, item, "output", delta); + return; + } + appendBoundedStringField(write, item, "text", delta); +} + +std::string addressedId(const Value::Object &payload, NodeKind kind) { + switch (kind) { + case NodeKind::Thread: { + constexpr std::array names{std::string_view("threadId"), + std::string_view("conversationId")}; + return firstId(payload, names); + } + case NodeKind::Turn: { + constexpr std::array names{std::string_view("turnId")}; + return firstId(payload, names); + } + case NodeKind::Item: { + constexpr std::array names{std::string_view("itemId"), + std::string_view("callId")}; + return firstId(payload, names); + } + case NodeKind::Project: { + constexpr std::array names{std::string_view("projectId")}; + return firstId(payload, names); + } + case NodeKind::ThreadSection: { + constexpr std::array names{std::string_view("sectionId")}; + return firstId(payload, names); + } + case NodeKind::Process: { + constexpr std::array names{std::string_view("processId"), + std::string_view("processHandle"), + std::string_view("commandId")}; + return firstId(payload, names); + } + case NodeKind::RealtimeSession: { + constexpr std::array names{std::string_view("sessionId")}; + return firstId(payload, names); + } + case NodeKind::FilesystemWatch: { + constexpr std::array names{std::string_view("watchId")}; + return firstId(payload, names); + } + default: + return {}; + } +} + +std::string nestedId(const Value::Object &object, + std::string_view fallback = {}) { + constexpr std::array names{ + std::string_view("id"), std::string_view("threadId"), + std::string_view("turnId"), std::string_view("itemId")}; + std::string result = firstId(object, names); + return result.empty() ? std::string(fallback) : result; +} + +bool beginsWith(std::string_view value, std::string_view prefix) { + return value.starts_with(prefix); +} + +bool isItemDeltaMethod(std::string_view method) { + return method == "item/agentMessage/delta" || method == "item/plan/delta" || + method == "item/reasoning/summaryTextDelta" || + method == "item/reasoning/summaryPartAdded" || + method == "item/reasoning/textDelta" || + method == "item/commandExecution/outputDelta" || + method == "item/fileChange/outputDelta"; +} + +bool isProviderNoticeMethod(std::string_view method) { + return method == "error" || method == "warning" || + method == "guardianWarning" || method == "deprecationNotice" || + method == "configWarning" || method == "windows/worldWritableWarning"; +} + +std::string mcpServerNodeKey(std::string_view method, + const Value::Object &payload) { + if (method == "mcpServer/event/stream/notification") { + const std::string subscription = + canonicalValue(member(payload, "subscriptionId")); + return subscription.empty() ? std::string(method) + : scopedCanonical("subscription", subscription); + } + + const std::string name = canonicalValue(member(payload, "name")); + if (name.empty()) + return std::string(method); + const std::string threadId = canonicalValue(member(payload, "threadId")); + return scopedCanonical(threadId.empty() ? std::string_view("global") + : std::string_view(threadId), + name); +} + +NodeKind kindForMethod(std::string_view method) { + if (beginsWith(method, "item/")) + return NodeKind::Item; + if (beginsWith(method, "turn/")) + return NodeKind::Turn; + if (beginsWith(method, "thread/realtime/")) + return NodeKind::RealtimeSession; + if (beginsWith(method, "thread/")) + return NodeKind::Thread; + if (beginsWith(method, "project/")) + return NodeKind::Project; + if (beginsWith(method, "threadSection/")) + return NodeKind::ThreadSection; + if (beginsWith(method, "command/") || beginsWith(method, "process/")) + return NodeKind::Process; + if (beginsWith(method, "account/")) + return NodeKind::Account; + if (beginsWith(method, "config") || beginsWith(method, "skills/config")) + return NodeKind::Configuration; + if (beginsWith(method, "mcpServer/")) + return NodeKind::McpServer; + if (beginsWith(method, "fs/")) + return NodeKind::FilesystemWatch; + if (method == "error" || method == "warning" || method == "guardianWarning" || + method == "deprecationNotice" || method == "configWarning" || + method == "windows/worldWritableWarning") + return NodeKind::Notice; + return NodeKind::Catalog; +} + +std::string catalogKey(std::string_view method) { + const std::size_t slash = method.find('/'); + return std::string(method.substr(0, slash)); +} + +struct CatalogEntitySeed final { + NodeKind kind = NodeKind::CatalogEntry; + const Value::Object *fields = nullptr; + std::string scope; +}; + +std::string catalogEntityId(const Value::Object &fields) { + constexpr std::array names{ + std::string_view("id"), std::string_view("model"), + std::string_view("key"), std::string_view("name"), + std::string_view("path"), std::string_view("connectorId"), + std::string_view("runtimeName")}; + return firstId(fields, names); +} + +void appendDirectCatalogEntries(std::vector &entries, + const Value::Array *values, NodeKind kind, + std::string_view scope = {}) { + if (!values) + return; + entries.reserve(entries.size() + values->size()); + for (const Value &value : *values) { + if (const Value::Object *object = value.asObject()) + entries.push_back({kind, object, std::string(scope)}); + } +} + +// Catalog envelopes retain cursors/errors on their Catalog node. Concrete +// rows live as ordered child nodes so declared entity kinds are not opaque +// blobs and stable NodeRefs survive ordinary refreshes. +bool applyNaturalCatalogSnapshot(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const std::string_view method = message.method; + const bool appNotification = + message.kind == DecodedMessageKind::ServerNotification && + method == "app/list/updated"; + if (message.kind != DecodedMessageKind::ClientResult && !appNotification) + return false; + + std::string_view catalogId; + NodeKind directKind = NodeKind::CatalogEntry; + const Value::Array *directEntries = nullptr; + std::vector entries; + + if (method == "model/list") { + catalogId = "model"; + directEntries = arrayMember(message.payload, "data"); + } else if (method == "permissionProfile/list") { + catalogId = "permissionProfile"; + directKind = NodeKind::PermissionProfile; + directEntries = arrayMember(message.payload, "data"); + } else if (method == "experimentalFeature/list") { + catalogId = "experimentalFeature"; + directEntries = arrayMember(message.payload, "data"); + } else if (method == "collaborationMode/list") { + catalogId = "collaborationMode"; + directEntries = arrayMember(message.payload, "data"); + } else if (method == "mcpServerStatus/list") { + catalogId = "mcpServer"; + directKind = NodeKind::McpServer; + directEntries = arrayMember(message.payload, "data"); + } else if (method == "app/list" || appNotification) { + catalogId = "app"; + directKind = NodeKind::App; + directEntries = arrayMember(message.payload, "data"); + if (appNotification && !directEntries) + return false; + } else if (method == "skills/list") { + catalogId = "skills"; + if (const Value::Array *groups = arrayMember(message.payload, "data")) { + for (const Value &value : *groups) { + const Value::Object *group = value.asObject(); + if (!group) + continue; + appendDirectCatalogEntries(entries, arrayMember(*group, "skills"), + NodeKind::Skill, + canonicalValue(member(*group, "cwd"))); + } + } + } else if (method == "hooks/list") { + catalogId = "hooks"; + if (const Value::Array *groups = arrayMember(message.payload, "data")) { + for (const Value &value : *groups) { + const Value::Object *group = value.asObject(); + if (!group) + continue; + appendDirectCatalogEntries(entries, arrayMember(*group, "hooks"), + NodeKind::Hook, + canonicalValue(member(*group, "cwd"))); + } + } + } else if (method == "plugin/list") { + catalogId = "plugin"; + if (const Value::Array *marketplaces = + arrayMember(message.payload, "marketplaces")) { + for (const Value &value : *marketplaces) { + const Value::Object *marketplace = value.asObject(); + if (!marketplace) + continue; + std::string scope = canonicalValue(member(*marketplace, "name")); + if (scope.empty()) + scope = canonicalValue(member(*marketplace, "path")); + appendDirectCatalogEntries(entries, + arrayMember(*marketplace, "plugins"), + NodeKind::Plugin, scope); + } + } + } else { + return false; + } + + appendDirectCatalogEntries(entries, directEntries, directKind); + NodeRef catalog = write.upsert({NodeKind::Catalog, std::string(catalogId)}); + const std::vector previous = write.children(catalog); + mergeObject(write, catalog, message.payload); + write.setField(catalog, "lastMethod", Value(std::string(method))); + write.eraseField(catalog, "stale"); + write.eraseField(catalog, "invalidatedBy"); + + const bool continuation = + !canonicalValue(member(message.payload, "cursor")).empty(); + std::vector ordered = + continuation ? previous : std::vector{}; + ordered.reserve(ordered.size() + entries.size()); + std::unordered_set retained; + retained.reserve(ordered.size() + entries.size()); + for (const NodeRef &entry : ordered) + retained.insert(entry.get()); + + std::size_t anonymous = 0; + for (const CatalogEntitySeed &seed : entries) { + if (!seed.fields) + continue; + std::string protocolId = catalogEntityId(*seed.fields); + if (protocolId.empty()) + protocolId = "anonymous:" + std::to_string(anonymous++); + std::string owner(catalogId); + if (!seed.scope.empty()) { + owner += ':'; + owner += seed.scope; + } + NodeState state; + state.status = statusFromValue(member(*seed.fields, "status")); + state.fields = *seed.fields; + state.fields.insert_or_assign("protocolId", Value(protocolId)); + state.fields.insert_or_assign("catalog", Value(std::string(catalogId))); + if (!seed.scope.empty()) + state.fields.insert_or_assign("catalogScope", Value(seed.scope)); + NodeId id{seed.kind, scopedCanonical(owner, protocolId)}; + NodeRef entry = write.find(id); + if (entry) + write.replaceState(entry, std::move(state)); + else + entry = write.upsert(std::move(id), std::move(state)); + if (retained.insert(entry.get()).second) + ordered.emplace_back(std::move(entry)); + } + + write.replaceChildren(catalog, ordered); + if (!continuation) { + std::vector omitted; + omitted.reserve(previous.size()); + for (const NodeRef &entry : previous) + if (entry && !retained.contains(entry.get())) + omitted.emplace_back(entry); + write.removeMany(omitted); + } + return true; +} + +bool applyAutoApprovalReview(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const bool started = message.method == "item/autoApprovalReview/started"; + const bool completed = message.method == "item/autoApprovalReview/completed"; + if (!started && !completed) + return false; + + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + const std::string turnId = addressedId(message.payload, NodeKind::Turn); + if (threadId.empty() || turnId.empty()) + return true; + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + NodeRef turn = ensureTurn(write, thread, turnId); + + const std::string targetId = + canonicalValue(member(message.payload, "targetItemId")); + NodeRef target = ensureItem(write, turn, targetId); + std::string reviewId = canonicalValue(member(message.payload, "reviewId")); + if (reviewId.empty() && !targetId.empty()) + reviewId = "auto-review:" + targetId; + if (reviewId.empty()) { + write.setField(turn, "lastAutoApprovalReview", Value(message.payload)); + return true; + } + + NodeRef review = ensureItem(write, turn, reviewId); + mergeObject(write, review, message.payload); + write.setField(review, "type", Value("autoApprovalReview")); + write.setField(review, "phase", Value(started ? "started" : "completed")); + write.setField(review, "protocolId", Value(reviewId)); + write.setStatus(review, + started ? NodeStatus::Running : NodeStatus::Completed); + if (target) { + const std::array targets{target}; + write.replaceRelated(review, RelationKind::ReviewTarget, targets); + } else { + write.replaceRelated(review, RelationKind::ReviewTarget, + std::span{}); + } + return true; +} + +NodeRef containingThread(NodeGraph::WriteAccess &write, NodeRef node) { + while (node && node->id().kind != NodeKind::Thread) + node = write.parent(node); + return node; +} + +void refreshPendingInteractionCount(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + if (!thread) + return; + std::size_t pending = 0; + for (const NodeRef &interaction : + write.related(thread, RelationKind::PendingInteraction)) { + if (interaction && + (write.state(interaction)->status == NodeStatus::Pending || + write.state(interaction)->status == NodeStatus::Failed)) + ++pending; + } + write.setField(thread, "pendingInteractionCount", + Value(static_cast(pending))); +} + +void clearPendingInteractionOwner(NodeGraph::WriteAccess &write, + const NodeRef &interaction) { + if (NodeRef runtime = write.find({NodeKind::Runtime, "runtime"})) + write.unrelate(runtime, RelationKind::PendingInteraction, interaction); + for (const NodeRef &target : + write.related(interaction, RelationKind::InteractionTarget)) { + if (NodeRef thread = containingThread(write, target)) { + write.unrelate(thread, RelationKind::PendingInteraction, interaction); + refreshPendingInteractionCount(write, thread); + } + } +} + +NodeRef newestInteractionForRequest(NodeGraph::WriteAccess &write, + std::string_view requestId) { + const auto &nodes = write.orderedNodes(); + for (auto node = nodes.rbegin(); node != nodes.rend(); ++node) { + if (!*node || (*node)->id().kind != NodeKind::Interaction) + continue; + if (canonicalValue(member(write.state(*node)->fields, "requestId")) == + requestId) + return *node; + } + return {}; +} + +bool interactionGenerationDiffers(const NodeState &state, + const DecodedMessage &message) { + if (message.connectionGeneration) { + const Value *stored = member(state.fields, "connectionGeneration"); + if (!stored || unsignedValue(stored) != *message.connectionGeneration) + return true; + } + if (message.providerGeneration) { + const Value *stored = member(state.fields, "providerGeneration"); + if (!stored || unsignedValue(stored) != *message.providerGeneration) + return true; + } + return false; +} + +std::string scopedInteractionKey(const ProtocolRequestId &requestId, + const DecodedMessage &message) { + const std::string generation = + "connection:" + std::to_string(message.connectionGeneration.value_or(0)) + + ":provider:" + std::to_string(message.providerGeneration.value_or(0)); + return "request:" + scopedCanonical(generation, requestId.canonical()); +} + +std::vector mergeExistingTail(std::vector first, + const std::vector &existing) { + std::unordered_set seen; + seen.reserve(first.size() + existing.size()); + for (const NodeRef &node : first) + if (node) + seen.insert(node.get()); + for (const NodeRef &node : existing) { + if (node && seen.insert(node.get()).second) + first.emplace_back(node); + } + return first; +} + +void removeContained(NodeGraph::WriteAccess &write, const NodeRef &node); + +bool isExplicitLocalOptimistic(NodeGraph::WriteAccess &write, + const NodeRef &node) { + if (!node) + return false; + const NodeState &state = *write.state(node); + const Value *local = member(state.fields, "local"); + if (!local || !local->asBool() || !*local->asBool()) + return false; + const std::string type = canonicalValue(member(state.fields, "type")); + return type == "localPrompt" || type == "localTurn" || + type == "localThread" || type == "localRecoveryTurn" || + type == "localRecoveryThread"; +} + +void collectLocalOptimisticItems(NodeGraph::WriteAccess &write, + const NodeRef &node, + std::vector &items, + std::unordered_set &visited) { + if (!node || !visited.insert(node.get()).second) + return; + if (node->id().kind == NodeKind::Item && + isExplicitLocalOptimistic(write, node)) { + items.emplace_back(node); + return; + } + for (const NodeRef &child : write.children(node)) + collectLocalOptimisticItems(write, child, items, visited); + if (node->id().kind == NodeKind::Turn) { + for (const NodeRef &root : write.related(node, RelationKind::TurnRootItem)) + collectLocalOptimisticItems(write, root, items, visited); + } +} + +NodeRef preserveLocalOptimisticTail(NodeGraph::WriteAccess &write, + const NodeRef &thread, + const NodeRef &omitted) { + if (!thread || thread->id().kind != NodeKind::Thread || !omitted) + return {}; + std::vector prompts; + std::unordered_set visited; + collectLocalOptimisticItems(write, omitted, prompts, visited); + if (prompts.empty()) + return {}; + + NodeState state; + state.status = NodeStatus::Running; + state.fields = {{"type", Value("localTurn")}, + {"local", Value(true)}, + {"replacementCarrier", Value(true)}}; + const std::string carrierId = + "local-turn:replacement:" + + scopedCanonical(thread->id().canonical, + std::to_string(write.revision() + 1) + ':' + + omitted->id().canonical); + NodeRef carrier = write.upsert({NodeKind::Turn, carrierId}, std::move(state)); + write.setParent(thread, carrier); + for (const NodeRef &prompt : prompts) { + if (omitted->id().kind == NodeKind::Turn) + write.unrelate(omitted, RelationKind::TurnRootItem, prompt); + write.setParent(carrier, prompt); + } + return carrier; +} + +std::vector replaceAuthoritativeChildren( + NodeGraph::WriteAccess &write, const NodeRef &parent, + std::vector authoritative, const std::vector &existing) { + std::unordered_set retained; + retained.reserve(authoritative.size() + existing.size()); + for (const NodeRef &node : authoritative) + if (node) + retained.insert(node.get()); + + for (const NodeRef &node : existing) { + if (!node || retained.contains(node.get()) || + write.find(node->id()) != node) + continue; + if (isExplicitLocalOptimistic(write, node)) { + retained.insert(node.get()); + authoritative.emplace_back(node); + continue; + } + if (NodeRef carrier = preserveLocalOptimisticTail(write, parent, node)) { + retained.insert(carrier.get()); + authoritative.emplace_back(std::move(carrier)); + } + removeContained(write, node); + if (write.find(node->id()) == node) + write.remove(node); + } + return authoritative; +} + +bool hasThreadOwner(NodeGraph::WriteAccess &write, const NodeRef &child) { + return !write.related(child, RelationKind::ThreadOwner).empty(); +} + +void clearThreadOwners(NodeGraph::WriteAccess &write, const NodeRef &child) { + const std::vector owners = + write.related(child, RelationKind::ThreadOwner); + for (const NodeRef &owner : owners) { + write.unrelate(owner, RelationKind::StructuralChildThread, child); + write.unrelate(owner, RelationKind::AgentChildThread, child); + write.unrelate(child, RelationKind::ThreadOwner, owner); + } +} + +void clearStructuralThreadOwners(NodeGraph::WriteAccess &write, + const NodeRef &child) { + const std::vector owners = + write.related(child, RelationKind::ThreadOwner); + for (const NodeRef &owner : owners) { + const std::vector structural = + write.related(owner, RelationKind::StructuralChildThread); + if (std::ranges::find(structural, child) == structural.end()) + continue; + write.unrelate(owner, RelationKind::StructuralChildThread, child); + const std::vector agentChildren = + write.related(owner, RelationKind::AgentChildThread); + if (std::ranges::find(agentChildren, child) == agentChildren.end()) + write.unrelate(child, RelationKind::ThreadOwner, owner); + } +} + +void assignThreadOwner(NodeGraph::WriteAccess &write, const NodeRef &owner, + RelationKind kind, const NodeRef &child) { + if (!owner || !child || owner == child) + return; + clearThreadOwners(write, child); + write.relate(owner, kind, child); + write.relate(child, RelationKind::ThreadOwner, owner); + if (NodeRef runtime = write.find({NodeKind::Runtime, "runtime"})) { + std::vector roots = + write.related(runtime, RelationKind::RootThread); + const std::size_t before = roots.size(); + roots.erase(std::remove(roots.begin(), roots.end(), child), roots.end()); + if (roots.size() != before) + write.replaceRelated(runtime, RelationKind::RootThread, roots); + } +} + +void replaceSingleRelation(NodeGraph::WriteAccess &write, const NodeRef &source, + RelationKind kind, const NodeRef &target) { + if (target) { + std::array only{target}; + write.replaceRelated(source, kind, only); + } else { + write.replaceRelated(source, kind, std::span{}); + } +} + +void assignProject(NodeGraph::WriteAccess &write, const NodeRef &thread, + const Value *projectId) { + const std::string id = canonicalValue(projectId); + NodeRef project; + if (!id.empty()) + project = write.upsert({NodeKind::Project, id}); + replaceSingleRelation(write, thread, RelationKind::ProjectMembership, + project); + write.setField(thread, "projectId", projectId ? *projectId : Value(nullptr)); +} + +void assignSection(NodeGraph::WriteAccess &write, const NodeRef &thread, + const Value *sectionValue) { + NodeRef section; + if (const Value::Object *object = + sectionValue ? sectionValue->asObject() : nullptr) { + const std::string id = nestedId(*object); + if (!id.empty()) { + section = write.upsert({NodeKind::ThreadSection, id}); + mergeObject(write, section, *object); + } + } else { + const std::string id = canonicalValue(sectionValue); + if (!id.empty()) + section = write.upsert({NodeKind::ThreadSection, id}); + } + replaceSingleRelation(write, thread, RelationKind::SectionMembership, + section); + write.setField(thread, "section", + sectionValue ? *sectionValue : Value(nullptr)); +} + +std::vector agentChildIds(const Value::Object &item) { + std::vector children; + std::unordered_set seen; + std::string child = canonicalValue(member(item, "agentThreadId")); + if (!child.empty() && seen.insert(child).second) + children.emplace_back(std::move(child)); + const Value::Array *receivers = arrayMember(item, "receiverThreadIds"); + if (!receivers) + return children; + for (const Value &receiver : *receivers) { + child = canonicalValue(&receiver); + if (!child.empty() && seen.insert(child).second) + children.emplace_back(std::move(child)); + } + return children; +} + +std::vector referencedAgentChildren(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + std::vector referenced; + if (!thread) + return referenced; + + std::unordered_set seenItems; + std::unordered_set seenChildren; + for (const NodeRef &turn : write.children(thread)) { + if (!turn || turn->id().kind != NodeKind::Turn) + continue; + std::vector items = write.children(turn); + items = mergeExistingTail(std::move(items), + write.related(turn, RelationKind::TurnRootItem)); + for (const NodeRef &item : items) { + if (!item || item->id().kind != NodeKind::Item || + !seenItems.insert(item.get()).second) + continue; + for (const NodeRef &child : + write.related(item, RelationKind::AgentChildThread)) { + if (child && seenChildren.insert(child.get()).second) + referenced.emplace_back(child); + } + } + } + return referenced; +} + +bool isUserMessage(const Value::Object &item) { + return canonicalValue(member(item, "type")) == "userMessage"; +} + +void correlateLocalPrompt(NodeGraph::WriteAccess &write, + const NodeRef &authoritative, + const Value::Object &item) { + if (!authoritative || !isUserMessage(item)) + return; + const std::string clientId = canonicalValue(member(item, "clientId")); + if (clientId.empty()) + return; + NodeRef runtime = write.find({NodeKind::Runtime, "runtime"}); + if (!runtime) + return; + for (const NodeRef &local : + write.related(runtime, RelationKind::PendingPrompt)) { + if (!local || local->id().kind != NodeKind::Item) + continue; + const std::shared_ptr state = write.state(local); + const auto found = state->fields.find("clientUserMessageId"); + if (found == state->fields.end() || !found->second.asString() || + *found->second.asString() != clientId) + continue; + if (const auto submission = state->fields.find("submissionId"); + submission != state->fields.end()) + write.setField(authoritative, "localSubmissionId", submission->second); + std::array alias{local}; + write.replaceRelated(authoritative, RelationKind::PromptMaterialization, + alias); + // A steering prompt and its provider item share one Turn. The provider + // item may arrive after activity caused by that prompt, but the visible + // You card must retain the exact slot where the user submitted it. + const NodeRef localTurn = write.parent(local); + if (localTurn && write.parent(authoritative) == localTurn) { + std::vector ordered = write.children(localTurn); + ordered.erase(std::remove(ordered.begin(), ordered.end(), authoritative), + ordered.end()); + if (const auto position = std::ranges::find(ordered, local); + position != ordered.end()) + ordered.insert(std::next(position), authoritative); + else + ordered.push_back(authoritative); + write.replaceChildren(localTurn, ordered); + } + break; + } +} + +void updateLoadedHistoryItemCount(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + if (!thread) + return; + std::unordered_set loaded; + for (const NodeRef &turn : write.children(thread)) { + if (!turn || turn->id().kind != NodeKind::Turn) + continue; + for (const NodeRef &item : write.children(turn)) { + if (item && item->id().kind == NodeKind::Item && + !isLocalPrompt(write, item)) + loaded.insert(item.get()); + } + for (const NodeRef &root : + write.related(turn, RelationKind::TurnRootItem)) { + if (root && root->id().kind == NodeKind::Item && + !isLocalPrompt(write, root)) + loaded.insert(root.get()); + } + } + write.setField(thread, "historyLoadedItemCount", + Value(static_cast(loaded.size()))); +} + +void incrementLoadedHistoryItemCount(NodeGraph::WriteAccess &write, + const NodeRef &thread, std::size_t added) { + if (!thread || added == 0) + return; + const Value *current = + member(write.state(thread)->fields, "historyLoadedItemCount"); + std::uint64_t count = 0; + if (const std::uint64_t *number = current ? current->asUInt64() : nullptr) + count = *number; + else if (const std::int64_t *number = current ? current->asInt64() : nullptr; + number && *number >= 0) + count = static_cast(*number); + else { + updateLoadedHistoryItemCount(write, thread); + return; + } + write.setField(thread, "historyLoadedItemCount", + Value(count + static_cast(added))); +} + +NodeId realtimeSessionNodeId(std::string_view threadId) { + return NodeId{NodeKind::RealtimeSession, + scopedCanonical(threadId, "current")}; +} + +NodeId realtimeItemNodeId(const NodeRef &session, std::string_view itemId) { + return NodeId{NodeKind::Item, + scopedCanonical(session->id().canonical, itemId)}; +} + +void removeContained(NodeGraph::WriteAccess &write, const NodeRef &node) { + std::unordered_set seen; + seen.insert(node.get()); + std::vector pending = write.children(node); + for (const NodeRef &root : write.related(node, RelationKind::TurnRootItem)) + pending.emplace_back(root); + + std::vector owned; + while (!pending.empty()) { + NodeRef current = std::move(pending.back()); + pending.pop_back(); + if (!current || !seen.insert(current.get()).second || + write.find(current->id()) != current) + continue; + owned.emplace_back(current); + for (const NodeRef &child : write.children(current)) + pending.emplace_back(child); + for (const NodeRef &root : + write.related(current, RelationKind::TurnRootItem)) + pending.emplace_back(root); + } + std::ranges::reverse(owned); + write.removeMany(owned); +} + +NodeRef ensureRealtimeSession(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + if (!thread) + return {}; + NodeRef session = write.upsert(realtimeSessionNodeId(thread->id().canonical)); + write.setField(session, "protocolThreadId", Value(thread->id().canonical)); + write.setParent(thread, session); + if (write.state(session)->status == NodeStatus::Unknown) { + write.setStatus(session, NodeStatus::Running); + write.setField(session, "active", Value(true)); + write.setField(session, "lifecycle", Value("running")); + } + return session; +} + +NodeRef ensureRealtimeItem(NodeGraph::WriteAccess &write, + const NodeRef &session, std::string_view itemId) { + if (!session || itemId.empty()) + return {}; + NodeRef item = write.upsert(realtimeItemNodeId(session, itemId)); + write.setField(item, "protocolId", Value(itemId)); + const std::shared_ptr sessionState = write.state(session); + if (const Value *threadId = member(sessionState->fields, "protocolThreadId")) + write.setField(item, "protocolThreadId", *threadId); + if (const Value *sessionId = member(sessionState->fields, "protocolId")) + write.setField(item, "realtimeSessionId", *sessionId); + write.setField(item, "realtime", Value(true)); + write.setParent(session, item); + return item; +} + +void appendArrayValue(NodeGraph::WriteAccess &write, const NodeRef &node, + std::string key, Value value) { + NodeState next = *write.state(node); + Value &stored = next.fields[std::move(key)]; + if (!stored.isArray()) + stored = Value::Array{}; + stored.asArray()->emplace_back(std::move(value)); + write.replaceState(node, std::move(next)); +} + +void updateRoleTranscript(NodeGraph::WriteAccess &write, const NodeRef &session, + std::string_view role, std::string_view text, + bool append, bool completed) { + NodeState next = *write.state(session); + const std::string retentionField = "transcripts/" + std::string(role); + if (!append) + clearTextRetention(next, retentionField); + Value &stored = next.fields["transcripts"]; + if (!stored.isObject()) + stored = Value::Object{}; + Value::Object &transcripts = *stored.asObject(); + Value &roleText = transcripts[std::string(role)]; + std::string combined; + if (append) { + if (const std::string *current = roleText.asString()) + combined = *current; + combined.append(text); + } else { + combined = std::string(text); + } + std::size_t discarded = 0; + if (combined.size() > MaximumRetainedStreamBytes) { + discarded = utf8TailStart(combined, RetainedStreamTailBytes); + combined.erase(0, discarded); + } + roleText = Value(std::move(combined)); + if (discarded != 0 || hasTextRetention(next, retentionField)) + updateTextRetention(next, retentionField, roleText.asString()->size(), + discarded); + + if (completed) { + Value &completion = next.fields["transcriptCompleted"]; + if (!completion.isObject()) + completion = Value::Object{}; + completion.asObject()->insert_or_assign(std::string(role), Value(true)); + } + write.replaceState(session, std::move(next)); +} + +std::uint64_t unsignedField(const NodeState &state, std::string_view name) { + const Value *value = member(state.fields, name); + if (const std::uint64_t *number = value ? value->asUInt64() : nullptr) + return *number; + if (const std::int64_t *number = value ? value->asInt64() : nullptr; + number && *number >= 0) + return static_cast(*number); + return 0; +} + +NodeRef currentConnection(NodeGraph::WriteAccess &write) { + return write.upsert({NodeKind::Connection, "connection"}); +} + +std::string connectionIncarnation(NodeGraph::WriteAccess &write, + const NodeRef &connection) { + const std::shared_ptr state = write.state(connection); + return "connection:" + + std::to_string(unsignedField(*state, "connectionGeneration")) + + ":provider:" + + std::to_string(unsignedField(*state, "providerGeneration")); +} + +NodeRef ensureConnectionScopedNode(NodeGraph::WriteAccess &write, NodeKind kind, + std::string_view rawId) { + if (rawId.empty()) + return {}; + NodeRef connection = currentConnection(write); + const std::shared_ptr connectionState = + write.state(connection); + NodeRef node = write.upsert( + {kind, scopedCanonical(connectionIncarnation(write, connection), rawId)}); + write.setField(node, "protocolId", Value(rawId)); + write.setField( + node, "connectionGeneration", + Value(unsignedField(*connectionState, "connectionGeneration"))); + write.setField(node, "providerGeneration", + Value(unsignedField(*connectionState, "providerGeneration"))); + if (kind == NodeKind::Process) + write.relate(connection, RelationKind::ProcessOwner, node); + return node; +} + +NodeRef ensureProcess(NodeGraph::WriteAccess &write, + const Value::Object &payload) { + constexpr std::array names{std::string_view("processId"), + std::string_view("processHandle"), + std::string_view("commandId")}; + return ensureConnectionScopedNode(write, NodeKind::Process, + firstId(payload, names)); +} + +NodeRef ensureWatch(NodeGraph::WriteAccess &write, + const Value::Object &payload) { + return ensureConnectionScopedNode(write, NodeKind::FilesystemWatch, + canonicalValue(member(payload, "watchId"))); +} + +bool applyExternalAgentImportUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const std::string_view method = message.method; + const bool importResult = + message.kind == DecodedMessageKind::ClientResult && + (method == "externalAgentConfig/import" || + method == "externalAgentConfig/import/recordHistory"); + const bool progress = method == "externalAgentConfig/import/progress"; + const bool completed = + method == "externalAgentConfig/import/completed" || + (importResult && method == "externalAgentConfig/import/recordHistory"); + if (!importResult && !progress && !completed) + return false; + + const std::string importId = + canonicalValue(member(message.payload, "importId")); + if (importId.empty()) + return true; + + NodeRef import = write.upsert({NodeKind::ExternalAgentImport, importId}); + mergeObject(write, import, message.payload); + write.setField(import, "protocolId", Value(importId)); + write.setField(import, "lastMethod", Value(message.method)); + write.setStatus(import, + completed ? NodeStatus::Completed : NodeStatus::Running); + write.setField(import, "lifecycle", + Value(completed ? "completed" : "running")); + return true; +} + +bool applyFuzzyFileSearchSessionUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const std::string_view method = message.method; + const bool notification = method == "fuzzyFileSearch/sessionUpdated" || + method == "fuzzyFileSearch/sessionCompleted"; + const bool result = message.kind == DecodedMessageKind::ClientResult && + (method == "fuzzyFileSearch/sessionStart" || + method == "fuzzyFileSearch/sessionUpdate" || + method == "fuzzyFileSearch/sessionStop"); + if (!notification && !result) + return false; + + const std::string sessionId = + canonicalValue(member(message.payload, "sessionId")); + if (sessionId.empty()) + return true; + + NodeRef session = ensureConnectionScopedNode( + write, NodeKind::FuzzyFileSearchSession, sessionId); + mergeObject(write, session, message.payload); + write.setField(session, "lastMethod", Value(message.method)); + const bool completed = method == "fuzzyFileSearch/sessionCompleted"; + const bool stopped = method == "fuzzyFileSearch/sessionStop"; + write.setStatus(session, completed ? NodeStatus::Completed + : stopped ? NodeStatus::Interrupted + : NodeStatus::Running); + write.setField(session, "lifecycle", + Value(completed ? "completed" + : stopped ? "stopped" + : "running")); + return true; +} + +std::string loginAttemptNodeKey(const Value *loginId) { + if (!loginId || loginId->isNull()) + return "login-id:none"; + if (const std::string *id = loginId->asString()) + return scopedCanonical("login-id", *id); + return "login-id:invalid"; +} + +bool applyAccountLoginUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const bool completed = message.method == "account/login/completed"; + const bool startResult = message.kind == DecodedMessageKind::ClientResult && + message.method == "account/login/start"; + const bool cancelResult = message.kind == DecodedMessageKind::ClientResult && + message.method == "account/login/cancel"; + if (!completed && !startResult && !cancelResult) + return false; + + const Value *loginId = member(message.payload, "loginId"); + NodeRef attempt = + write.upsert({NodeKind::LoginAttempt, loginAttemptNodeKey(loginId)}); + mergeObject(write, attempt, message.payload); + if (loginId) + write.setField(attempt, "protocolId", *loginId); + write.setField(attempt, "lastMethod", Value(message.method)); + if (completed) { + const Value *success = member(message.payload, "success"); + if (!success || !success->asBool()) + return true; + write.setStatus(attempt, *success->asBool() ? NodeStatus::Completed + : NodeStatus::Failed); + write.setField(attempt, "lifecycle", + Value(*success->asBool() ? "completed" : "failed")); + } else if (cancelResult) { + const std::string status = + canonicalValue(member(message.payload, "status")); + write.setStatus(attempt, status == "canceled" ? NodeStatus::Interrupted + : NodeStatus::Completed); + write.setField(attempt, "lifecycle", + Value(status.empty() ? "cancelled" : status)); + } else { + write.setStatus(attempt, NodeStatus::Running); + write.setField(attempt, "lifecycle", Value("running")); + } + return true; +} + +bool applyAccountUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const bool readResult = message.kind == DecodedMessageKind::ClientResult && + message.method == "account/read"; + const bool logoutResult = message.kind == DecodedMessageKind::ClientResult && + message.method == "account/logout"; + const bool notification = + message.kind == DecodedMessageKind::ServerNotification && + message.method == "account/updated"; + if (!readResult && !logoutResult && !notification) + return false; + + NodeRef account = write.upsert({NodeKind::Account, "account"}); + if (logoutResult) { + NodeState next; + const std::shared_ptr previous = write.state(account); + if (const Value *requiresAuth = + member(previous->fields, "requiresOpenaiAuth")) + next.fields.emplace("requiresOpenaiAuth", *requiresAuth); + next.fields.emplace("account", Value(nullptr)); + next.fields.emplace("authMode", Value(nullptr)); + next.fields.emplace("planType", Value(nullptr)); + next.fields.emplace("lastMethod", Value(message.method)); + write.replaceState(account, std::move(next)); + return true; + } + mergeObject(write, account, message.payload); + write.setField(account, "lastMethod", Value(message.method)); + return true; +} + +bool applyAccountRateLimitsUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const bool readResult = message.kind == DecodedMessageKind::ClientResult && + message.method == "account/rateLimits/read"; + const bool notification = + message.kind == DecodedMessageKind::ServerNotification && + message.method == "account/rateLimits/updated"; + if (!readResult && !notification) + return false; + + NodeRef rateLimits = write.upsert({NodeKind::Account, "rate-limits"}); + NodeState next = readResult ? NodeState{} : *write.state(rateLimits); + if (readResult) + next.fields = message.payload; + else + mergeSparseRateLimitFields(next.fields, message.payload); + next.fields.insert_or_assign("lastMethod", Value(message.method)); + write.replaceState(rateLimits, std::move(next)); + return true; +} + +bool applyConfigurationUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + if (message.kind != DecodedMessageKind::ClientResult) + return false; + + const bool readResult = message.method == "config/read"; + const bool writeResult = message.method == "config/value/write" || + message.method == "config/batchWrite"; + if (!readResult && !writeResult) + return false; + + NodeRef configuration = + write.upsert({NodeKind::Configuration, "config/read"}); + if (readResult) { + NodeState next; + next.fields = message.payload; + next.fields.insert_or_assign("lastMethod", Value(message.method)); + write.replaceState(configuration, std::move(next)); + return true; + } + + // A successful write confirms that the prior effective snapshot is no + // longer authoritative, but its result does not contain enough information + // to reconstruct layered effective config safely. + write.setField(configuration, "stale", Value(true)); + write.setField(configuration, "invalidatedBy", Value(message.method)); + write.setField(configuration, "lastMethod", Value(message.method)); + return true; +} + +void applyAddressedTurnError(NodeGraph::WriteAccess &write, + const Value::Object &payload) { + const std::string threadId = addressedId(payload, NodeKind::Thread); + if (threadId.empty()) + return; + + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + const std::string turnId = addressedId(payload, NodeKind::Turn); + NodeRef turn = ensureTurn(write, thread, turnId); + const Value *error = member(payload, "error"); + const Value *willRetry = member(payload, "willRetry"); + for (const NodeRef &target : {thread, turn}) { + if (!target) + continue; + if (error) + write.setField(target, "error", *error); + if (willRetry) + write.setField(target, "willRetry", *willRetry); + } + if (turn) + write.setField(thread, "errorTurnId", Value(turnId)); +} + +} // namespace + +NodeId scopedTurnNodeId(std::string_view threadId, std::string_view turnId) { + return NodeId{NodeKind::Turn, scopedCanonical(threadId, turnId)}; +} + +NodeId scopedItemNodeId(const NodeId &turnNodeId, std::string_view itemId) { + return NodeId{NodeKind::Item, scopedCanonical(turnNodeId.canonical, itemId)}; +} + +std::string protocolCanonicalId(const NodeState &state, const NodeRef &node) { + const auto found = state.fields.find("protocolId"); + if (found != state.fields.end()) { + const std::string retained = canonicalValue(&found->second); + if (!retained.empty()) + return retained; + } + return node ? node->id().canonical : std::string{}; +} + +std::string ProtocolRequestId::canonical() const { + if (const std::int64_t *number = std::get_if(&value)) + return "number:" + std::to_string(*number); + return "string:" + std::get(value); +} + +ProtocolUpdater::ProtocolUpdater(NodeGraph &graph) noexcept : graph_(&graph) {} + +ApplyResult ProtocolUpdater::apply(DecodedMessage message) { + auto write = graph_->write(); + AppliedMessage applied = applyInto(write, message); + return ApplyResult{applied.knownMethod, applied.disposition, write.finish(), + std::move(applied.primary)}; +} + +AppliedMessage ProtocolUpdater::applyInto(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const auto touchAddressedThread = [&] { + if (!write.hasPendingChanges()) + return; + std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (threadId.empty()) { + if (const Value::Object *object = objectMember(message.payload, "thread")) + threadId = nestedId(*object); + } + if (!threadId.empty()) { + if (NodeRef thread = write.find({NodeKind::Thread, threadId})) + write.touchRevision(thread); + } + }; + const ProtocolDirection direction = catalogDirection(message.kind); + const auto descriptor = findProtocolMethod(direction, message.method); + if (!descriptor) { + applyUnknown(write, message); + applyThreadActivity(write, message); + touchAddressedThread(); + return AppliedMessage{false, MessageDisposition::GraphUpdate, {}}; + } + + if (descriptor->get().disposition == + MessageDisposition::IntentionallyStateNeutral) { + return AppliedMessage{true, descriptor->get().disposition, {}}; + } + + NodeRef primary; + switch (descriptor->get().disposition) { + case MessageDisposition::WorkerOperationResult: + primary = applyOperation(write, message); + break; + case MessageDisposition::ReverseInteraction: + primary = applyInteraction(write, message); + break; + case MessageDisposition::GraphUpdate: + case MessageDisposition::TypedUiEffect: + applyGraphUpdate(write, message); + break; + case MessageDisposition::IntentionallyStateNeutral: + break; + } + applyThreadActivity(write, message); + touchAddressedThread(); + return AppliedMessage{true, descriptor->get().disposition, + std::move(primary)}; +} + +void ProtocolUpdater::applyThreadActivity(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + if (!message.activityAt) + return; + + NodeRef thread; + std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (threadId.empty()) { + if (const Value::Object *object = objectMember(message.payload, "thread")) + threadId = nestedId(*object); + } + if (!threadId.empty()) + thread = write.find({NodeKind::Thread, threadId}); + + if (!thread) + return; + + std::unordered_set visited; + while (thread && visited.insert(thread.get()).second) { + const std::shared_ptr state = write.state(thread); + const Value *existing = member(state->fields, "localActivityAt"); + const std::int64_t *signedValue = existing ? existing->asInt64() : nullptr; + const std::uint64_t *unsignedValue = + existing ? existing->asUInt64() : nullptr; + const bool newer = + (!signedValue && !unsignedValue) || + (signedValue && *message.activityAt > *signedValue) || + (unsignedValue && *message.activityAt >= 0 && + static_cast(*message.activityAt) > *unsignedValue); + if (newer) + write.setField(thread, "localActivityAt", Value(*message.activityAt)); + + const std::vector owners = + write.related(thread, RelationKind::ThreadOwner); + thread = owners.empty() ? NodeRef{} : owners.front(); + } +} + +GraphChange +ProtocolUpdater::resolveInteraction(const ProtocolRequestId &requestId, + bool accepted, std::string error) { + auto write = graph_->write(); + NodeRef interaction = + newestInteractionForRequest(write, requestId.canonical()); + if (!interaction) + return write.finish(); + if (!accepted) { + write.setStatus(interaction, NodeStatus::Failed); + write.setField(interaction, "error", Value(std::move(error))); + for (const NodeRef &target : + write.related(interaction, RelationKind::InteractionTarget)) + refreshPendingInteractionCount(write, containingThread(write, target)); + return write.finish(); + } + clearPendingInteractionOwner(write, interaction); + write.remove(interaction); + return write.finish(); +} + +GraphChange ProtocolUpdater::resolveInteraction(const NodeRef &interaction, + bool accepted, + std::string error) { + auto write = graph_->write(); + if (!interaction || interaction->id().kind != NodeKind::Interaction) + return write.finish(); + const NodeRef current = write.find(interaction->id()); + if (current != interaction) + return write.finish(); + if (!accepted) { + write.setStatus(interaction, NodeStatus::Failed); + write.setField(interaction, "error", Value(std::move(error))); + for (const NodeRef &target : + write.related(interaction, RelationKind::InteractionTarget)) + refreshPendingInteractionCount(write, containingThread(write, target)); + return write.finish(); + } + clearPendingInteractionOwner(write, interaction); + write.remove(interaction); + return write.finish(); +} + +ProtocolDirection +ProtocolUpdater::catalogDirection(DecodedMessageKind kind) const noexcept { + switch (kind) { + case DecodedMessageKind::ClientRequest: + case DecodedMessageKind::ClientResult: + case DecodedMessageKind::ClientError: + return ProtocolDirection::ClientRequest; + case DecodedMessageKind::ServerRequest: + return ProtocolDirection::ServerRequest; + case DecodedMessageKind::ServerNotification: + return ProtocolDirection::ServerNotification; + case DecodedMessageKind::ClientNotification: + return ProtocolDirection::ClientNotification; + } + return ProtocolDirection::ServerNotification; +} + +NodeRef ProtocolUpdater::applyOperation(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + if (message.kind == DecodedMessageKind::ClientNotification) { + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + write.setField(runtime, "initialized", Value(true)); + write.setField(runtime, "lastMethod", Value(message.method)); + return runtime; + } + + const std::string operationId = message.requestId + ? message.requestId->canonical() + : "uncorrelated:" + message.method; + if (message.kind == DecodedMessageKind::ClientRequest) { + if (NodeRef previous = write.find({NodeKind::Operation, operationId})) + write.remove(previous); + NodeRef operation = write.upsert({NodeKind::Operation, operationId}); + write.setField(operation, "method", Value(message.method)); + write.setField(operation, "requestPayload", Value(message.payload)); + if (message.method == "thread/read") + write.setField(operation, "requestTargetRevision", + Value(write.revision() + 1)); + write.setStatus(operation, NodeStatus::Pending); + NodeRef target; + const bool exactTargetSupplied = static_cast(message.requestTarget); + if (exactTargetSupplied) { + if (write.find(message.requestTarget->id()) == message.requestTarget) + target = message.requestTarget; + } else { + const std::string threadId = + addressedId(message.payload, NodeKind::Thread); + const std::string turnId = addressedId(message.payload, NodeKind::Turn); + const std::string itemId = addressedId(message.payload, NodeKind::Item); + NodeRef thread; + if (!threadId.empty()) + thread = write.upsert({NodeKind::Thread, threadId}); + NodeRef turn; + if (!turnId.empty()) + turn = ensureTurn(write, thread, turnId); + if (beginsWith(message.method, "command/exec") || + beginsWith(message.method, "process/")) { + target = ensureProcess(write, message.payload); + if (target) { + mergeObject(write, target, message.payload); + if (message.method == "command/exec" || + message.method == "process/spawn") + write.setStatus(target, NodeStatus::Running); + } + } else if (message.method == "fs/watch" || + message.method == "fs/unwatch") { + target = ensureWatch(write, message.payload); + if (target) { + mergeObject(write, target, message.payload); + if (message.method == "fs/watch") + write.setStatus(target, NodeStatus::Pending); + } + } else if (!itemId.empty()) { + target = ensureItem(write, turn, itemId); + } + if (!target && turn) + target = turn; + if (!target && thread) + target = thread; + } + if (target) { + write.relate(operation, RelationKind::OperationTarget, target); + } + if (target || exactTargetSupplied) + write.setField(operation, "hadOperationTarget", Value(true)); + return operation; + } + + NodeRef operation = write.find({NodeKind::Operation, operationId}); + if (!operation) { + // Tests and bridge integrations may deliver an already-correlated result + // without asking the graph to expose its transient request. Exact worker + // callbacks always supply expectedNode, in which case absence means stale. + if (!message.expectedNode && + message.kind == DecodedMessageKind::ClientResult) + applyGraphUpdate(write, message); + return {}; + } + if (message.expectedNode && message.expectedNode != operation) + return {}; + const std::shared_ptr operationState = + write.state(operation); + const Value *storedMethod = nullptr; + if (const auto found = operationState->fields.find("method"); + found != operationState->fields.end()) + storedMethod = &found->second; + if (operationState->status != NodeStatus::Pending || !storedMethod || + !storedMethod->asString() || *storedMethod->asString() != message.method) + return {}; + + const Value *hadOperationTarget = + member(operationState->fields, "hadOperationTarget"); + if (hadOperationTarget && hadOperationTarget->asBool() && + *hadOperationTarget->asBool() && + write.related(operation, RelationKind::OperationTarget).empty()) { + // The target was deleted after this request was sent. Retire the exact + // operation without allowing any late result payload to recreate it. + write.remove(operation); + return {}; + } + + if (message.kind == DecodedMessageKind::ClientResult) { + DecodedMessage correlated = message; + const auto request = operationState->fields.find("requestPayload"); + if (request != operationState->fields.end()) { + if (const Value::Object *requestObject = request->second.asObject()) { + for (const auto &[key, value] : *requestObject) + correlated.payload.try_emplace(key, value); + } + } + std::optional preserveChangesAfter; + if (message.method == "thread/read") { + const Value *started = + member(operationState->fields, "requestTargetRevision"); + const std::uint64_t *revision = started ? started->asUInt64() : nullptr; + if (revision) + preserveChangesAfter = *revision; + } + applyGraphUpdate(write, correlated, preserveChangesAfter); + } + // Operations model only work that is currently pending. Results/errors are + // applied to current state and then the operation is retired in this same + // graph transaction; retaining terminal operations would be an event log. + write.remove(operation); + return {}; +} + +NodeRef ProtocolUpdater::applyInteraction(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + if (!message.requestId) + throw std::invalid_argument("a server request requires an id"); + const std::string requestId = message.requestId->canonical(); + std::string interactionKey = requestId; + if (NodeRef previous = write.find({NodeKind::Interaction, requestId})) { + const std::shared_ptr previousState = + write.state(previous); + const Value *recovery = member(previousState->fields, "recoveryOnly"); + const bool recoveryOnly = + recovery && recovery->asBool() && *recovery->asBool(); + if (recoveryOnly || interactionGenerationDiffers(*previousState, message)) { + interactionKey = scopedInteractionKey(*message.requestId, message); + } else { + clearPendingInteractionOwner(write, previous); + write.remove(previous); + } + } + if (NodeRef previous = write.find({NodeKind::Interaction, interactionKey})) { + clearPendingInteractionOwner(write, previous); + write.remove(previous); + } + NodeRef interaction = + write.upsert({NodeKind::Interaction, std::move(interactionKey)}); + clearPendingInteractionOwner(write, interaction); + const std::vector previousTargets = + write.related(interaction, RelationKind::InteractionTarget); + for (const NodeRef &previous : previousTargets) + write.unrelate(interaction, RelationKind::InteractionTarget, previous); + NodeState state; + state.status = NodeStatus::Pending; + state.fields.emplace("method", Value(message.method)); + state.fields.emplace("payload", Value(message.payload)); + state.fields.emplace("requestId", Value(requestId)); + if (message.connectionGeneration) + state.fields.emplace("connectionGeneration", + Value(*message.connectionGeneration)); + if (message.providerGeneration) + state.fields.emplace("providerGeneration", + Value(*message.providerGeneration)); + write.replaceState(interaction, std::move(state)); + + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + const std::string turnId = addressedId(message.payload, NodeKind::Turn); + const std::string itemId = addressedId(message.payload, NodeKind::Item); + NodeRef thread; + NodeRef turn; + NodeRef item; + if (!threadId.empty()) + thread = write.upsert({NodeKind::Thread, threadId}); + if (!turnId.empty()) + turn = ensureTurn(write, thread, turnId); + if (!itemId.empty()) + item = ensureItem(write, turn, itemId); + NodeRef target; + if (item) + target = item; + else if (turn) + target = turn; + else if (thread) + target = thread; + if (target) { + write.relate(interaction, RelationKind::InteractionTarget, target); + if (NodeRef owner = containingThread(write, target)) { + write.relate(owner, RelationKind::PendingInteraction, interaction); + refreshPendingInteractionCount(write, owner); + } + } + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + write.relate(runtime, RelationKind::PendingInteraction, interaction); + return interaction; +} + +bool ProtocolUpdater::applyRealtimeUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const std::string_view method = message.method; + if (!beginsWith(method, "thread/realtime/")) + return false; + + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (threadId.empty()) + return true; + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + NodeRef session = ensureRealtimeSession(write, thread); + + if (method == "thread/realtime/started") { + const std::string sessionId = + canonicalValue(member(message.payload, "realtimeSessionId")); + const std::shared_ptr previous = write.state(session); + const std::string previousId = + canonicalValue(member(previous->fields, "protocolId")); + if (previous->status != NodeStatus::Running || previousId != sessionId) { + removeContained(write, session); + NodeState next; + next.status = NodeStatus::Running; + next.fields.emplace("protocolThreadId", Value(threadId)); + next.fields.emplace("active", Value(true)); + next.fields.emplace("lifecycle", Value("running")); + if (!sessionId.empty()) + next.fields.emplace("protocolId", Value(sessionId)); + for (const auto &[key, value] : message.payload) + next.fields.insert_or_assign(key, value); + write.replaceState(session, std::move(next)); + } else { + mergeObject(write, session, message.payload); + write.setField(session, "active", Value(true)); + write.setField(session, "lifecycle", Value("running")); + write.setStatus(session, NodeStatus::Running); + } + return true; + } + + if (method == "thread/realtime/itemAdded") { + const Value *value = member(message.payload, "item"); + const Value::Object *object = value ? value->asObject() : nullptr; + const std::string itemSessionId = + object ? canonicalValue(member(*object, "realtimeSessionId")) + : std::string{}; + const std::string currentSessionId = + canonicalValue(member(write.state(session)->fields, "protocolId")); + if (!itemSessionId.empty() && !currentSessionId.empty() && + itemSessionId != currentSessionId) + return true; + const std::string itemId = object ? nestedId(*object) : std::string{}; + if (NodeRef realtimeItem = ensureRealtimeItem(write, session, itemId)) { + mergeObject(write, realtimeItem, *object); + } else if (value) { + appendArrayValue(write, session, "unaddressedItems", *value); + } + return true; + } + + if (method == "thread/realtime/item/started" || + method == "thread/realtime/item/completed") { + const Value::Object *object = objectMember(message.payload, "item"); + const std::string itemSessionId = + object ? canonicalValue(member(*object, "realtimeSessionId")) + : std::string{}; + const std::string currentSessionId = + canonicalValue(member(write.state(session)->fields, "protocolId")); + if (!itemSessionId.empty() && !currentSessionId.empty() && + itemSessionId != currentSessionId) + return true; + std::string itemId = object ? nestedId(*object) : std::string{}; + if (itemId.empty()) + itemId = addressedId(message.payload, NodeKind::Item); + if (NodeRef realtimeItem = ensureRealtimeItem(write, session, itemId)) { + if (object) + mergeObject(write, realtimeItem, *object); + const NodeStatus supplied = + object ? statusFromValue(member(*object, "status")) + : NodeStatus::Unknown; + if (method == "thread/realtime/item/started") { + if (supplied == NodeStatus::Unknown) + write.setStatus(realtimeItem, NodeStatus::Running); + } else if (supplied == NodeStatus::Unknown) { + write.setStatus(realtimeItem, NodeStatus::Completed); + } + } + return true; + } + + if (method == "thread/realtime/item/transcript/delta") { + const std::string itemId = addressedId(message.payload, NodeKind::Item); + if (NodeRef realtimeItem = ensureRealtimeItem(write, session, itemId)) { + const std::string delta = + canonicalValue(member(message.payload, "delta")); + if (!delta.empty()) + appendBoundedStringField(write, realtimeItem, "transcript", delta); + } + return true; + } + + if (method == "thread/realtime/transcript/delta" || + method == "thread/realtime/transcript/done") { + const std::string role = canonicalValue(member(message.payload, "role")); + const bool done = method == "thread/realtime/transcript/done"; + const std::string text = + canonicalValue(member(message.payload, done ? "text" : "delta")); + updateRoleTranscript(write, session, role.empty() ? "unknown" : role, text, + !done, done); + return true; + } + + if (method == "thread/realtime/outputAudio/delta") { + const Value *audio = member(message.payload, "audio"); + const Value::Object *audioObject = audio ? audio->asObject() : nullptr; + const std::string itemId = + audioObject ? canonicalValue(member(*audioObject, "itemId")) + : std::string{}; + NodeRef target = ensureRealtimeItem(write, session, itemId); + if (!target) + target = session; + if (audio) + appendArrayValue(write, target, "outputAudioChunks", *audio); + return true; + } + + if (method == "thread/realtime/sdp") { + if (const Value *sdp = member(message.payload, "sdp")) + write.setField(session, "sdp", *sdp); + return true; + } + + if (method == "thread/realtime/error") { + if (const Value *error = member(message.payload, "message")) + appendArrayValue(write, session, "errors", *error); + write.setStatus(session, NodeStatus::Failed); + write.setField(session, "active", Value(false)); + write.setField(session, "lifecycle", Value("failed")); + return true; + } + + if (method == "thread/realtime/closed") { + if (const Value *reason = member(message.payload, "reason")) + write.setField(session, "closedReason", *reason); + if (write.state(session)->status != NodeStatus::Failed) + write.setStatus(session, NodeStatus::Completed); + write.setField(session, "active", Value(false)); + write.setField(session, "lifecycle", Value("closed")); + return true; + } + + return true; +} + +void ProtocolUpdater::applyGraphUpdate( + NodeGraph::WriteAccess &write, const DecodedMessage &message, + std::optional preserveChangesAfter) { + const std::string_view method = message.method; + + if (isProviderNoticeMethod(method)) { + NodeRef notice = write.upsert({NodeKind::Notice, "provider-notice"}); + write.setField(notice, "method", Value(std::string(method))); + write.setField(notice, "severity", + Value(method == "error" ? "error" : "warning")); + mergeObject(write, notice, message.payload); + write.setStatus(notice, method == "error" ? NodeStatus::Failed + : NodeStatus::Completed); + if (method == "error") + applyAddressedTurnError(write, message.payload); + return; + } + + if (applyNaturalCatalogSnapshot(write, message) || + applyAutoApprovalReview(write, message)) + return; + + if (method == "autoApprovalReview/strictReviewRequired") { + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + const std::string turnId = addressedId(message.payload, NodeKind::Turn); + if (!threadId.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + NodeRef target = + turnId.empty() ? thread : ensureTurn(write, thread, turnId); + mergeObject(write, target, message.payload); + write.setField(target, "strictReviewRequired", Value(true)); + } + return; + } + + if (method == "thread/environment/connected" || + method == "thread/environment/disconnected") { + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (!threadId.empty()) { + const bool connected = method == "thread/environment/connected"; + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + mergeObject(write, thread, message.payload); + write.setField(thread, "environmentConnected", Value(connected)); + write.setField(thread, "environmentStatus", + Value(connected ? "connected" : "disconnected")); + } + return; + } + + if (method == "modelProvider/authRecoveryStarted" || + method == "modelProvider/authRecoveryCompleted") { + const bool active = method == "modelProvider/authRecoveryStarted"; + NodeRef provider = write.upsert({NodeKind::Catalog, "modelProvider"}); + mergeObject(write, provider, message.payload); + write.setField(provider, "authRecoveryActive", Value(active)); + write.setField(provider, "authRecoveryStatus", + Value(active ? "recovering" : "completed")); + write.setField(provider, "lastMethod", Value(std::string(method))); + write.setStatus(provider, + active ? NodeStatus::Running : NodeStatus::Completed); + return; + } + + if (method == "thread/compacted") { + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (!threadId.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, threadId}); + mergeObject(write, thread, message.payload); + write.setField(thread, "compacted", Value(true)); + if (const Value *turnId = member(message.payload, "turnId")) + write.setField(thread, "lastCompactedTurnId", *turnId); + } + return; + } + + if (applyExternalAgentImportUpdate(write, message) || + applyFuzzyFileSearchSessionUpdate(write, message) || + applyAccountLoginUpdate(write, message) || + applyAccountUpdate(write, message) || + applyAccountRateLimitsUpdate(write, message) || + applyConfigurationUpdate(write, message)) + return; + + if (method.starts_with("mcpServer/")) { + NodeRef server = write.upsert( + {NodeKind::McpServer, mcpServerNodeKey(method, message.payload)}); + const std::string protocolId = + method == "mcpServer/event/stream/notification" + ? canonicalValue(member(message.payload, "subscriptionId")) + : canonicalValue(member(message.payload, "name")); + if (!protocolId.empty()) + write.setField(server, "protocolId", Value(protocolId)); + write.setField(server, "lastMethod", Value(std::string(method))); + mergeObject(write, server, message.payload); + return; + } + + if (method == "hook/started" || method == "hook/completed") { + static_cast(applyHookNotification(write, message)); + return; + } + + if (applyRealtimeUpdate(write, message)) + return; + + if (method == "command/exec/outputDelta" || method == "process/outputDelta") { + NodeRef process = ensureProcess(write, message.payload); + if (!process) + return; + const std::string stream = + canonicalValue(member(message.payload, "stream")); + const std::string delta = + canonicalValue(member(message.payload, "deltaBase64")); + if (!delta.empty()) + appendBoundedStringField(write, process, + (stream.empty() ? "output" : stream) + "Base64", + delta); + if (const Value *capReached = member(message.payload, "capReached")) + write.setField(process, + (stream.empty() ? "output" : stream) + "CapReached", + *capReached); + write.setStatus(process, NodeStatus::Running); + return; + } + + if (method == "process/exited") { + if (NodeRef process = ensureProcess(write, message.payload)) { + mergeObject(write, process, message.payload); + write.setStatus(process, NodeStatus::Completed); + } + return; + } + + if (method == "fs/changed") { + if (NodeRef watch = ensureWatch(write, message.payload)) { + mergeObject(write, watch, message.payload); + write.setStatus(watch, NodeStatus::Running); + } + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + (beginsWith(method, "command/exec") || beginsWith(method, "process/") || + method == "fs/watch" || method == "fs/unwatch")) { + if (method == "fs/watch" || method == "fs/unwatch") { + if (NodeRef watch = ensureWatch(write, message.payload)) { + if (method == "fs/unwatch") + write.remove(watch); + else { + mergeObject(write, watch, message.payload); + write.setStatus(watch, NodeStatus::Running); + } + } + return; + } + if (NodeRef process = ensureProcess(write, message.payload)) { + mergeObject(write, process, message.payload); + if (method == "command/exec") + write.setStatus(process, NodeStatus::Completed); + else if (method == "process/spawn") + write.setStatus(process, NodeStatus::Running); + else if (method == "command/exec/terminate" || method == "process/kill") + write.setStatus(process, NodeStatus::Interrupted); + } + return; + } + + if (method == "serverRequest/resolved") { + const Value *requestId = member(message.payload, "requestId"); + const std::string canonical = canonicalValue(requestId); + if (!canonical.empty()) { + const std::string typedRequestId = requestId && requestId->asString() + ? "string:" + canonical + : "number:" + canonical; + NodeRef interaction; + if (message.expectedNode && + write.find(message.expectedNode->id()) == message.expectedNode && + canonicalValue(member(write.state(message.expectedNode)->fields, + "requestId")) == typedRequestId) { + interaction = message.expectedNode; + } else if (!message.expectedNode) { + interaction = newestInteractionForRequest(write, typedRequestId); + } + if (interaction && + (!message.expectedNode || message.expectedNode == interaction)) { + clearPendingInteractionOwner(write, interaction); + write.remove(interaction); + } + } + return; + } + + if (method == "thread/deleted" || + (message.kind == DecodedMessageKind::ClientResult && + method == "thread/delete")) { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + if (NodeRef node = write.find({NodeKind::Thread, id})) + removeThread(write, node); + } + return; + } + + if (method == "thread/archived" || method == "thread/unarchived" || + method == "thread/closed" || + (message.kind == DecodedMessageKind::ClientResult && + (method == "thread/archive" || method == "thread/unarchive"))) { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + if (method == "thread/archived" || method == "thread/archive") { + write.setField(thread, "archived", Value(true)); + write.setField(thread, "lifecycle", Value("archived")); + } else if (method == "thread/unarchived" || + method == "thread/unarchive") { + write.setField(thread, "archived", Value(false)); + write.setField(thread, "lifecycle", Value("unarchived")); + admitRootThread(write, thread, false); + } else { + write.setField(thread, "lifecycle", Value("closed")); + write.setField(thread, "status", Value("notLoaded")); + write.setStatus(thread, NodeStatus::NotLoaded); + write.replaceRelated(thread, RelationKind::ActiveTurn, + std::span{}); + } + } + return; + } + + if (method == "thread/goal/updated" || method == "thread/goal/cleared") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + if (method == "thread/goal/cleared") { + write.setField(thread, "goal", Value(nullptr)); + write.setField(thread, "goalTurnId", Value(nullptr)); + } else { + if (const Value *goal = member(message.payload, "goal")) + write.setField(thread, "goal", *goal); + if (const Value *turnId = member(message.payload, "turnId")) + write.setField(thread, "goalTurnId", *turnId); + } + } + return; + } + + if (method == "thread/queue/changed" || method == "thread/reverted") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + write.setField(thread, + method == "thread/queue/changed" ? "queueStale" + : "historyStale", + Value(true)); + } + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + method == "thread/queue/list") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + mergeObject(write, thread, message.payload); + write.eraseField(thread, "queueStale"); + } + return; + } + + if (method == "skills/changed" || method == "app/list/updated") { + NodeRef catalog = write.upsert( + {NodeKind::Catalog, method == "skills/changed" ? "skills" : "app"}); + write.setField(catalog, "stale", Value(true)); + write.setField(catalog, "invalidatedBy", Value(method)); + mergeObject(write, catalog, message.payload); + return; + } + + if (method == "project/changed") { + const std::string id = addressedId(message.payload, NodeKind::Project); + if (!id.empty()) { + NodeRef project = write.upsert({NodeKind::Project, id}); + mergeObject(write, project, message.payload); + write.setField(project, "stale", Value(true)); + } + return; + } + + if (method == "thread/project/updated") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + assignProject(write, thread, member(message.payload, "projectId")); + } + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + method == "thread/section/move") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + assignSection(write, thread, member(message.payload, "sectionId")); + if (const Value *enteredAt = member(message.payload, "sectionEnteredAt")) + write.setField(thread, "sectionEnteredAt", *enteredAt); + } + return; + } + + if (method == "thread/status/changed") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + if (const Value *status = member(message.payload, "status")) { + write.setField(thread, "status", *status); + const NodeStatus normalized = statusFromValue(status); + write.setStatus(thread, normalized); + if (normalized != NodeStatus::Running) + write.replaceRelated(thread, RelationKind::ActiveTurn, + std::span{}); + } + } + return; + } + + if (method == "thread/name/updated") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (!id.empty()) { + NodeRef thread = write.upsert({NodeKind::Thread, id}); + if (const Value *name = member(message.payload, "threadName")) + write.setField(thread, "name", *name); + } + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + beginsWith(method, "project/")) { + if (method == "project/list") { + if (const Value::Array *projects = arrayMember(message.payload, "data")) { + for (const Value &value : *projects) { + const Value::Object *object = value.asObject(); + const std::string id = object ? nestedId(*object) : std::string{}; + if (!id.empty()) { + NodeRef project = write.upsert({NodeKind::Project, id}); + mergeObject(write, project, *object); + write.eraseField(project, "stale"); + } + } + } + return; + } + std::string id; + const Value::Object *object = objectMember(message.payload, "project"); + if (object) + id = nestedId(*object); + if (id.empty()) + id = addressedId(message.payload, NodeKind::Project); + if (id.empty()) + return; + if (method == "project/delete") { + if (NodeRef project = write.find({NodeKind::Project, id})) + write.remove(project); + return; + } + NodeRef project = write.upsert({NodeKind::Project, id}); + if (object) + mergeObject(write, project, *object); + else + mergeObject(write, project, message.payload); + write.eraseField(project, "stale"); + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + beginsWith(method, "threadSection/")) { + if (method == "threadSection/list") { + if (const Value::Array *sections = arrayMember(message.payload, "data")) { + for (const Value &value : *sections) { + const Value::Object *object = value.asObject(); + const std::string id = object ? nestedId(*object) : std::string{}; + if (!id.empty()) { + NodeRef section = write.upsert({NodeKind::ThreadSection, id}); + mergeObject(write, section, *object); + } + } + } + return; + } + std::string id; + const Value::Object *object = objectMember(message.payload, "section"); + if (object) + id = nestedId(*object); + if (id.empty()) + id = addressedId(message.payload, NodeKind::ThreadSection); + if (id.empty()) + return; + if (method == "threadSection/delete") { + if (NodeRef section = write.find({NodeKind::ThreadSection, id})) + write.remove(section); + return; + } + NodeRef section = write.upsert({NodeKind::ThreadSection, id}); + if (object) + mergeObject(write, section, *object); + else + mergeObject(write, section, message.payload); + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + method == "thread/list") { + const Value::Array *threads = arrayMember(message.payload, "data"); + if (!threads) + threads = arrayMember(message.payload, "threads"); + if (threads) + replaceThreadList(write, *threads); + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + method == "thread/items/list") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (id.empty()) + return; + NodeRef thread = write.upsert({NodeKind::Thread, id}); + std::vector>> pages; + std::unordered_map pageIndexes; + std::vector> pageItems; + if (const Value::Array *entries = arrayMember(message.payload, "data")) { + pages.reserve(entries->size()); + pageItems.reserve(entries->size()); + for (const Value &value : *entries) { + const Value::Object *entry = value.asObject(); + const Value::Object *itemObject = + entry ? objectMember(*entry, "item") : nullptr; + const std::string turnId = + entry ? canonicalValue(member(*entry, "turnId")) : std::string{}; + if (!itemObject || turnId.empty()) + continue; + NodeRef turn = ensureTurn(write, thread, turnId); + NodeRef item = ingestItem(write, *itemObject, turn); + if (!item) + continue; + auto [position, inserted] = + pageIndexes.emplace(turn.get(), pages.size()); + if (inserted) { + pages.emplace_back(turn, std::vector{}); + pageItems.emplace_back(); + } + const std::size_t page = position->second; + if (pageItems[page].insert(item.get()).second) + pages[page].second.emplace_back(std::move(item)); + } + } + + const std::string direction = + canonicalValue(member(message.payload, "sortDirection")); + for (auto &[turn, page] : pages) { + if (direction == "desc") + std::reverse(page.begin(), page.end()); + const std::vector existing = write.children(turn); + if (direction == "asc") + write.replaceChildren(turn, mergeExistingTail(existing, page)); + else + write.replaceChildren(turn, + mergeExistingTail(std::move(page), existing)); + } + + NodeRef cursorOwner = thread; + const std::string requestedTurn = + canonicalValue(member(message.payload, "turnId")); + if (!requestedTurn.empty()) + cursorOwner = ensureTurn(write, thread, requestedTurn); + const std::string nextCursor = + canonicalValue(member(message.payload, "nextCursor")); + write.setField(cursorOwner, "itemsHistoryHasMore", + Value(!nextCursor.empty())); + if (nextCursor.empty()) + write.eraseField(cursorOwner, "itemsHistoryNextCursor"); + else + write.setField(cursorOwner, "itemsHistoryNextCursor", Value(nextCursor)); + const std::string backwardsCursor = + canonicalValue(member(message.payload, "backwardsCursor")); + if (backwardsCursor.empty()) + write.eraseField(cursorOwner, "itemsHistoryBackwardsCursor"); + else + write.setField(cursorOwner, "itemsHistoryBackwardsCursor", + Value(backwardsCursor)); + updateLoadedHistoryItemCount(write, thread); + return; + } + + if (message.kind == DecodedMessageKind::ClientResult && + method == "thread/turns/list") { + const std::string id = addressedId(message.payload, NodeKind::Thread); + if (id.empty()) + return; + NodeRef thread = write.upsert({NodeKind::Thread, id}); + const std::vector previous = write.children(thread); + std::vector page; + const Value::Array *turns = arrayMember(message.payload, "data"); + if (!turns) + turns = arrayMember(message.payload, "turns"); + if (turns) { + page.reserve(turns->size()); + std::unordered_set seen; + seen.reserve(turns->size()); + for (const Value &value : *turns) { + const Value::Object *turnObject = value.asObject(); + if (!turnObject) + continue; + NodeRef turn = ingestTurn(write, *turnObject, thread, {}, true); + if (turn && seen.insert(turn.get()).second) + page.emplace_back(std::move(turn)); + } + write.replaceChildren(thread, + mergeExistingTail(std::move(page), previous)); + } + + refreshActiveTurn(write, thread); + + const std::string nextCursor = + canonicalValue(member(message.payload, "nextCursor")); + write.setField(thread, "historyHasMore", Value(!nextCursor.empty())); + if (nextCursor.empty()) + write.eraseField(thread, "historyNextCursor"); + else + write.setField(thread, "historyNextCursor", Value(nextCursor)); + updateLoadedHistoryItemCount(write, thread); + return; + } + + if (isItemDeltaMethod(method)) { + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + const std::string turnId = addressedId(message.payload, NodeKind::Turn); + const std::string itemId = addressedId(message.payload, NodeKind::Item); + NodeRef thread; + NodeRef turn; + NodeRef item; + bool newItemMembership = false; + if (!threadId.empty()) + thread = write.upsert({NodeKind::Thread, threadId}); + if (!turnId.empty()) + turn = ensureTurn(write, thread, turnId); + if (!itemId.empty()) { + const NodeId itemNodeId = turn ? scopedItemNodeId(turn->id(), itemId) + : NodeId{NodeKind::Item, {}}; + const NodeRef previousItem = turn ? write.find(itemNodeId) : NodeRef{}; + const NodeRef previousParent = + previousItem ? write.parent(previousItem) : NodeRef{}; + item = ensureItem(write, turn, itemId); + if (item) { + newItemMembership = previousParent != turn; + appendSemanticDelta(write, item, method, message.payload); + } + } + if (newItemMembership) + incrementLoadedHistoryItemCount(write, thread, 1); + return; + } + + NodeRef thread; + NodeRef turn; + NodeRef item; + bool historyMembershipMayChange = false; + bool historyMembershipRequiresRecount = false; + std::size_t newHistoryItems = 0; + const auto refreshHistoryCount = [&] { + if (historyMembershipRequiresRecount) + updateLoadedHistoryItemCount(write, thread); + else + incrementLoadedHistoryItemCount(write, thread, newHistoryItems); + }; + if (const Value::Object *threadObject = + objectMember(message.payload, "thread")) { + const bool authoritativeHistoryResult = + message.kind == DecodedMessageKind::ClientResult && + (method == "thread/rollback" || method == "thread/revert"); + historyMembershipMayChange = authoritativeHistoryResult || + arrayMember(*threadObject, "turns") != nullptr; + historyMembershipRequiresRecount = historyMembershipMayChange; + const bool replaceTurns = + message.kind == DecodedMessageKind::ClientResult && + (method == "thread/read" || authoritativeHistoryResult); + thread = ingestThread(write, *threadObject, {}, replaceTurns, + method == "thread/read" ? preserveChangesAfter + : std::nullopt); + if (authoritativeHistoryResult && thread) { + const std::string turnsCursor = + canonicalValue(member(message.payload, "turnsBackwardsCursor")); + write.setField(thread, "historyHasMore", Value(!turnsCursor.empty())); + if (turnsCursor.empty()) + write.eraseField(thread, "historyNextCursor"); + else + write.setField(thread, "historyNextCursor", Value(turnsCursor)); + const std::string itemsCursor = + canonicalValue(member(message.payload, "itemsBackwardsCursor")); + if (itemsCursor.empty()) + write.eraseField(thread, "itemsHistoryBackwardsCursor"); + else + write.setField(thread, "itemsHistoryBackwardsCursor", + Value(itemsCursor)); + write.eraseField(thread, "historyStale"); + } + if (thread && (method == "thread/started" || + (message.kind == DecodedMessageKind::ClientResult && + (method == "thread/start" || method == "thread/resume" || + method == "thread/fork" || method == "thread/read")))) + admitRootThread(write, thread, true); + if (thread && message.kind == DecodedMessageKind::ClientResult && + method == "thread/read" && replaceTurns) { + const Value *includeTurns = member(message.payload, "includeTurns"); + if (includeTurns && includeTurns->asBool() && *includeTurns->asBool()) + write.eraseField(thread, "historyStale"); + } + } else { + const std::string threadId = addressedId(message.payload, NodeKind::Thread); + if (!threadId.empty()) + thread = write.upsert({NodeKind::Thread, threadId}); + } + + if (const Value::Object *turnObject = objectMember(message.payload, "turn")) { + const std::string id = nestedId(*turnObject); + const NodeRef previousTurn = + id.empty() || !thread + ? NodeRef{} + : write.find(scopedTurnNodeId(thread->id().canonical, id)); + const NodeRef previousParent = + previousTurn ? write.parent(previousTurn) : NodeRef{}; + historyMembershipMayChange |= + arrayMember(*turnObject, "items") != nullptr || + (thread && previousParent != thread); + historyMembershipRequiresRecount |= + arrayMember(*turnObject, "items") != nullptr; + turn = ingestTurn(write, *turnObject, thread); + if (turn && method == "turn/started" && + write.state(turn)->status == NodeStatus::Unknown) { + write.setStatus(turn, NodeStatus::Running); + write.setField(turn, "status", Value("running")); + } + if (turn && method == "turn/completed" && + statusFromValue(member(*turnObject, "status")) == NodeStatus::Unknown) { + write.setStatus(turn, NodeStatus::Completed); + write.setField(turn, "status", Value("completed")); + } + updateActiveTurn(write, thread, turn); + } else { + const std::string id = addressedId(message.payload, NodeKind::Turn); + if (!id.empty()) { + const NodeRef previousTurn = + thread ? write.find(scopedTurnNodeId(thread->id().canonical, id)) + : NodeRef{}; + const NodeRef previousParent = + previousTurn ? write.parent(previousTurn) : NodeRef{}; + turn = ensureTurn(write, thread, id); + if (turn) { + historyMembershipMayChange |= previousParent != thread; + } + } + } + + if (const Value::Object *itemObject = objectMember(message.payload, "item")) { + const std::string id = nestedId(*itemObject); + const NodeRef previousItem = + id.empty() || !turn ? NodeRef{} + : write.find(scopedItemNodeId(turn->id(), id)); + const NodeRef previousParent = + previousItem ? write.parent(previousItem) : NodeRef{}; + if (turn && previousParent != turn) { + historyMembershipMayChange = true; + ++newHistoryItems; + } + item = ingestItem(write, *itemObject, turn); + if (item) { + if (const Value *startedAt = member(message.payload, "startedAtMs")) + write.setField(item, "startedAtMs", *startedAt); + if (const Value *completedAt = member(message.payload, "completedAtMs")) + write.setField(item, "completedAtMs", *completedAt); + } + if (item && method == "item/started" && + write.state(item)->status == NodeStatus::Unknown) { + write.setStatus(item, NodeStatus::Running); + write.setField(item, "status", Value("running")); + } + if (item && method == "item/completed" && + statusFromValue(member(*itemObject, "status")) == NodeStatus::Unknown) { + write.setStatus(item, NodeStatus::Completed); + write.setField(item, "status", Value("completed")); + } + } else { + const std::string id = addressedId(message.payload, NodeKind::Item); + if (!id.empty()) { + const NodeRef previousItem = + turn ? write.find(scopedItemNodeId(turn->id(), id)) : NodeRef{}; + const NodeRef previousParent = + previousItem ? write.parent(previousItem) : NodeRef{}; + item = ensureItem(write, turn, id); + if (item) { + if (previousParent != turn) { + historyMembershipMayChange = true; + ++newHistoryItems; + } + } + } + } + + if (thread && message.kind == DecodedMessageKind::ClientResult && + (method == "thread/start" || method == "thread/resume" || + method == "thread/fork")) { + mergeThreadResultSettings(write, thread, message.payload); + } + if (thread && method == "thread/settings/updated") { + if (const Value::Object *settings = + objectMember(message.payload, "threadSettings")) { + mergeEffectiveThreadSettings(write, thread, *settings); + write.setField(thread, "latestSettingsUpdate", Value(*settings)); + write.setField(thread, "settingsRevision", Value(write.revision() + 1)); + } + } + + if (method == "turn/plan/updated" && turn) { + if (const Value *explanation = member(message.payload, "explanation")) + write.setField(turn, "planExplanation", *explanation); + if (const Value *plan = member(message.payload, "plan")) + write.setField(turn, "plan", *plan); + if (historyMembershipMayChange) + refreshHistoryCount(); + return; + } + + if (method == "turn/diff/updated" && turn) { + if (const Value *diff = member(message.payload, "diff")) + write.setField(turn, "diff", *diff); + if (historyMembershipMayChange) + refreshHistoryCount(); + return; + } + + if (!thread && !turn && !item) { + const NodeKind kind = kindForMethod(method); + std::string id = addressedId(message.payload, kind); + if (id.empty()) + id = kind == NodeKind::Catalog ? catalogKey(method) : std::string(method); + NodeRef node = write.upsert({kind, std::move(id)}); + if (method == "command/exec/outputDelta" || + method == "process/outputDelta") { + const std::string delta = + canonicalValue(member(message.payload, "delta")); + if (!delta.empty()) + appendBoundedStringField(write, node, "output", delta); + } else { + mergeObject(write, node, message.payload); + } + return; + } + + if (objectMember(message.payload, "thread") || + objectMember(message.payload, "turn") || + objectMember(message.payload, "item")) { + if (historyMembershipMayChange) + refreshHistoryCount(); + return; + } + + NodeRef addressed = item ? item : (turn ? turn : thread); + if (addressed) + mergeObject(write, addressed, message.payload); + if (historyMembershipMayChange) + refreshHistoryCount(); +} + +void ProtocolUpdater::applyUnknown(NodeGraph::WriteAccess &write, + const DecodedMessage &message) { + const ProtocolDirection direction = catalogDirection(message.kind); + const std::string id = + std::to_string(static_cast(direction)) + ":" + message.method; + NodeState state; + state.fields.emplace("method", Value(message.method)); + state.fields.emplace("payload", Value(message.payload)); + state.fields.emplace("direction", + Value(static_cast(direction))); + NodeRef unknown = write.upsert({NodeKind::UnknownProtocol, id}); + write.replaceState(unknown, std::move(state)); +} + +NodeRef ProtocolUpdater::ingestThread( + NodeGraph::WriteAccess &write, const Value::Object &object, + std::string_view fallbackId, bool replaceTurns, + std::optional preserveChangesAfter) { + const std::string id = nestedId(object, fallbackId); + if (id.empty()) + return {}; + NodeRef thread = write.upsert({NodeKind::Thread, id}); + const std::string previousForkSourceId = + canonicalValue(member(write.state(thread)->fields, "forkedFromId")); + const auto acceptsField = [&](std::string_view field) { + return !preserveChangesAfter || + write.fieldChangedRevision(thread, field) <= *preserveChangesAfter; + }; + mergeObject(write, thread, object, "turns", preserveChangesAfter); + + if (const Value *projectId = member(object, "projectId"); + projectId && acceptsField("projectId")) + assignProject(write, thread, projectId); + if (const Value *section = member(object, "section"); + section && acceptsField("section")) + assignSection(write, thread, section); + + if (const Value *parentValue = member(object, "parentThreadId"); + parentValue && acceptsField("parentThreadId")) { + const std::string parentId = canonicalValue(parentValue); + if (!parentId.empty() && parentId != id) { + NodeRef parent = write.upsert({NodeKind::Thread, parentId}); + assignThreadOwner(write, parent, RelationKind::StructuralChildThread, + thread); + } else if (parentValue->isNull() || parentId.empty()) { + // A provider-null structural parent does not contradict the direct + // spawn relation retained from the owning thread's agent activity. + // Clearing every owner here promoted a selected child to a new root and + // displaced its real root thread in ThreadPane after thread/read. + clearStructuralThreadOwners(write, thread); + } + } + + if (const Value *forkValue = member(object, "forkedFromId"); + forkValue && acceptsField("forkedFromId")) { + const std::string forkedFromId = canonicalValue(forkValue); + if (!previousForkSourceId.empty() && previousForkSourceId != forkedFromId) { + if (NodeRef previousSource = + write.find({NodeKind::Thread, previousForkSourceId})) + write.unrelate(previousSource, RelationKind::ForkChildThread, thread); + } + if (!forkedFromId.empty() && forkedFromId != id) { + NodeRef source = write.upsert({NodeKind::Thread, forkedFromId}); + write.relate(source, RelationKind::ForkChildThread, thread); + } + } + + if (const Value::Array *turns = arrayMember(object, "turns")) { + std::vector order; + order.reserve(turns->size()); + std::unordered_set ordered; + ordered.reserve(turns->size()); + for (const Value &value : *turns) { + if (const Value::Object *turnObject = value.asObject()) { + const std::string turnId = nestedId(*turnObject); + NodeRef turn = ingestTurn(write, *turnObject, thread, {}, replaceTurns, + preserveChangesAfter, true); + if (turn && ordered.insert(turn.get()).second) + order.emplace_back(std::move(turn)); + } + } + if (replaceTurns) + write.replaceChildren( + thread, replaceAuthoritativeChildren(write, thread, std::move(order), + write.children(thread))); + else if (!order.empty()) + write.replaceChildren( + thread, mergeExistingTail(std::move(order), write.children(thread))); + refreshActiveTurn(write, thread); + if (replaceTurns) + reconcileAgentChildRelations(write, thread); + } + return thread; +} + +NodeRef +ProtocolUpdater::ingestTurn(NodeGraph::WriteAccess &write, + const Value::Object &object, const NodeRef &thread, + std::string_view fallbackId, bool replaceItems, + std::optional preserveChangesAfter, + bool updateCurrentRelation) { + const std::string id = nestedId(object, fallbackId); + if (id.empty() || !thread) + return {}; + NodeRef turn = ensureTurn(write, thread, id); + mergeObject(write, turn, object, "items", preserveChangesAfter); + if (const Value::Array *items = arrayMember(object, "items")) { + std::vector order; + order.reserve(items->size()); + std::unordered_set ordered; + ordered.reserve(items->size()); + for (const Value &value : *items) { + if (const Value::Object *itemObject = value.asObject()) { + const std::string itemId = nestedId(*itemObject); + NodeRef item = + ingestItem(write, *itemObject, turn, {}, preserveChangesAfter); + if (item && ordered.insert(item.get()).second) + order.emplace_back(std::move(item)); + } + } + if (replaceItems) { + const std::vector existing = + mergeExistingTail(write.children(turn), + write.related(turn, RelationKind::TurnRootItem)); + write.replaceChildren( + turn, replaceAuthoritativeChildren(write, turn, std::move(order), + existing)); + } else if (!order.empty()) + write.replaceChildren( + turn, mergeExistingTail(std::move(order), write.children(turn))); + if (replaceItems) { + NodeRef root; + for (const NodeRef &candidate : write.children(turn)) { + if (candidate && isUserMessage(write.state(candidate)->fields)) { + root = candidate; + break; + } + } + replaceSingleRelation(write, turn, RelationKind::TurnRootItem, root); + reconcileAgentChildRelations(write, write.parent(turn)); + } + } + if (updateCurrentRelation) + updateActiveTurn(write, thread, turn); + return turn; +} + +NodeRef +ProtocolUpdater::ingestItem(NodeGraph::WriteAccess &write, + const Value::Object &object, const NodeRef &turn, + std::string_view fallbackId, + std::optional preserveChangesAfter) { + const std::string id = nestedId(object, fallbackId); + if (id.empty() || !turn) + return {}; + NodeRef item = ensureItem(write, turn, id); + mergeObject(write, item, object, {}, preserveChangesAfter); + boundRetainedItemText(write, item, object, preserveChangesAfter); + correlateLocalPrompt(write, item, object); + if (isUserMessage(object)) { + const std::vector roots = + write.related(turn, RelationKind::TurnRootItem); + bool claimsTurnRoot = roots.empty(); + if (!claimsTurnRoot) { + const std::vector prompts = + write.related(item, RelationKind::PromptMaterialization); + claimsTurnRoot = std::ranges::any_of( + prompts, [&write, &roots](const NodeRef &prompt) { + if (std::ranges::find(roots, prompt) == roots.end()) + return false; + const Value *dispatch = + member(write.state(prompt)->fields, "dispatchState"); + return canonicalValue(dispatch) == "awaitingMaterialization"; + }); + } + if (claimsTurnRoot) + replaceSingleRelation(write, turn, RelationKind::TurnRootItem, item); + } + + const bool hasAgentChildren = + member(object, "agentThreadId") || member(object, "receiverThreadIds"); + if (hasAgentChildren) { + const std::vector childThreadIds = + agentChildIds(write.state(item)->fields); + const std::vector previous = + write.related(item, RelationKind::AgentChildThread); + std::vector children; + children.reserve(childThreadIds.size()); + for (const std::string &childThreadId : childThreadIds) + children.emplace_back(write.upsert({NodeKind::Thread, childThreadId})); + write.replaceRelated(item, RelationKind::AgentChildThread, children); + NodeRef owner = turn ? write.parent(turn) : NodeRef{}; + if (owner && previous != children) + reconcileAgentChildRelations(write, owner); + } + return item; +} + +void ProtocolUpdater::reconcileAgentChildRelations( + NodeGraph::WriteAccess &write, const NodeRef &thread) { + if (!thread) + return; + const std::vector previous = + write.related(thread, RelationKind::AgentChildThread); + const std::vector referenced = + referencedAgentChildren(write, thread); + + for (const NodeRef &child : referenced) + assignThreadOwner(write, thread, RelationKind::AgentChildThread, child); + write.replaceRelated(thread, RelationKind::AgentChildThread, referenced); + + const std::vector structural = + write.related(thread, RelationKind::StructuralChildThread); + for (const NodeRef &released : previous) { + if (std::find(referenced.begin(), referenced.end(), released) != + referenced.end()) + continue; + if (std::find(structural.begin(), structural.end(), released) == + structural.end()) + write.unrelate(released, RelationKind::ThreadOwner, thread); + if (!hasThreadOwner(write, released)) + admitRootThread(write, released, false); + } +} + +void ProtocolUpdater::admitRootThread(NodeGraph::WriteAccess &write, + const NodeRef &thread, bool prepend) { + if (!thread || hasThreadOwner(write, thread)) + return; + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + std::vector roots = write.related(runtime, RelationKind::RootThread); + roots.erase(std::remove(roots.begin(), roots.end(), thread), roots.end()); + if (prepend) + roots.insert(roots.begin(), thread); + else + roots.emplace_back(thread); + write.replaceRelated(runtime, RelationKind::RootThread, roots); +} + +void ProtocolUpdater::replaceThreadList(NodeGraph::WriteAccess &write, + const Value::Array &threads) { + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + const std::vector previous = + write.related(runtime, RelationKind::RootThread); + std::vector listed; + listed.reserve(threads.size()); + std::unordered_set listedSet; + listedSet.reserve(threads.size()); + for (const Value &value : threads) { + const Value::Object *object = value.asObject(); + if (!object) + continue; + NodeRef thread = ingestThread(write, *object); + if (thread && listedSet.insert(thread.get()).second) + listed.emplace_back(std::move(thread)); + } + + std::vector roots; + roots.reserve(listed.size() + previous.size()); + std::unordered_set rootSet; + rootSet.reserve(listed.size() + previous.size()); + for (const NodeRef &thread : listed) { + if (!hasThreadOwner(write, thread) && rootSet.insert(thread.get()).second) + roots.emplace_back(thread); + } + for (const NodeRef &thread : previous) { + if (!hasThreadOwner(write, thread) && rootSet.insert(thread.get()).second) + roots.emplace_back(thread); + } + write.replaceRelated(runtime, RelationKind::RootThread, roots); +} + +void ProtocolUpdater::removeThread(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + NodeRef runtime = write.find({NodeKind::Runtime, "runtime"}); + std::vector roots = + runtime ? write.related(runtime, RelationKind::RootThread) + : std::vector{}; + const auto rootPosition = std::find(roots.begin(), roots.end(), thread); + const std::size_t insertion = rootPosition == roots.end() + ? roots.size() + : static_cast(std::distance( + roots.begin(), rootPosition)); + roots.erase(std::remove(roots.begin(), roots.end(), thread), roots.end()); + + std::vector promoted = + write.related(thread, RelationKind::StructuralChildThread); + promoted = + mergeExistingTail(std::move(promoted), + write.related(thread, RelationKind::AgentChildThread)); + + std::vector descendants; + std::unordered_set collected; + const auto collect = [&](const auto &self, const NodeRef &node) -> void { + std::vector contained = write.children(node); + if (node->id().kind == NodeKind::Turn) { + contained = + mergeExistingTail(std::move(contained), + write.related(node, RelationKind::TurnRootItem)); + } + for (const NodeRef &child : contained) { + if (!collected.insert(child.get()).second) + continue; + self(self, child); + descendants.emplace_back(child); + } + }; + collect(collect, thread); + + std::vector localPrompts; + for (const NodeRef &descendant : descendants) { + if (isLocalPrompt(write, descendant)) + localPrompts.emplace_back(descendant); + } + + NodeRef recoveryThread; + if (!localPrompts.empty()) { + if (!runtime) + runtime = write.upsert({NodeKind::Runtime, "runtime"}); + const std::string recoverySuffix = + std::to_string(write.revision() + 1) + ':' + thread->id().canonical; + NodeState recoveryState; + recoveryState.status = NodeStatus::Failed; + recoveryState.fields = {{"type", Value("localRecoveryThread")}, + {"local", Value(true)}, + {"recoveryOnly", Value(true)}, + {"name", Value("Unsent prompt")}}; + recoveryThread = write.upsert( + {NodeKind::Thread, "local-recovery-thread:removed:" + recoverySuffix}, + std::move(recoveryState)); + + std::size_t promptIndex = 0; + for (const NodeRef &prompt : localPrompts) { + NodeState turnState; + turnState.status = NodeStatus::Failed; + turnState.fields = {{"type", Value("localRecoveryTurn")}, + {"local", Value(true)}}; + NodeRef recoveryTurn = write.upsert( + {NodeKind::Turn, "local-recovery-turn:removed:" + recoverySuffix + + ':' + std::to_string(promptIndex++)}, + std::move(turnState)); + write.setParent(recoveryThread, recoveryTurn); + write.setParent(recoveryTurn, prompt); + write.setStatus(prompt, NodeStatus::Failed); + write.setField(prompt, "dispatchState", Value("uncertain")); + write.setField(prompt, "error", + Value("The destination thread was removed")); + write.setField(prompt, "requiresExplicitRecovery", Value(true)); + write.setField(prompt, "threadId", Value(recoveryThread->id().canonical)); + write.setField(prompt, "startsTurn", Value(true)); + write.eraseField(prompt, "turnId"); + write.eraseField(prompt, "expectedTurnId"); + write.eraseField(prompt, "requestId"); + write.eraseField(prompt, "uiMaterialized"); + write.relate(runtime, RelationKind::PendingPrompt, prompt); + write.relate(recoveryThread, RelationKind::PendingPrompt, prompt); + } + } + + std::vector removedDescendants; + removedDescendants.reserve(descendants.size()); + for (const NodeRef &descendant : descendants) + if (!isLocalPrompt(write, descendant)) + removedDescendants.emplace_back(descendant); + write.removeMany(removedDescendants); + write.remove(thread); + + std::size_t next = std::min(insertion, roots.size()); + if (recoveryThread) { + roots.insert(roots.begin() + static_cast(next), + recoveryThread); + ++next; + } + for (const NodeRef &child : promoted) { + if (hasThreadOwner(write, child) || + std::find(roots.begin(), roots.end(), child) != roots.end()) + continue; + roots.insert(roots.begin() + static_cast(next), child); + ++next; + } + if (runtime) + write.replaceRelated(runtime, RelationKind::RootThread, roots); +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/ProtocolUpdater.h b/src/codex/nodegraph/ProtocolUpdater.h new file mode 100644 index 0000000..f7bcbd8 --- /dev/null +++ b/src/codex/nodegraph/ProtocolUpdater.h @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_PROTOCOLUPDATER_H +#define CODEXUI_CODEX_NODEGRAPH_PROTOCOLUPDATER_H + +#include "codex/nodegraph/NodeGraph.h" +#include "codex/nodegraph/ProtocolCatalog.h" + +#include +#include +#include +#include + +namespace codexui::nodegraph { + +enum class DecodedMessageKind : std::uint8_t { + ClientRequest, + ClientResult, + ClientError, + ServerRequest, + ServerNotification, + ClientNotification, +}; + +struct ProtocolRequestId final { + std::variant value; + + ProtocolRequestId(std::int64_t id) : value(id) {} + ProtocolRequestId(std::string id) : value(std::move(id)) {} + ProtocolRequestId(const char *id) : value(std::string(id ? id : "")) {} + + [[nodiscard]] std::string canonical() const; + bool operator==(const ProtocolRequestId &) const = default; +}; + +// This is already decoded. The nodegraph never parses or emits app-server JSON. +// Client results/errors carry the correlated request method supplied by the +// worker because the wire response itself has no method member. +struct DecodedMessage final { + DecodedMessageKind kind = DecodedMessageKind::ServerNotification; + std::string method; + std::optional requestId; + Value::Object payload; + // Worker callbacks retain the exact request/interaction node they created. + // Supplying it prevents a late response from mutating a newer node after a + // provider reuses the same JSON-RPC id. + NodeRef expectedNode; + // Supplied by the CodexUI worker for meaningful thread-scoped traffic. + // Tests and other headless users need no clock; absence leaves activity + // unchanged. Hydration, global, and catalog traffic deliberately omit it. + std::optional activityAt; + // JSON-RPC request ids are connection-scoped. WorkerLogic supplies both + // generations for server requests so a replacement provider can reuse a + // wire id without replacing a retained recovery interaction. + std::optional connectionGeneration; + std::optional providerGeneration; + // Client requests originating from a NodeAction carry the exact graph node + // selected by Qt. Reduction validates this stable reference and never + // reconstructs a different operation target from payload identifiers. + NodeRef requestTarget; +}; + +struct ApplyResult final { + bool knownMethod = false; + MessageDisposition disposition = + MessageDisposition::IntentionallyStateNeutral; + GraphChange change; + // The exact newly-created Operation or Interaction, when applicable. + NodeRef primary; +}; + +struct AppliedMessage final { + bool knownMethod = false; + MessageDisposition disposition = + MessageDisposition::IntentionallyStateNeutral; + NodeRef primary; +}; + +// Provider turn and item identifiers are only unique inside their protocol +// owners. These helpers are the one canonical encoding used at graph lookup +// boundaries. Locally-created provisional nodes keep their existing IDs. +[[nodiscard]] NodeId scopedTurnNodeId(std::string_view threadId, + std::string_view turnId); +[[nodiscard]] NodeId scopedItemNodeId(const NodeId &turnNodeId, + std::string_view itemId); + +// Returns the raw provider identifier retained in node state, falling back to +// the node's canonical ID for globally-addressed and local nodes. +[[nodiscard]] std::string protocolCanonicalId(const NodeState &state, + const NodeRef &node); + +class ProtocolUpdater final { +public: + explicit ProtocolUpdater(NodeGraph &graph) noexcept; + + [[nodiscard]] ApplyResult apply(DecodedMessage message); + // WorkerLogic uses this concrete transaction form when a request result + // must update its operation and associated local prompt/hydration state in + // the same graph revision. The caller owns and finishes the write access. + [[nodiscard]] AppliedMessage applyInto(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + + // Called on the worker after CodexBridge accepts the matching server-request + // response. This is a lifecycle operation, not a Qt callback. + [[nodiscard]] GraphChange + resolveInteraction(const ProtocolRequestId &requestId, bool accepted, + std::string error = {}); + [[nodiscard]] GraphChange resolveInteraction(const NodeRef &interaction, + bool accepted, + std::string error = {}); + +private: + [[nodiscard]] ProtocolDirection + catalogDirection(DecodedMessageKind kind) const noexcept; + [[nodiscard]] NodeRef applyOperation(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + [[nodiscard]] NodeRef applyInteraction(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + void applyGraphUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message, + std::optional preserveChangesAfter = {}); + [[nodiscard]] bool applyRealtimeUpdate(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + void applyUnknown(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + void applyThreadActivity(NodeGraph::WriteAccess &write, + const DecodedMessage &message); + + [[nodiscard]] NodeRef + ingestThread(NodeGraph::WriteAccess &write, const Value::Object &object, + std::string_view fallbackId = {}, bool replaceTurns = false, + std::optional preserveChangesAfter = {}); + [[nodiscard]] NodeRef + ingestTurn(NodeGraph::WriteAccess &write, const Value::Object &object, + const NodeRef &thread, std::string_view fallbackId = {}, + bool replaceItems = false, + std::optional preserveChangesAfter = {}, + bool updateCurrentRelation = true); + [[nodiscard]] NodeRef + ingestItem(NodeGraph::WriteAccess &write, const Value::Object &object, + const NodeRef &turn, std::string_view fallbackId = {}, + std::optional preserveChangesAfter = {}); + void reconcileAgentChildRelations(NodeGraph::WriteAccess &write, + const NodeRef &thread); + void admitRootThread(NodeGraph::WriteAccess &write, const NodeRef &thread, + bool prepend); + void replaceThreadList(NodeGraph::WriteAccess &write, + const Value::Array &threads); + void removeThread(NodeGraph::WriteAccess &write, const NodeRef &thread); + + NodeGraph *graph_; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_PROTOCOLUPDATER_H diff --git a/src/codex/nodegraph/SpscQueue.h b/src/codex/nodegraph/SpscQueue.h new file mode 100644 index 0000000..c8290a4 --- /dev/null +++ b/src/codex/nodegraph/SpscQueue.h @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_SPSCQUEUE_H +#define CODEXUI_CODEX_NODEGRAPH_SPSCQUEUE_H + +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +template class SpscQueue { + static_assert(Capacity > 0, "an SPSC queue needs at least one slot"); + static_assert(Capacity <= std::numeric_limits::max() / 2, + "SPSC queue capacity is too large"); + static_assert(std::atomic::is_always_lock_free, + "SPSC queue positions must be lock-free"); + +public: + SpscQueue() = default; + ~SpscQueue() = default; + + SpscQueue(const SpscQueue &) = delete; + SpscQueue &operator=(const SpscQueue &) = delete; + SpscQueue(SpscQueue &&) = delete; + SpscQueue &operator=(SpscQueue &&) = delete; + + [[nodiscard]] bool + tryPush(T &&value) noexcept(std::is_nothrow_move_constructible_v) { + const std::size_t producer = + producer_.position.load(std::memory_order_relaxed); + const std::size_t consumer = + consumer_.position.load(std::memory_order_acquire); + if (distance(consumer, producer) == Capacity) + return false; + + slots_[slotIndex(producer)].emplace(std::move(value)); + producer_.position.store(advance(producer), std::memory_order_release); + return true; + } + + template + requires std::is_constructible_v + [[nodiscard]] bool tryEmplace(Args &&...args) noexcept( + std::is_nothrow_constructible_v) { + const std::size_t producer = + producer_.position.load(std::memory_order_relaxed); + const std::size_t consumer = + consumer_.position.load(std::memory_order_acquire); + if (distance(consumer, producer) == Capacity) + return false; + + slots_[slotIndex(producer)].emplace(std::forward(args)...); + producer_.position.store(advance(producer), std::memory_order_release); + return true; + } + + [[nodiscard]] bool + tryPop(T &value) noexcept(std::is_nothrow_move_assignable_v) { + const std::size_t consumer = + consumer_.position.load(std::memory_order_relaxed); + const std::size_t producer = + producer_.position.load(std::memory_order_acquire); + if (consumer == producer) + return false; + + std::optional &slot = slots_[slotIndex(consumer)]; + value = std::move(*slot); + slot.reset(); + consumer_.position.store(advance(consumer), std::memory_order_release); + return true; + } + + [[nodiscard]] bool empty() const noexcept { + const std::size_t consumer = + consumer_.position.load(std::memory_order_acquire); + const std::size_t producer = + producer_.position.load(std::memory_order_acquire); + return consumer == producer; + } + + [[nodiscard]] bool full() const noexcept { + const std::size_t producer = + producer_.position.load(std::memory_order_acquire); + const std::size_t consumer = + consumer_.position.load(std::memory_order_acquire); + return distance(consumer, producer) == Capacity; + } + + [[nodiscard]] std::size_t sizeApprox() const noexcept { + const std::size_t consumer = + consumer_.position.load(std::memory_order_acquire); + const std::size_t producer = + producer_.position.load(std::memory_order_acquire); + const std::size_t observed = distance(consumer, producer); + return observed <= Capacity ? observed : Capacity; + } + + [[nodiscard]] static constexpr std::size_t capacity() noexcept { + return Capacity; + } + +private: + static constexpr std::size_t CycleSize = Capacity * 2; + static constexpr std::size_t CacheLineSize = 64; + + struct alignas(CacheLineSize) Position { + std::atomic position{0}; + }; + + [[nodiscard]] static constexpr std::size_t + advance(std::size_t position) noexcept { + return position + 1 == CycleSize ? 0 : position + 1; + } + + [[nodiscard]] static constexpr std::size_t + slotIndex(std::size_t position) noexcept { + return position < Capacity ? position : position - Capacity; + } + + [[nodiscard]] static constexpr std::size_t distance(std::size_t from, + std::size_t to) noexcept { + return to >= from ? to - from : CycleSize - from + to; + } + + Position producer_; + Position consumer_; + alignas(CacheLineSize) std::array, Capacity> slots_{}; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_SPSCQUEUE_H diff --git a/src/codex/nodegraph/ThreadChannels.cpp b/src/codex/nodegraph/ThreadChannels.cpp new file mode 100644 index 0000000..9cd388d --- /dev/null +++ b/src/codex/nodegraph/ThreadChannels.cpp @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/ThreadChannels.h" + +#include +#include + +namespace codexui::nodegraph { + +bool messageAdmitted(ChannelSendStatus status) noexcept { + return status != ChannelSendStatus::QueueFull; +} + +bool deliveryGuaranteed(ChannelSendStatus status) noexcept { + return messageAdmitted(status); +} + +bool wakeFailed(ChannelSendStatus status) noexcept { + return status == ChannelSendStatus::AcceptedWakeFailed || + status == ChannelSendStatus::CoalescedRescanWakeFailed; +} + +bool ThreadChannels::valid() const noexcept { + return !closed_.load(std::memory_order_acquire) && workerToQtWake_.valid() && + qtToWorkerWake_.valid(); +} + +int ThreadChannels::workerToQtEventFd() const noexcept { + return workerToQtWake_.descriptor(); +} + +int ThreadChannels::qtToWorkerEventFd() const noexcept { + return qtToWorkerWake_.descriptor(); +} + +int ThreadChannels::workerToQtCreationError() const noexcept { + return workerToQtWake_.creationError(); +} + +int ThreadChannels::qtToWorkerCreationError() const noexcept { + return qtToWorkerWake_.creationError(); +} + +ChannelSendStatus ThreadChannels::sendGraphChanged(GraphChange change) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + if (change.empty()) + return ChannelSendStatus::Accepted; + const std::uint64_t revision = change.revision; + if (change.affected.size() > MaximumDirectGraphReferences || + change.removed.size() > + MaximumDirectGraphReferences - change.affected.size()) { + requireRescan(revision); + return wakeWorkerToQt(true); + } + if (workerToQt_.sizeApprox() >= + WorkerToQtCapacity - WorkerToQtReservedSlots) { + requireRescan(revision); + return wakeWorkerToQt(true); + } + WorkerToQtMessage message(std::in_place_type, + GraphChanged{revision, std::move(change.affected), + std::move(change.removed), false}); + if (workerToQt_.tryPush(std::move(message))) + return wakeWorkerToQt(false); + + requireRescan(revision); + return wakeWorkerToQt(true); +} + +ChannelSendStatus ThreadChannels::sendUiEffect(UiEffect &effect) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + const std::size_t limit = effect.kind == UiEffectKind::SelectThread + ? WorkerToQtCapacity - 1 + : WorkerToQtCapacity - WorkerToQtReservedSlots; + if (workerToQt_.sizeApprox() >= limit) + return ChannelSendStatus::QueueFull; + if (!workerToQt_.tryEmplace(std::in_place_type, std::move(effect))) + return ChannelSendStatus::QueueFull; + return wakeWorkerToQt(false); +} + +ChannelSendStatus ThreadChannels::sendWorkerStopped(WorkerStopped &stopped) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + if (!workerToQt_.tryEmplace(std::in_place_type, + std::move(stopped))) + return ChannelSendStatus::QueueFull; + return wakeWorkerToQt(false); +} + +EventFd::DrainResult ThreadChannels::drainWorkerToQtWake() const noexcept { + return workerToQtWake_.drain(); +} + +bool ThreadChannels::tryReceiveForQt(WorkerToQtMessage &message) { + if (queuedTurnAfterRescan_) { + queuedTurnAfterRescan_ = false; + if (workerToQt_.tryPop(message)) + return true; + } + const std::uint64_t revision = + rescanRevision_.exchange(0, std::memory_order_acq_rel); + if (revision != 0) { + queuedTurnAfterRescan_ = true; + message = GraphChanged{revision, {}, {}, true}; + return true; + } + return workerToQt_.tryPop(message); +} + +ChannelSendStatus ThreadChannels::sendNodeAction(NodeAction &action) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + if (qtToWorker_.sizeApprox() >= QtToWorkerCapacity - 1) + return ChannelSendStatus::QueueFull; + if (!qtToWorker_.tryEmplace(std::in_place_type, + std::move(action))) + return ChannelSendStatus::QueueFull; + return wakeQtToWorker(); +} + +ChannelSendStatus ThreadChannels::sendRuntimeAction(RuntimeAction &action) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + if (qtToWorker_.sizeApprox() >= QtToWorkerCapacity - 1) + return ChannelSendStatus::QueueFull; + if (!qtToWorker_.tryEmplace(std::in_place_type, + std::move(action))) + return ChannelSendStatus::QueueFull; + return wakeQtToWorker(); +} + +ChannelSendStatus ThreadChannels::sendShutdown(ShutdownRequest &request) { + if (closed_.load(std::memory_order_acquire)) + return ChannelSendStatus::QueueFull; + if (!qtToWorker_.tryEmplace(std::in_place_type, + std::move(request))) + return ChannelSendStatus::QueueFull; + return wakeQtToWorker(); +} + +EventFd::DrainResult ThreadChannels::drainQtToWorkerWake() const noexcept { + return qtToWorkerWake_.drain(); +} + +bool ThreadChannels::tryReceiveForWorker(QtToWorkerMessage &message) { + return qtToWorker_.tryPop(message); +} + +std::size_t ThreadChannels::workerToQtSizeApprox() const noexcept { + return workerToQt_.sizeApprox(); +} + +std::size_t ThreadChannels::qtToWorkerSizeApprox() const noexcept { + return qtToWorker_.sizeApprox(); +} + +bool ThreadChannels::rescanPending() const noexcept { + return rescanRevision_.load(std::memory_order_acquire) != 0; +} + +void ThreadChannels::failNextWorkerToQtWakeForTest() noexcept { + failNextWorkerToQtWake_.store(true, std::memory_order_release); +} + +void ThreadChannels::failNextQtToWorkerWakeForTest() noexcept { + failNextQtToWorkerWake_.store(true, std::memory_order_release); +} + +void ThreadChannels::close() noexcept { + closed_.store(true, std::memory_order_release); + workerToQtWake_.close(); + qtToWorkerWake_.close(); +} + +ChannelSendStatus +ThreadChannels::wakeWorkerToQt(bool coalesced) const noexcept { + if (failNextWorkerToQtWake_.exchange(false, std::memory_order_acq_rel)) + return coalesced ? ChannelSendStatus::CoalescedRescanWakeFailed + : ChannelSendStatus::AcceptedWakeFailed; + const EventFd::NotifyResult wake = workerToQtWake_.notify(); + if (wake.accepted()) + return coalesced ? ChannelSendStatus::CoalescedRescan + : ChannelSendStatus::Accepted; + return coalesced ? ChannelSendStatus::CoalescedRescanWakeFailed + : ChannelSendStatus::AcceptedWakeFailed; +} + +ChannelSendStatus ThreadChannels::wakeQtToWorker() const noexcept { + if (failNextQtToWorkerWake_.exchange(false, std::memory_order_acq_rel)) + return ChannelSendStatus::AcceptedWakeFailed; + return qtToWorkerWake_.notify().accepted() + ? ChannelSendStatus::Accepted + : ChannelSendStatus::AcceptedWakeFailed; +} + +void ThreadChannels::requireRescan(std::uint64_t revision) noexcept { + std::uint64_t observed = rescanRevision_.load(std::memory_order_relaxed); + while (observed < revision && + !rescanRevision_.compare_exchange_weak(observed, revision, + std::memory_order_release, + std::memory_order_relaxed)) { + } +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/ThreadChannels.h b/src/codex/nodegraph/ThreadChannels.h new file mode 100644 index 0000000..05c9420 --- /dev/null +++ b/src/codex/nodegraph/ThreadChannels.h @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_THREADCHANNELS_H +#define CODEXUI_CODEX_NODEGRAPH_THREADCHANNELS_H + +#include "codex/nodegraph/EventFd.h" +#include "codex/nodegraph/Messages.h" +#include "codex/nodegraph/SpscQueue.h" + +#include +#include +#include + +namespace codexui::nodegraph { + +enum class ChannelSendStatus : std::uint8_t { + Accepted, + QueueFull, + CoalescedRescan, + AcceptedWakeFailed, + CoalescedRescanWakeFailed, +}; + +[[nodiscard]] bool messageAdmitted(ChannelSendStatus status) noexcept; +// Normal eventfd delivery and the bounded fallback drains both guarantee that +// an admitted payload is consumed exactly once. Wake failure remains visible +// so callers can report degraded delivery without retrying a mutation. +[[nodiscard]] bool deliveryGuaranteed(ChannelSendStatus status) noexcept; +[[nodiscard]] bool wakeFailed(ChannelSendStatus status) noexcept; + +// The application owns exactly one instance. It contains exactly one bounded +// SPSC queue and one eventfd in each direction; eventfds carry wake counts +// only. +class ThreadChannels final { +public: + static constexpr std::size_t WorkerToQtCapacity = 512; + static constexpr std::size_t QtToWorkerCapacity = 256; + // Larger transactions are already committed and are cheaper for Qt to + // rediscover through its bounded graph scans than to process as one event. + static constexpr std::size_t MaximumDirectGraphReferences = 64; + // Graph bursts and ordinary notices stop before the final two slots. One + // remains available for a critical selection effect and one for terminal + // WorkerStopped delivery. + static constexpr std::size_t WorkerToQtReservedSlots = 2; + + ThreadChannels() = default; + ThreadChannels(const ThreadChannels &) = delete; + ThreadChannels &operator=(const ThreadChannels &) = delete; + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] int workerToQtEventFd() const noexcept; + [[nodiscard]] int qtToWorkerEventFd() const noexcept; + [[nodiscard]] int workerToQtCreationError() const noexcept; + [[nodiscard]] int qtToWorkerCreationError() const noexcept; + + // Worker-thread producer operations. + [[nodiscard]] ChannelSendStatus sendGraphChanged(GraphChange change); + [[nodiscard]] ChannelSendStatus sendUiEffect(UiEffect &effect); + [[nodiscard]] ChannelSendStatus sendWorkerStopped(WorkerStopped &stopped); + + // Qt-thread consumer operations. + [[nodiscard]] EventFd::DrainResult drainWorkerToQtWake() const noexcept; + [[nodiscard]] bool tryReceiveForQt(WorkerToQtMessage &message); + + // Qt-thread producer operations. The supplied payload remains intact when + // QueueFull is returned, so the user's text/attachments stay available. + [[nodiscard]] ChannelSendStatus sendNodeAction(NodeAction &action); + [[nodiscard]] ChannelSendStatus sendRuntimeAction(RuntimeAction &action); + [[nodiscard]] ChannelSendStatus sendShutdown(ShutdownRequest &request); + + // Worker-thread consumer operations. + [[nodiscard]] EventFd::DrainResult drainQtToWorkerWake() const noexcept; + [[nodiscard]] bool tryReceiveForWorker(QtToWorkerMessage &message); + + [[nodiscard]] std::size_t workerToQtSizeApprox() const noexcept; + [[nodiscard]] std::size_t qtToWorkerSizeApprox() const noexcept; + [[nodiscard]] bool rescanPending() const noexcept; + + // Deterministic syscall-failure seams used by the headless recovery tests. + // They suppress one wake only; payload admission and FIFO storage are real. + void failNextWorkerToQtWakeForTest() noexcept; + void failNextQtToWorkerWakeForTest() noexcept; + + // Call only after both event-loop observers are disabled and the worker is + // joined. + void close() noexcept; + +private: + [[nodiscard]] ChannelSendStatus wakeWorkerToQt(bool coalesced) const noexcept; + [[nodiscard]] ChannelSendStatus wakeQtToWorker() const noexcept; + void requireRescan(std::uint64_t revision) noexcept; + + SpscQueue workerToQt_; + SpscQueue qtToWorker_; + EventFd workerToQtWake_; + EventFd qtToWorkerWake_; + std::atomic rescanRevision_{0}; + std::atomic_bool closed_{false}; + mutable std::atomic_bool failNextWorkerToQtWake_{false}; + mutable std::atomic_bool failNextQtToWorkerWake_{false}; + bool queuedTurnAfterRescan_ = false; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_THREADCHANNELS_H diff --git a/src/codex/nodegraph/Value.cpp b/src/codex/nodegraph/Value.cpp new file mode 100644 index 0000000..6d0963f --- /dev/null +++ b/src/codex/nodegraph/Value.cpp @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/Value.h" + +#include + +namespace codexui::nodegraph { + +Value::Value(std::nullptr_t) noexcept : storage_(std::monostate{}) {} + +Value::Value(bool value) noexcept : storage_(value) {} + +Value::Value(std::int64_t value) noexcept : storage_(value) {} + +Value::Value(std::uint64_t value) noexcept : storage_(value) {} + +Value::Value(double value) noexcept : storage_(value) {} + +Value::Value(const char *value) : storage_(std::string(value ? value : "")) {} + +Value::Value(std::string value) : storage_(std::move(value)) {} + +Value::Value(std::string_view value) : storage_(std::string(value)) {} + +Value::Value(Array value) : storage_(std::move(value)) {} + +Value::Value(Object value) : storage_(std::move(value)) {} + +bool Value::isNull() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isBool() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isSigned() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isUnsigned() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isDouble() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isString() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isArray() const noexcept { + return std::holds_alternative(storage_); +} + +bool Value::isObject() const noexcept { + return std::holds_alternative(storage_); +} + +bool *Value::asBool() noexcept { return std::get_if(&storage_); } + +const bool *Value::asBool() const noexcept { + return std::get_if(&storage_); +} + +std::int64_t *Value::asInt64() noexcept { + return std::get_if(&storage_); +} + +const std::int64_t *Value::asInt64() const noexcept { + return std::get_if(&storage_); +} + +std::uint64_t *Value::asUInt64() noexcept { + return std::get_if(&storage_); +} + +const std::uint64_t *Value::asUInt64() const noexcept { + return std::get_if(&storage_); +} + +double *Value::asDouble() noexcept { return std::get_if(&storage_); } + +const double *Value::asDouble() const noexcept { + return std::get_if(&storage_); +} + +std::string *Value::asString() noexcept { + return std::get_if(&storage_); +} + +const std::string *Value::asString() const noexcept { + return std::get_if(&storage_); +} + +Value::Array *Value::asArray() noexcept { + return std::get_if(&storage_); +} + +const Value::Array *Value::asArray() const noexcept { + return std::get_if(&storage_); +} + +Value::Object *Value::asObject() noexcept { + return std::get_if(&storage_); +} + +const Value::Object *Value::asObject() const noexcept { + return std::get_if(&storage_); +} + +Value *Value::find(std::string_view key) noexcept { + Object *object = asObject(); + if (!object) + return nullptr; + const auto member = object->find(key); + return member == object->end() ? nullptr : &member->second; +} + +const Value *Value::find(std::string_view key) const noexcept { + const Object *object = asObject(); + if (!object) + return nullptr; + const auto member = object->find(key); + return member == object->end() ? nullptr : &member->second; +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/Value.h b/src/codex/nodegraph/Value.h new file mode 100644 index 0000000..5ea7037 --- /dev/null +++ b/src/codex/nodegraph/Value.h @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_VALUE_H +#define CODEXUI_CODEX_NODEGRAPH_VALUE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +// A directly owned representation of the protocol values retained by graph +// nodes. Accessors are deliberately exact and non-throwing: callers decide +// how an unexpected protocol alternative should be handled. +class Value final { +public: + using Array = std::vector; + using Object = std::map>; + + Value() noexcept = default; + Value(std::nullptr_t) noexcept; + Value(bool value) noexcept; + Value(std::int64_t value) noexcept; + Value(std::uint64_t value) noexcept; + Value(double value) noexcept; + Value(const char *value); + Value(std::string value); + Value(std::string_view value); + Value(Array value); + Value(Object value); + + template < + typename Integer, + std::enable_if_t> && + !std::is_same_v, bool>, + int> = 0> + Value(Integer value) noexcept { + if constexpr (std::is_signed_v) + storage_.emplace(static_cast(value)); + else + storage_.emplace(static_cast(value)); + } + + [[nodiscard]] bool isNull() const noexcept; + [[nodiscard]] bool isBool() const noexcept; + [[nodiscard]] bool isSigned() const noexcept; + [[nodiscard]] bool isUnsigned() const noexcept; + [[nodiscard]] bool isDouble() const noexcept; + [[nodiscard]] bool isString() const noexcept; + [[nodiscard]] bool isArray() const noexcept; + [[nodiscard]] bool isObject() const noexcept; + + [[nodiscard]] bool *asBool() noexcept; + [[nodiscard]] const bool *asBool() const noexcept; + [[nodiscard]] std::int64_t *asInt64() noexcept; + [[nodiscard]] const std::int64_t *asInt64() const noexcept; + [[nodiscard]] std::uint64_t *asUInt64() noexcept; + [[nodiscard]] const std::uint64_t *asUInt64() const noexcept; + [[nodiscard]] double *asDouble() noexcept; + [[nodiscard]] const double *asDouble() const noexcept; + [[nodiscard]] std::string *asString() noexcept; + [[nodiscard]] const std::string *asString() const noexcept; + [[nodiscard]] Array *asArray() noexcept; + [[nodiscard]] const Array *asArray() const noexcept; + [[nodiscard]] Object *asObject() noexcept; + [[nodiscard]] const Object *asObject() const noexcept; + + [[nodiscard]] Value *find(std::string_view key) noexcept; + [[nodiscard]] const Value *find(std::string_view key) const noexcept; + + bool operator==(const Value &) const = default; + +private: + using Storage = + std::variant; + + Storage storage_; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_VALUE_H diff --git a/src/codex/nodegraph/WorkerLogic.cpp b/src/codex/nodegraph/WorkerLogic.cpp new file mode 100644 index 0000000..4c21c13 --- /dev/null +++ b/src/codex/nodegraph/WorkerLogic.cpp @@ -0,0 +1,1409 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/WorkerLogic.h" + +#include "codex/nodegraph/PromptText.h" + +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { +namespace { + +const Value *field(const NodeState &state, std::string_view name) { + const auto found = state.fields.find(name); + return found == state.fields.end() ? nullptr : &found->second; +} + +std::string stringField(const NodeState &state, std::string_view name) { + const Value *value = field(state, name); + const std::string *text = value ? value->asString() : nullptr; + return text ? *text : std::string{}; +} + +std::uint64_t unsignedField(const NodeState &state, std::string_view name) { + const Value *value = field(state, name); + const std::uint64_t *number = value ? value->asUInt64() : nullptr; + return number ? *number : 0; +} + +const Value *objectField(const Value::Object &object, std::string_view name) { + const auto found = object.find(name); + return found == object.end() ? nullptr : &found->second; +} + +std::string objectString(const Value::Object &object, std::string_view name) { + const Value *value = objectField(object, name); + const std::string *text = value ? value->asString() : nullptr; + return text ? *text : std::string{}; +} + +bool isProviderNotice(const DecodedMessage &message) { + if (message.kind != DecodedMessageKind::ServerNotification) + return false; + const std::string_view method = message.method; + return method == "error" || method == "warning" || + method == "guardianWarning" || method == "deprecationNotice" || + method == "configWarning" || method == "windows/worldWritableWarning"; +} + +std::string providerNoticeText(const DecodedMessage &message) { + std::string text = objectString(message.payload, "message"); + if (text.empty()) + text = objectString(message.payload, "detail"); + if (text.empty()) { + const Value *error = objectField(message.payload, "error"); + if (const Value::Object *object = error ? error->asObject() : nullptr) + text = objectString(*object, "message"); + } + return text.empty() ? message.method : text; +} + +bool isLocalPrompt(const NodeState &state) { + return stringField(state, "type") == "localPrompt"; +} + +bool hydrationResultIsUsable(NodeGraph::WriteAccess &write, + const DecodedMessage &result, + const NodeRef &thread) { + if (!thread || write.find(thread->id()) != thread) + return false; + // A successfully reduced correlated result retires its transient operation + // in the same transaction. If it is still live, the response was stale or + // mismatched and cannot make this thread ready. + if (result.expectedNode && + write.find(result.expectedNode->id()) == result.expectedNode) + return false; + const Value *threadValue = objectField(result.payload, "thread"); + const Value::Object *threadObject = + threadValue ? threadValue->asObject() : nullptr; + if (!threadObject || + objectString(*threadObject, "id") != thread->id().canonical) + return false; + + std::unordered_set checkedItems; + const auto itemIsUsable = [&](const NodeRef &item) { + if (!item || item->id().kind != NodeKind::Item || + !checkedItems.insert(item.get()).second) + return true; + const NodeState &state = *write.state(item); + return isLocalPrompt(state) || !stringField(state, "type").empty(); + }; + for (const NodeRef &turn : write.children(thread)) { + if (!turn || turn->id().kind != NodeKind::Turn) + continue; + for (const NodeRef &item : write.children(turn)) { + if (!itemIsUsable(item)) + return false; + } + for (const NodeRef &root : + write.related(turn, RelationKind::TurnRootItem)) { + if (!itemIsUsable(root)) + return false; + } + } + return true; +} + +bool isLocalShell(const NodeRef &node) { + return node && (node->id().canonical.starts_with("local-thread:") || + node->id().canonical.starts_with("local-turn:") || + node->id().canonical.starts_with("local-recovery-thread:") || + node->id().canonical.starts_with("local-recovery-turn:")); +} + +Value attachmentSummaries(const std::vector &attachments) { + Value::Array summaries; + summaries.reserve(attachments.size()); + for (const Attachment &attachment : attachments) { + summaries.emplace_back( + Value::Object{{"path", Value(attachment.path)}, + {"displayName", Value(attachment.displayName)}, + {"mimeType", Value(attachment.mimeType)}}); + } + return Value(std::move(summaries)); +} + +std::optional takeObject(Value::Object &source, + std::string_view name) { + const auto found = source.find(name); + if (found == source.end() || !found->second.asObject()) + return std::nullopt; + Value::Object result = std::move(*found->second.asObject()); + source.erase(found); + return result; +} + +std::string takeString(Value::Object &source, std::string_view name) { + const auto found = source.find(name); + if (found == source.end() || !found->second.asString()) + return {}; + std::string result = std::move(*found->second.asString()); + source.erase(found); + return result; +} + +NodeRef containingThread(NodeGraph::WriteAccess &write, NodeRef node) { + while (node && node->id().kind != NodeKind::Thread) + node = write.parent(node); + return node; +} + +NodeState initialConnectionState() { + return NodeState{NodeStatus::Unknown, + {{"transportState", Value(std::string{})}, + {"transportDetail", Value(std::string{})}, + {"connectionGeneration", Value(std::uint64_t{0})}, + {"connectionId", Value(std::string{})}, + {"role", Value(std::string{})}, + {"controllerConnectionId", Value(std::string{})}, + {"providerGeneration", Value(std::uint64_t{0})}, + {"providerState", Value(std::string{})}, + {"providerDetail", Value(std::string{})}, + {"settings", Value(Value::Object{})}}}; +} + +NodeRef connectionNode(NodeGraph::WriteAccess &write) { + if (NodeRef connection = write.find({NodeKind::Connection, "connection"})) + return connection; + return write.upsert({NodeKind::Connection, "connection"}, + initialConnectionState()); +} + +WorkerGenerations connectionGenerations(NodeGraph::WriteAccess &write) { + const NodeRef connection = write.find({NodeKind::Connection, "connection"}); + if (!connection) + return {}; + const std::shared_ptr state = write.state(connection); + return {unsignedField(*state, "connectionGeneration"), + unsignedField(*state, "providerGeneration")}; +} + +NodeStatus transportStatus(std::string_view state) { + if (state == "connected") + return NodeStatus::Connected; + if (state == "connecting" || state == "retrying") + return NodeStatus::Pending; + if (state == "disconnected" || state == "failure") + return NodeStatus::Disconnected; + return NodeStatus::Unknown; +} + +void clearBridgeFields(NodeGraph::WriteAccess &write, const NodeRef &connection, + bool clearProviderGeneration) { + write.setField(connection, "connectionId", Value(std::string{})); + write.setField(connection, "role", Value(std::string{})); + write.setField(connection, "controllerConnectionId", Value(std::string{})); + if (clearProviderGeneration) + write.setField(connection, "providerGeneration", Value(std::uint64_t{0})); + write.setField(connection, "providerState", Value(std::string{})); + write.setField(connection, "providerDetail", Value(std::string{})); +} + +} // namespace + +WorkerLogic::WorkerLogic(NodeGraph &graph, ThreadChannels &channels) noexcept + : graph_(graph), channels_(channels), updater_(graph) {} + +ChannelSendStatus WorkerLogic::apply(DecodedMessage message) { + return applyDetailed(std::move(message)).status; +} + +WorkerApplyResult WorkerLogic::applyDetailed(DecodedMessage message) { + std::optional noticeEffect; + if (isProviderNotice(message)) { + const std::uint64_t serial = nextNoticeSerial_++; + const std::string severity = + message.method == "error" ? "error" : "warning"; + const std::string notice = providerNoticeText(message); + message.payload.insert_or_assign("noticeSerial", Value(serial)); + message.payload.insert_or_assign("noticeText", Value(notice)); + message.payload.insert_or_assign("severity", Value(severity)); + noticeEffect = UiEffect{UiEffectKind::ShowNotice, std::nullopt, notice, + Value::Object{{"serial", Value(serial)}, + {"severity", Value(severity)}}}; + } + + GraphChange change; + NodeRef primary; + { + auto write = graph_.write(); + if (message.kind == DecodedMessageKind::ServerRequest) { + const WorkerGenerations current = connectionGenerations(write); + message.connectionGeneration = current.connection; + message.providerGeneration = current.provider; + } + AppliedMessage applied = updater_.applyInto(write, message); + primary = std::move(applied.primary); + change = write.finish(); + } + forgetRemoved(change); + // Queue the transient notice ahead of its GraphChanged wake. The graph is + // already fully committed and unlocked; this FIFO order also prevents the + // graph fallback from skipping an earlier directly queued notice. + ChannelSendStatus effectStatus = ChannelSendStatus::Accepted; + if (noticeEffect) + effectStatus = channels_.sendUiEffect(*noticeEffect); + ChannelSendStatus graphStatus = publish(std::move(change)); + if (noticeEffect && wakeFailed(graphStatus) && !wakeFailed(effectStatus) && + effectStatus != ChannelSendStatus::QueueFull) + graphStatus = effectStatus; + return {graphStatus, std::move(primary)}; +} + +void WorkerLogic::forgetRemoved(const GraphChange &change) { + for (const NodeRef &removed : change.removed) { + if (!removed) + continue; + if (removed->id().kind == NodeKind::Item && + removed->id().canonical.starts_with("local-prompt:")) + forgetPrompt(removed); + if (removed->id().kind == NodeKind::Thread) { + promptQueues_.erase(removed.get()); + promptInFlight_.erase(removed.get()); + for (auto creating = creatingThreads_.begin(); + creating != creatingThreads_.end();) { + if (creating->second == removed) + creating = creatingThreads_.erase(creating); + else + ++creating; + } + } + } +} + +WorkerGenerations WorkerLogic::generations() const { + auto write = graph_.write(); + const WorkerGenerations current = connectionGenerations(write); + static_cast(write.finish()); + return current; +} + +ChannelSendStatus WorkerLogic::transportEvent(std::string state, + std::string detail) { + const bool connected = state == "connected"; + const bool clearsBridge = state == "connecting" || state == "retrying" || + state == "disconnected" || state == "failure"; + const bool resetProvider = + connected || (clearsBridge && state != "connecting"); + std::string resetReason; + if (connected) { + resetReason = "A new app-server connection replaced provider state"; + } else if (resetProvider) { + resetReason = + detail.empty() ? "The app-server connection was reset" : detail; + } + + GraphChange change; + { + auto write = graph_.write(); + const NodeRef connection = connectionNode(write); + const std::uint64_t generation = + connectionGenerations(write).connection + (connected ? 1U : 0U); + write.setStatus(connection, transportStatus(state)); + write.setField(connection, "transportState", Value(std::move(state))); + write.setField(connection, "transportDetail", + Value(connected ? std::string{} : std::move(detail))); + write.setField(connection, "connectionGeneration", Value(generation)); + if (connected || clearsBridge) + clearBridgeFields(write, connection, connected); + if (resetProvider) + resetProviderDerived(write, resetReason); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::bridgeState( + std::string connectionId, std::string role, + std::string controllerConnectionId, std::uint64_t providerGeneration, + std::optional providerState, std::string detail) { + GraphChange change; + { + auto write = graph_.write(); + const NodeRef connection = connectionNode(write); + const std::uint64_t currentProviderGeneration = + connectionGenerations(write).provider; + if (providerState && providerGeneration < currentProviderGeneration) { + change = write.finish(); + } else { + const bool resetProvider = + providerState && providerGeneration > currentProviderGeneration; + const std::string resetReason = + resetProvider + ? (detail.empty() ? "The provider generation changed" : detail) + : std::string{}; + write.setField(connection, "connectionId", + Value(std::move(connectionId))); + write.setField(connection, "role", Value(std::move(role))); + write.setField(connection, "controllerConnectionId", + Value(std::move(controllerConnectionId))); + if (providerState) { + write.setField(connection, "providerGeneration", + Value(providerGeneration)); + write.setField(connection, "providerState", + Value(std::move(*providerState))); + write.setField(connection, "providerDetail", Value(std::move(detail))); + } + if (resetProvider) + resetProviderDerived(write, resetReason); + change = write.finish(); + } + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::connectionSettings(Value::Object settings) { + GraphChange change; + { + auto write = graph_.write(); + write.setField(connectionNode(write), "settings", + Value(std::move(settings))); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::threadHydration(const NodeRef &thread, + std::string state, + std::string error) { + GraphChange change; + { + auto write = graph_.write(); + updateThreadHydration(write, thread, std::move(state), std::move(error)); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::completeThreadHydration(DecodedMessage result, + const NodeRef &thread, + std::string state, + std::string error) { + GraphChange change; + { + auto write = graph_.write(); + static_cast(updater_.applyInto(write, result)); + if (state == "ready" && !hydrationResultIsUsable(write, result, thread)) { + state = "failed"; + if (error.empty()) + error = "Thread hydration returned incomplete item identity"; + } + updateThreadHydration(write, thread, std::move(state), std::move(error)); + change = write.finish(); + } + forgetRemoved(change); + return publish(std::move(change)); +} + +void WorkerLogic::updateThreadHydration(NodeGraph::WriteAccess &write, + const NodeRef &thread, + std::string state, std::string error) { + if (!thread || thread->id().kind != NodeKind::Thread || + write.find(thread->id()) != thread) + return; + write.setField(thread, "hydrationState", Value(std::move(state))); + const WorkerGenerations current = connectionGenerations(write); + write.setField(thread, "hydrationConnectionGeneration", + Value(current.connection)); + if (error.empty()) + write.eraseField(thread, "hydrationError"); + else + write.setField(thread, "hydrationError", Value(std::move(error))); +} + +std::vector WorkerLogic::activeAgentChildren(const NodeRef &thread) { + std::vector result; + auto write = graph_.write(); + if (!thread || thread->id().kind != NodeKind::Thread || + write.find(thread->id()) != thread) { + static_cast(write.finish()); + return result; + } + + std::unordered_set seen; + for (const NodeRef &turn : write.children(thread)) { + if (!turn || turn->id().kind != NodeKind::Turn) + continue; + for (const NodeRef &item : write.children(turn)) { + if (!item || item->id().kind != NodeKind::Item) + continue; + const std::shared_ptr state = write.state(item); + const std::string status = stringField(*state, "status"); + const bool active = state->status == NodeStatus::Pending || + state->status == NodeStatus::Running || + status == "pending" || status == "queued" || + status == "running" || status == "active" || + status == "inProgress"; + if (!active) + continue; + for (const NodeRef &child : + write.related(item, RelationKind::AgentChildThread)) { + if (child && child->id().kind == NodeKind::Thread && + write.find(child->id()) == child && seen.insert(child.get()).second) + result.emplace_back(child); + } + } + } + static_cast(write.finish()); + return result; +} + +ChannelSendStatus WorkerLogic::showNotice(std::string message) { + if (message.empty()) + return ChannelSendStatus::Accepted; + const std::uint64_t serial = nextNoticeSerial_++; + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + message, + {{"serial", Value(serial)}}}; + const ChannelSendStatus direct = channels_.sendUiEffect(effect); + if (direct != ChannelSendStatus::QueueFull) + return direct; + + GraphChange change; + { + auto write = graph_.write(); + NodeRef notice = write.upsert({NodeKind::Notice, "local-worker-notice"}); + write.setField(notice, "local", Value(true)); + write.setField(notice, "message", Value(std::move(message))); + write.setField(notice, "noticeSerial", Value(serial)); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::selectThread(const NodeRef &thread) { + if (!thread || thread->id().kind != NodeKind::Thread) + return ChannelSendStatus::Accepted; + const std::uint64_t serial = nextSelectionSerial_++; + UiEffect effect{ + UiEffectKind::SelectThread, thread, {}, {{"serial", Value(serial)}}}; + const ChannelSendStatus direct = channels_.sendUiEffect(effect); + if (direct != ChannelSendStatus::QueueFull) + return direct; + + GraphChange change; + { + auto write = graph_.write(); + if (write.find(thread->id()) != thread) + return ChannelSendStatus::Accepted; + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + std::array target{thread}; + write.replaceRelated(runtime, RelationKind::UiSelectionTarget, target); + write.setField(runtime, "uiSelectionSerial", Value(serial)); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus +WorkerLogic::resolveInteraction(const ProtocolRequestId &requestId, + bool accepted, std::string error) { + return publish( + updater_.resolveInteraction(requestId, accepted, std::move(error))); +} + +ChannelSendStatus WorkerLogic::resolveInteraction(const NodeRef &interaction, + bool accepted, + std::string error) { + return publish( + updater_.resolveInteraction(interaction, accepted, std::move(error))); +} + +ChannelSendStatus +WorkerLogic::rejectInteractionResponse(const NodeRef &interaction, + Value::Object authoredResponse, + std::string error) { + GraphChange change; + { + auto write = graph_.write(); + if (interaction && interaction->id().kind == NodeKind::Interaction && + write.find(interaction->id()) == interaction) { + write.setStatus(interaction, NodeStatus::Failed); + write.setField(interaction, "error", Value(std::move(error))); + write.setField(interaction, "retainedResponsePayload", + Value(std::move(authoredResponse))); + for (const NodeRef &target : + write.related(interaction, RelationKind::InteractionTarget)) { + NodeRef thread = containingThread(write, target); + if (!thread) + continue; + std::size_t pending = 0; + for (const NodeRef &candidate : + write.related(thread, RelationKind::PendingInteraction)) { + if (candidate && + (write.state(candidate)->status == NodeStatus::Pending || + write.state(candidate)->status == NodeStatus::Failed)) + ++pending; + } + write.setField(thread, "pendingInteractionCount", + Value(static_cast(pending))); + } + } + change = write.finish(); + } + return publish(std::move(change)); +} + +PromptTransition +WorkerLogic::admitPrompt(NodeAction action, + std::optional activityAt, + std::optional admittedAtMs) { + if (action.kind != NodeActionKind::SubmitPrompt || !action.target) { + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + "The prompt has no current destination thread", + {}}; + return {showNotice(std::move(effect.text)), std::nullopt}; + } + PendingPrompt pending; + pending.localPrompt = {}; + pending.thread = std::move(action.target); + pending.promptText = std::move(action.promptText); + pending.attachments = std::move(action.attachments); + pending.options = std::move(action.payload); + pending.creationCorrelation = std::move(action.correlation); + return admit(std::move(pending), activityAt, admittedAtMs); +} + +PromptTransition +WorkerLogic::admitFirstPrompt(RuntimeAction action, + std::optional activityAt, + std::optional admittedAtMs) { + if (action.kind != RuntimeActionKind::CreateThread) { + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + "The new-thread prompt could not be admitted", + {}}; + return {showNotice(std::move(effect.text)), std::nullopt}; + } + PendingPrompt pending; + pending.createsThread = true; + pending.creationCorrelation = std::move(action.correlation); + pending.promptText = std::move(action.promptText); + pending.attachments = std::move(action.attachments); + std::optional options = + takeObject(action.payload, "threadStart"); + if (std::optional turnOptions = + takeObject(action.payload, "turnStart")) + pending.turnOptions = std::move(*turnOptions); + pending.requestedName = takeString(action.payload, "requestedName"); + pending.options = options ? std::move(*options) : std::move(action.payload); + return admit(std::move(pending), activityAt, admittedAtMs); +} + +PromptTransition WorkerLogic::admit(PendingPrompt pending, + std::optional activityAt, + std::optional admittedAtMs) { + if (pending.promptText.empty() && pending.attachments.empty()) { + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + "Enter a message or attach a file before sending", + {}}; + return {showNotice(std::move(effect.text)), std::nullopt}; + } + + const std::uint64_t submission = nextSubmissionId_++; + const std::string suffix = std::to_string(submission); + pending.clientUserMessageId = "codexui-" + suffix; + if (pending.createsThread && pending.creationCorrelation.empty()) + pending.creationCorrelation = "worker-draft:" + suffix; + + GraphChange change; + NodeRef selectedDraft; + std::optional command; + bool invalidTarget = false; + std::string invalidTargetReason; + { + auto write = graph_.write(); + if (pending.createsThread) { + if (const auto creating = + creatingThreads_.find(pending.creationCorrelation); + creating != creatingThreads_.end() && creating->second && + write.find(creating->second->id()) == creating->second) { + pending.thread = creating->second; + pending.createsThread = false; + pending.options = std::move(pending.turnOptions); + } + } + if (pending.createsThread) { + NodeState threadState; + threadState.status = NodeStatus::Pending; + threadState.fields = {{"type", Value("localThread")}, + {"local", Value(true)}}; + if (!pending.requestedName.empty()) + threadState.fields.emplace("name", Value(pending.requestedName)); + pending.thread = write.upsert( + {NodeKind::Thread, "local-thread:" + suffix}, std::move(threadState)); + creatingThreads_.insert_or_assign(pending.creationCorrelation, + pending.thread); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + std::vector roots = + write.related(runtime, RelationKind::RootThread); + roots.erase(std::remove(roots.begin(), roots.end(), pending.thread), + roots.end()); + roots.insert(roots.begin(), pending.thread); + write.replaceRelated(runtime, RelationKind::RootThread, roots); + selectedDraft = pending.thread; + } else { + const NodeRef current = write.find(pending.thread->id()); + if (current != pending.thread || + pending.thread->id().kind != NodeKind::Thread) { + invalidTarget = true; + invalidTargetReason = "The destination thread is no longer available"; + } else { + const Value *recoveryOnly = + field(*write.state(current), "recoveryOnly"); + if (recoveryOnly && recoveryOnly->asBool() && *recoveryOnly->asBool()) { + invalidTarget = true; + invalidTargetReason = + "Restore this unsent prompt before sending it again"; + } + const std::string hydration = + stringField(*write.state(current), "hydrationState"); + if (!invalidTarget && + (hydration == "loading" || hydration == "failed" || + (write.state(current)->status == NodeStatus::NotLoaded && + hydration != "ready"))) { + invalidTarget = true; + invalidTargetReason = + hydration == "failed" + ? "Reload this thread before sending the preserved prompt" + : "Wait for this thread to finish loading before sending"; + } + } + } + + if (invalidTarget) { + // Queue admission already moved the user's draft off Qt-main. Preserve + // it as an explicit failed local prompt if its target disappeared or + // became recovery-only before the worker consumed the command. + NodeState threadState; + threadState.status = NodeStatus::Failed; + threadState.fields = {{"type", Value("localRecoveryThread")}, + {"local", Value(true)}, + {"recoveryOnly", Value(true)}, + {"name", Value("Unsent prompt")}}; + pending.thread = + write.upsert({NodeKind::Thread, "local-recovery-thread:" + suffix}, + std::move(threadState)); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + std::vector roots = + write.related(runtime, RelationKind::RootThread); + roots.insert(roots.begin(), pending.thread); + write.replaceRelated(runtime, RelationKind::RootThread, roots); + selectedDraft = pending.thread; + } + + if (!pending.thread) { + change = write.finish(); + } else { + NodeRef turn = activeTurn(write, pending.thread); + const bool startsTurn = !turn; + if (!turn) { + NodeState turnState; + turnState.status = + invalidTarget ? NodeStatus::Failed : NodeStatus::Pending; + turnState.fields = { + {"type", Value(invalidTarget ? "localRecoveryTurn" : "localTurn")}, + {"local", Value(true)}}; + turn = write.upsert( + {NodeKind::Turn, + (invalidTarget ? "local-recovery-turn:" : "local-turn:") + suffix}, + std::move(turnState)); + write.setParent(pending.thread, turn); + } + + NodeState promptState; + promptState.status = + invalidTarget ? NodeStatus::Failed : NodeStatus::Pending; + promptState.fields = { + {"type", Value("localPrompt")}, + {"local", Value(true)}, + {"submissionId", Value(submission)}, + {"clientUserMessageId", Value(pending.clientUserMessageId)}, + {"authoredText", Value(pending.promptText)}, + {"text", Value(composePromptMarkdown(pending.promptText, + pending.attachments))}, + {"attachments", attachmentSummaries(pending.attachments)}, + {"dispatchState", Value(invalidTarget ? "failed" : "queued")}, + {"showPendingAnimation", Value(!invalidTarget)}, + {"startsTurn", Value(startsTurn)}, + {"createsThread", Value(pending.createsThread)}, + {"threadId", Value(pending.thread->id().canonical)}}; + if (!pending.creationCorrelation.empty()) + promptState.fields.emplace("creationCorrelation", + Value(pending.creationCorrelation)); + if (pending.createsThread) { + promptState.fields.emplace("threadStartOptions", + Value(pending.options)); + promptState.fields.emplace("turnStartOptions", + Value(pending.turnOptions)); + if (!pending.requestedName.empty()) + promptState.fields.emplace("requestedName", + Value(pending.requestedName)); + } + if (invalidTarget) { + promptState.fields.emplace("error", Value(invalidTargetReason)); + promptState.fields.emplace("requiresExplicitRecovery", Value(true)); + } + if (admittedAtMs) + promptState.fields.emplace("admittedAtMs", Value(*admittedAtMs)); + if (!startsTurn) + promptState.fields.emplace( + "expectedTurnId", + Value(protocolCanonicalId(*write.state(turn), turn))); + pending.localPrompt = write.upsert( + {NodeKind::Item, "local-prompt:" + suffix}, std::move(promptState)); + write.setParent(turn, pending.localPrompt); + if (startsTurn) { + const std::array root{pending.localPrompt}; + write.replaceRelated(turn, RelationKind::TurnRootItem, root); + } + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + write.relate(runtime, RelationKind::PendingPrompt, pending.localPrompt); + write.relate(pending.thread, RelationKind::PendingPrompt, + pending.localPrompt); + if (activityAt) + advancePromptActivity(write, pending.thread, *activityAt); + if (!invalidTarget) { + const NodeRef ownerThread = pending.thread; + promptQueues_[pending.thread.get()].emplace_back(std::move(pending)); + command = takeNextPrompt(write, ownerThread); + } + change = write.finish(); + } + } + + if (selectedDraft) { + static_cast(selectThread(selectedDraft)); + } + if (invalidTarget) { + static_cast(showNotice(std::move(invalidTargetReason))); + } + return {publish(std::move(change)), std::move(command)}; +} + +void WorkerLogic::advancePromptActivity(NodeGraph::WriteAccess &write, + const NodeRef &target, + std::int64_t proposedActivityAt) { + std::int64_t activityAt = proposedActivityAt; + const auto retainMaximum = [&activityAt](const Value *value) { + if (!value) + return; + if (const std::int64_t *number = value->asInt64()) { + if (*number >= activityAt && *number < INT64_MAX) + activityAt = *number + 1; + } else if (const std::uint64_t *number = value->asUInt64()) { + if (*number >= static_cast( + std::max(0, activityAt)) && + *number < static_cast(INT64_MAX)) + activityAt = static_cast(*number) + 1; + } + }; + for (const NodeRef &node : write.orderedNodes()) { + if (!node || node->id().kind != NodeKind::Thread) + continue; + const std::shared_ptr state = write.state(node); + for (const std::string_view key : + {std::string_view("updatedAt"), std::string_view("recencyAt"), + std::string_view("localPromptActivityAt")}) { + retainMaximum(field(*state, key)); + } + } + + NodeRef thread = target; + std::unordered_set visited; + while (thread && visited.insert(thread.get()).second) { + write.setField(thread, "localPromptActivityAt", Value(activityAt)); + write.setField(thread, "localActivityAt", Value(activityAt)); + + const std::vector owners = + write.related(thread, RelationKind::ThreadOwner); + thread = owners.empty() ? NodeRef{} : owners.front(); + } +} + +NodeRef WorkerLogic::activeTurn(NodeGraph::WriteAccess &write, + const NodeRef &thread) const { + const std::vector indexed = + write.related(thread, RelationKind::ActiveTurn); + if (!indexed.empty() && indexed.front() && + indexed.front()->id().kind == NodeKind::Turn) + return indexed.front(); + return {}; +} + +std::optional +WorkerLogic::takeNextPrompt(NodeGraph::WriteAccess &write, + const NodeRef &thread) { + if (!thread || promptInFlight_.contains(thread.get())) + return std::nullopt; + auto queue = promptQueues_.find(thread.get()); + while (queue != promptQueues_.end() && !queue->second.empty()) { + PendingPrompt pending = std::move(queue->second.front()); + queue->second.pop_front(); + if (write.find(pending.localPrompt->id()) != pending.localPrompt) + continue; + + PromptCommand command; + command.kind = pending.createsThread ? PromptCommandKind::CreateThread + : PromptCommandKind::StartTurn; + command.localPrompt = pending.localPrompt; + command.thread = thread; + command.clientUserMessageId = std::move(pending.clientUserMessageId); + command.promptText = std::move(pending.promptText); + command.attachments = std::move(pending.attachments); + command.options = std::move(pending.options); + command.turnOptions = std::move(pending.turnOptions); + command.requestedName = std::move(pending.requestedName); + command.creationCorrelation = std::move(pending.creationCorrelation); + + if (!pending.createsThread) { + if (NodeRef running = activeTurn(write, thread)) { + command.kind = PromptCommandKind::SteerTurn; + command.expectedTurnId = + protocolCanonicalId(*write.state(running), running); + NodeRef oldParent = write.parent(command.localPrompt); + write.setParent(running, command.localPrompt); + if (oldParent && oldParent != running && isLocalShell(oldParent) && + write.children(oldParent).empty()) + write.remove(oldParent); + write.setField(command.localPrompt, "startsTurn", Value(false)); + write.setField(command.localPrompt, "expectedTurnId", + Value(command.expectedTurnId)); + } else { + write.setField(command.localPrompt, "startsTurn", Value(true)); + write.eraseField(command.localPrompt, "expectedTurnId"); + } + } + write.setField(command.localPrompt, "dispatchState", Value("dispatching")); + promptInFlight_.insert_or_assign(thread.get(), command.localPrompt); + if (queue->second.empty()) + promptQueues_.erase(queue); + return command; + } + if (queue != promptQueues_.end()) + promptQueues_.erase(queue); + return std::nullopt; +} + +ChannelSendStatus WorkerLogic::attachCreatedThread(PromptCommand &command, + std::string threadId, + NodeState providerState) { + NodeRef authoritative; + GraphChange change; + bool attached = false; + { + auto write = graph_.write(); + attached = attachCreatedThread(write, command, std::move(threadId), + std::move(providerState), authoritative); + change = write.finish(); + } + forgetRemoved(change); + const ChannelSendStatus status = publish(std::move(change)); + if (!attached) + return status; + + command.thread = authoritative; + command.kind = PromptCommandKind::StartTurn; + command.expectedTurnId.clear(); + command.options = std::move(command.turnOptions); + static_cast(selectThread(authoritative)); + return status; +} + +ChannelSendStatus WorkerLogic::completeCreatedThread(DecodedMessage result, + PromptCommand &command, + std::string threadId, + NodeState providerState) { + NodeRef authoritative; + GraphChange change; + bool attached = false; + { + auto write = graph_.write(); + static_cast(updater_.applyInto(write, result)); + attached = attachCreatedThread(write, command, std::move(threadId), + std::move(providerState), authoritative); + change = write.finish(); + } + forgetRemoved(change); + const ChannelSendStatus status = publish(std::move(change)); + if (!attached) + return status; + + command.thread = authoritative; + command.kind = PromptCommandKind::StartTurn; + command.expectedTurnId.clear(); + command.options = std::move(command.turnOptions); + static_cast(selectThread(authoritative)); + return status; +} + +bool WorkerLogic::attachCreatedThread(NodeGraph::WriteAccess &write, + PromptCommand &command, + std::string threadId, + NodeState providerState, + NodeRef &authoritative) { + if (command.kind != PromptCommandKind::CreateThread || threadId.empty() || + !command.thread || !command.localPrompt) + return false; + + NodeRef draft = write.find(command.thread->id()); + if (draft != command.thread || + write.find(command.localPrompt->id()) != command.localPrompt) + return false; + const std::shared_ptr draftState = write.state(draft); + authoritative = write.find({NodeKind::Thread, threadId}); + if (!authoritative) + authoritative = write.upsert({NodeKind::Thread, std::move(threadId)}, + std::move(providerState)); + // A successful thread/start result is the complete initial authority for a + // newly created thread. Treat it as display-ready so the admitted prompt + // is not hidden behind a redundant thread/read gate. + updateThreadHydration(write, authoritative, "ready", {}); + for (const std::string_view key : + {std::string_view("localActivityAt"), + std::string_view("localPromptActivityAt")}) { + if (const Value *value = field(*draftState, key)) + write.setField(authoritative, std::string(key), *value); + } + + const std::vector draftChildren = write.children(draft); + for (const NodeRef &child : draftChildren) + write.setParent(authoritative, child); + const std::vector prompts = + write.related(draft, RelationKind::PendingPrompt); + for (const NodeRef &prompt : prompts) { + write.relate(authoritative, RelationKind::PendingPrompt, prompt); + write.setField(prompt, "threadId", Value(authoritative->id().canonical)); + } + + if (NodeRef runtime = write.find({NodeKind::Runtime, "runtime"})) { + std::vector roots = + write.related(runtime, RelationKind::RootThread); + roots.erase(std::remove(roots.begin(), roots.end(), draft), roots.end()); + roots.erase(std::remove(roots.begin(), roots.end(), authoritative), + roots.end()); + roots.insert(roots.begin(), authoritative); + write.replaceRelated(runtime, RelationKind::RootThread, roots); + } + + if (auto queued = promptQueues_.find(draft.get()); + queued != promptQueues_.end()) { + std::deque moved = std::move(queued->second); + promptQueues_.erase(queued); + std::deque &destination = promptQueues_[authoritative.get()]; + for (PendingPrompt &entry : moved) { + entry.thread = authoritative; + destination.emplace_back(std::move(entry)); + } + } + if (auto inFlight = promptInFlight_.find(draft.get()); + inFlight != promptInFlight_.end()) { + NodeRef prompt = std::move(inFlight->second); + promptInFlight_.erase(inFlight); + promptInFlight_.insert_or_assign(authoritative.get(), std::move(prompt)); + } + if (!command.creationCorrelation.empty()) { + const auto creating = creatingThreads_.find(command.creationCorrelation); + if (creating != creatingThreads_.end() && creating->second == draft) + creatingThreads_.erase(creating); + } + write.remove(draft); + return true; +} + +ChannelSendStatus +WorkerLogic::markPromptDispatched(const NodeRef &localPrompt, + const ProtocolRequestId &requestId) { + GraphChange change; + { + auto write = graph_.write(); + if (!localPrompt || write.find(localPrompt->id()) != localPrompt) + return ChannelSendStatus::Accepted; + write.setField(localPrompt, "dispatchState", Value("inFlight")); + write.setField(localPrompt, "requestId", Value(requestId.canonical())); + write.setStatus(localPrompt, NodeStatus::Running); + change = write.finish(); + } + return publish(std::move(change)); +} + +PromptTransition +WorkerLogic::completePrompt(const NodeRef &localPrompt, bool accepted, + std::string error, + std::optional turnId) { + GraphChange change; + std::optional next; + { + auto write = graph_.write(); + next = completePrompt(write, localPrompt, accepted, std::move(error), + std::move(turnId)); + change = write.finish(); + } + forgetRemoved(change); + return {publish(std::move(change)), std::move(next)}; +} + +PromptTransition WorkerLogic::completePromptResult( + DecodedMessage result, const NodeRef &localPrompt, bool accepted, + std::string error, std::optional turnId) { + GraphChange change; + std::optional next; + { + auto write = graph_.write(); + bool requiresRecovery = false; + if (localPrompt && write.find(localPrompt->id()) == localPrompt) { + const std::shared_ptr state = write.state(localPrompt); + const Value *value = field(*state, "requiresExplicitRecovery"); + requiresRecovery = value && value->asBool() && *value->asBool(); + } + if (requiresRecovery) { + // Deletion or a provider-generation reset has already made delivery + // uncertain. Retire only the exact operation; a late result must not + // recreate the removed destination or silently acknowledge the prompt. + if (result.expectedNode && + write.find(result.expectedNode->id()) == result.expectedNode) + write.remove(result.expectedNode); + forgetPrompt(localPrompt); + } else { + static_cast(updater_.applyInto(write, result)); + next = completePrompt(write, localPrompt, accepted, std::move(error), + std::move(turnId)); + } + change = write.finish(); + } + forgetRemoved(change); + return {publish(std::move(change)), std::move(next)}; +} + +std::optional WorkerLogic::completePrompt( + NodeGraph::WriteAccess &write, const NodeRef &localPrompt, bool accepted, + std::string error, std::optional turnId) { + if (!localPrompt || write.find(localPrompt->id()) != localPrompt) + return std::nullopt; + const std::shared_ptr initialState = + write.state(localPrompt); + const Value *recovery = field(*initialState, "requiresExplicitRecovery"); + if (recovery && recovery->asBool() && *recovery->asBool()) { + forgetPrompt(localPrompt); + return std::nullopt; + } + NodeRef thread = containingThread(write, localPrompt); + if (!thread) + return std::nullopt; + if (auto inFlight = promptInFlight_.find(thread.get()); + inFlight != promptInFlight_.end() && inFlight->second == localPrompt) + promptInFlight_.erase(inFlight); + + const std::shared_ptr promptState = write.state(localPrompt); + const Value *materialized = field(*promptState, "uiMaterialized"); + const bool uiMaterialized = + materialized && materialized->asBool() && *materialized->asBool(); + const Value *startsTurnValue = field(*promptState, "startsTurn"); + const bool startsTurn = startsTurnValue && startsTurnValue->asBool() && + *startsTurnValue->asBool(); + + if (uiMaterialized) { + NodeRef provisionalTurn = write.parent(localPrompt); + write.remove(localPrompt); + if (provisionalTurn && isLocalShell(provisionalTurn) && + write.children(provisionalTurn).empty()) + write.remove(provisionalTurn); + if (thread && isLocalShell(thread) && write.children(thread).empty()) + write.remove(thread); + } else if (accepted) { + write.setField(localPrompt, "dispatchState", + Value("awaitingMaterialization")); + // Request acceptance is not yet visible completion. Keep the optimistic + // card active until the correlated authoritative user item is projected + // into that exact card. + write.setField(localPrompt, "showPendingAnimation", Value(true)); + write.eraseField(localPrompt, "error"); + write.eraseField(localPrompt, "requiresExplicitRecovery"); + write.setStatus(localPrompt, NodeStatus::Running); + if (turnId && !turnId->empty()) { + NodeRef turn = + write.upsert(scopedTurnNodeId(thread->id().canonical, *turnId)); + write.setField(turn, "protocolId", Value(*turnId)); + write.setField(turn, "protocolThreadId", Value(thread->id().canonical)); + if (!write.parent(turn)) + write.setParent(thread, turn); + const NodeStatus turnStatus = write.state(turn)->status; + if (turnStatus == NodeStatus::Unknown || + turnStatus == NodeStatus::Pending || + turnStatus == NodeStatus::NotLoaded) + write.setStatus(turn, NodeStatus::Running); + const NodeStatus currentTurnStatus = write.state(turn)->status; + if (currentTurnStatus == NodeStatus::Running) { + const std::array activeTurn{turn}; + write.replaceRelated(thread, RelationKind::ActiveTurn, activeTurn); + } else + write.unrelate(thread, RelationKind::ActiveTurn, turn); + NodeRef previous = write.parent(localPrompt); + write.setParent(turn, localPrompt); + if (startsTurn) { + NodeRef materializedItem; + std::vector ordered = write.children(turn); + for (const NodeRef &candidate : ordered) { + if (!candidate || candidate == localPrompt || + candidate->id().kind != NodeKind::Item) + continue; + const std::vector prompts = + write.related(candidate, RelationKind::PromptMaterialization); + if (std::find(prompts.begin(), prompts.end(), localPrompt) != + prompts.end()) { + materializedItem = candidate; + break; + } + } + ordered.erase(std::remove(ordered.begin(), ordered.end(), localPrompt), + ordered.end()); + if (materializedItem) + ordered.erase( + std::remove(ordered.begin(), ordered.end(), materializedItem), + ordered.end()); + ordered.insert(ordered.begin(), localPrompt); + if (materializedItem) + ordered.insert(ordered.begin() + 1, materializedItem); + write.replaceChildren(turn, ordered); + const std::vector roots = + write.related(turn, RelationKind::TurnRootItem); + if (roots.empty() || + std::ranges::find(roots, localPrompt) != roots.end()) { + const std::array root{ + materializedItem ? materializedItem : localPrompt}; + write.replaceRelated(turn, RelationKind::TurnRootItem, root); + } + } + if (previous && previous != turn) + write.unrelate(previous, RelationKind::TurnRootItem, localPrompt); + if (previous && previous != turn && isLocalShell(previous) && + write.children(previous).empty()) + write.remove(previous); + write.setField(localPrompt, "turnId", Value(*turnId)); + } + } else { + const std::string failure = + error.empty() ? "Prompt submission failed" : std::move(error); + write.setField(localPrompt, "dispatchState", Value("failed")); + write.setField(localPrompt, "showPendingAnimation", Value(false)); + write.setField(localPrompt, "error", Value(failure)); + write.setField(localPrompt, "requiresExplicitRecovery", Value(true)); + write.setStatus(localPrompt, NodeStatus::Failed); + + // A failed thread/start has no canonical destination for later prompts. + // Keep every authored draft visible as failed; never turn a queued draft + // into an automatic request against the local-only thread id. + if (thread->id().canonical.starts_with("local-thread:")) { + const std::string correlation = + stringField(*write.state(localPrompt), "creationCorrelation"); + if (!correlation.empty()) { + const auto creating = creatingThreads_.find(correlation); + if (creating != creatingThreads_.end() && creating->second == thread) + creatingThreads_.erase(creating); + } + if (auto queued = promptQueues_.find(thread.get()); + queued != promptQueues_.end()) { + for (const PendingPrompt &pending : queued->second) { + if (!pending.localPrompt || + write.find(pending.localPrompt->id()) != pending.localPrompt) + continue; + write.setField(pending.localPrompt, "dispatchState", Value("failed")); + write.setField(pending.localPrompt, "error", Value(failure)); + write.setField(pending.localPrompt, "requiresExplicitRecovery", + Value(true)); + write.setStatus(pending.localPrompt, NodeStatus::Failed); + } + promptQueues_.erase(queued); + } + } + } + if (accepted || !thread->id().canonical.starts_with("local-thread:")) + return takeNextPrompt(write, thread); + return std::nullopt; +} + +PromptTransition WorkerLogic::failPrompt(const NodeRef &localPrompt, + std::string error) { + return completePrompt(localPrompt, false, std::move(error)); +} + +ChannelSendStatus WorkerLogic::promptMaterialized(const NodeRef &localPrompt) { + GraphChange change; + bool retainedForResult = false; + { + auto write = graph_.write(); + if (!localPrompt || write.find(localPrompt->id()) != localPrompt) + return ChannelSendStatus::Accepted; + NodeRef turn = write.parent(localPrompt); + NodeRef thread = turn ? write.parent(turn) : NodeRef{}; + const auto inFlight = + thread ? promptInFlight_.find(thread.get()) : promptInFlight_.end(); + retainedForResult = + inFlight != promptInFlight_.end() && inFlight->second == localPrompt; + if (retainedForResult) { + // The authoritative user item can arrive before the turn request's + // JSON-RPC result. The widget has moved to that item, but this small + // current node must keep the per-thread dispatch slot until the exact + // result advances the queue. + write.setField(localPrompt, "uiMaterialized", Value(true)); + } else { + write.remove(localPrompt); + if (turn && isLocalShell(turn) && write.children(turn).empty()) + write.remove(turn); + if (thread && isLocalShell(thread) && write.children(thread).empty()) + write.remove(thread); + } + change = write.finish(); + } + if (!retainedForResult) + forgetPrompt(localPrompt); + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::acknowledgeUiDetached(NodeRef node) { + GraphChange change; + { + const std::array detached{std::move(node)}; + auto write = graph_.write(); + write.releaseRetired(detached); + change = write.finish(); + } + return publish(std::move(change)); +} + +ChannelSendStatus WorkerLogic::sendWorkerStopped(std::string reason) { + WorkerStopped stopped{std::move(reason)}; + return channels_.sendWorkerStopped(stopped); +} + +ChannelSendStatus WorkerLogic::publish(GraphChange change) { + return channels_.sendGraphChanged(std::move(change)); +} + +void WorkerLogic::resetProviderDerived(NodeGraph::WriteAccess &write, + std::string_view reason) { + const std::vector nodes = write.orderedNodes(); + std::unordered_set retained; + std::vector prompts; + std::vector interactions; + if (NodeRef runtime = write.find({NodeKind::Runtime, "runtime"})) + retained.insert(runtime.get()); + if (NodeRef connection = write.find({NodeKind::Connection, "connection"})) + retained.insert(connection.get()); + + for (const NodeRef &node : nodes) { + if (node && node->id().kind == NodeKind::Interaction) { + interactions.emplace_back(node); + retained.insert(node.get()); + continue; + } + if (!node || node->id().kind != NodeKind::Item || + !isLocalPrompt(*write.state(node))) + continue; + prompts.emplace_back(node); + retained.insert(node.get()); + } + + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + retained.insert(runtime.get()); + write.setField(runtime, "initialized", Value(false)); + for (const NodeRef &interaction : interactions) { + write.setStatus(interaction, NodeStatus::Failed); + write.setField(interaction, "recoveryOnly", Value(true)); + write.setField(interaction, "error", + Value(reason.empty() + ? "The provider reset before the response was sent" + : std::string(reason))); + } + std::unordered_map recoveryByFormerThread; + std::vector recoveryThreads; + for (const NodeRef &prompt : prompts) { + NodeRef formerThread = containingThread(write, prompt); + NodeRef recoveryThread; + if (const auto found = recoveryByFormerThread.find(formerThread.get()); + found != recoveryByFormerThread.end()) { + recoveryThread = found->second; + } else { + const std::string recoveryId = std::to_string(nextSubmissionId_++); + NodeState threadState; + threadState.status = NodeStatus::Failed; + threadState.fields = {{"type", Value("localRecoveryThread")}, + {"local", Value(true)}, + {"recoveryOnly", Value(true)}, + {"name", Value("Unsent prompt")}}; + recoveryThread = write.upsert( + {NodeKind::Thread, "local-recovery-thread:" + recoveryId}, + std::move(threadState)); + recoveryByFormerThread.emplace(formerThread.get(), recoveryThread); + recoveryThreads.emplace_back(recoveryThread); + } + const std::string turnId = std::to_string(nextSubmissionId_++); + NodeState turnState; + turnState.status = NodeStatus::Failed; + turnState.fields = {{"type", Value("localRecoveryTurn")}, + {"local", Value(true)}}; + NodeRef recoveryTurn = + write.upsert({NodeKind::Turn, "local-recovery-turn:" + turnId}, + std::move(turnState)); + write.setParent(recoveryThread, recoveryTurn); + write.setParent(recoveryTurn, prompt); + + write.setStatus(prompt, NodeStatus::Failed); + write.setField(prompt, "dispatchState", Value("uncertain")); + write.setField(prompt, "error", + Value(reason.empty() ? "Provider state was reset" + : std::string(reason))); + write.setField(prompt, "requiresExplicitRecovery", Value(true)); + write.setField(prompt, "threadId", Value(recoveryThread->id().canonical)); + write.eraseField(prompt, "turnId"); + write.eraseField(prompt, "expectedTurnId"); + write.eraseField(prompt, "requestId"); + write.relate(runtime, RelationKind::PendingPrompt, prompt); + write.relate(recoveryThread, RelationKind::PendingPrompt, prompt); + } + + // Every provider-derived node, including the former canonical owners of a + // recovery prompt, is retired. Reused provider ids therefore allocate fresh + // NodeRefs and cannot inherit local recovery fields. + std::vector removed; + removed.reserve(nodes.size()); + for (const NodeRef &node : nodes) + if (!retained.contains(node.get())) + removed.emplace_back(node); + write.removeMany(removed); + write.replaceRelated(runtime, RelationKind::RootThread, recoveryThreads); + promptQueues_.clear(); + promptInFlight_.clear(); + creatingThreads_.clear(); +} + +void WorkerLogic::forgetPrompt(const NodeRef &localPrompt) { + if (!localPrompt) + return; + for (auto iterator = promptInFlight_.begin(); + iterator != promptInFlight_.end();) { + if (iterator->second == localPrompt) + iterator = promptInFlight_.erase(iterator); + else + ++iterator; + } + for (auto iterator = promptQueues_.begin(); + iterator != promptQueues_.end();) { + std::erase_if(iterator->second, [&](const PendingPrompt &pending) { + return pending.localPrompt == localPrompt; + }); + if (iterator->second.empty()) + iterator = promptQueues_.erase(iterator); + else + ++iterator; + } +} + +} // namespace codexui::nodegraph diff --git a/src/codex/nodegraph/WorkerLogic.h b/src/codex/nodegraph/WorkerLogic.h new file mode 100644 index 0000000..9e7c745 --- /dev/null +++ b/src/codex/nodegraph/WorkerLogic.h @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_NODEGRAPH_WORKERLOGIC_H +#define CODEXUI_CODEX_NODEGRAPH_WORKERLOGIC_H + +#include "codex/nodegraph/ProtocolUpdater.h" +#include "codex/nodegraph/ThreadChannels.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::nodegraph { + +struct WorkerGenerations final { + std::uint64_t connection = 0; + std::uint64_t provider = 0; + + bool operator==(const WorkerGenerations &) const = default; +}; + +struct WorkerApplyResult final { + ChannelSendStatus status = ChannelSendStatus::Accepted; + NodeRef primary; +}; + +enum class PromptCommandKind : std::uint8_t { + CreateThread, + StartTurn, + SteerTurn, +}; + +// One concrete command handed to ClientRuntime for one direct CodexBridge +// call. Large newly-authored input is moved, never copied, from the Qt action +// through the worker's bounded pending queue into this command. +struct PromptCommand final { + PromptCommandKind kind = PromptCommandKind::StartTurn; + NodeRef localPrompt; + NodeRef thread; + std::string clientUserMessageId; + std::string promptText; + std::vector attachments; + Value::Object options; + std::string expectedTurnId; + + // CreateThread only: attachCreatedThread changes kind to StartTurn and + // moves turnOptions into options after thread/start returns. + Value::Object turnOptions; + std::string requestedName; + std::string creationCorrelation; +}; + +struct PromptTransition final { + ChannelSendStatus status = ChannelSendStatus::Accepted; + std::optional command; +}; + +// Application logic hosted by the existing SNode.C worker. CodexBridge gives +// it already-decoded messages; it publishes current graph state and wakes Qt. +class WorkerLogic final { +public: + WorkerLogic(NodeGraph &graph, ThreadChannels &channels) noexcept; + WorkerLogic(const WorkerLogic &) = delete; + WorkerLogic &operator=(const WorkerLogic &) = delete; + + [[nodiscard]] ChannelSendStatus apply(DecodedMessage message); + [[nodiscard]] WorkerApplyResult applyDetailed(DecodedMessage message); + [[nodiscard]] WorkerGenerations generations() const; + + // A successful transport connection begins a new connection generation. + // Retry and disconnect events retain that generation so late bridge/provider + // events can still be recognized as belonging to the current connection. + [[nodiscard]] ChannelSendStatus transportEvent(std::string state, + std::string detail = {}); + + // CodexBridge supplies its complete current addressing/provider facts. A + // stale provider generation cannot replace newer provider state. + [[nodiscard]] ChannelSendStatus bridgeState( + std::string connectionId, std::string role, + std::string controllerConnectionId, std::uint64_t providerGeneration, + std::optional providerState, std::string detail = {}); + + [[nodiscard]] ChannelSendStatus connectionSettings(Value::Object settings); + + [[nodiscard]] ChannelSendStatus threadHydration(const NodeRef &thread, + std::string state, + std::string error = {}); + [[nodiscard]] ChannelSendStatus + completeThreadHydration(DecodedMessage result, const NodeRef &thread, + std::string state, std::string error = {}); + [[nodiscard]] std::vector activeAgentChildren(const NodeRef &thread); + [[nodiscard]] ChannelSendStatus showNotice(std::string message); + [[nodiscard]] ChannelSendStatus selectThread(const NodeRef &thread); + + [[nodiscard]] ChannelSendStatus + resolveInteraction(const ProtocolRequestId &requestId, bool accepted, + std::string error = {}); + [[nodiscard]] ChannelSendStatus resolveInteraction(const NodeRef &interaction, + bool accepted, + std::string error = {}); + [[nodiscard]] ChannelSendStatus + rejectInteractionResponse(const NodeRef &interaction, + Value::Object authoredResponse, std::string error); + + // RuntimeAction::CreateThread uses the explicit payload shape + // {threadStart: Object, turnStart: Object, requestedName: String}. + // NodeAction::SubmitPrompt payload is the exact extra turn/start options. + [[nodiscard]] PromptTransition + admitPrompt(NodeAction action, + std::optional activityAt = std::nullopt, + std::optional admittedAtMs = std::nullopt); + [[nodiscard]] PromptTransition + admitFirstPrompt(RuntimeAction action, + std::optional activityAt = std::nullopt, + std::optional admittedAtMs = std::nullopt); + [[nodiscard]] ChannelSendStatus + attachCreatedThread(PromptCommand &command, std::string threadId, + NodeState providerState = {}); + [[nodiscard]] ChannelSendStatus + completeCreatedThread(DecodedMessage result, PromptCommand &command, + std::string threadId, NodeState providerState = {}); + [[nodiscard]] ChannelSendStatus + markPromptDispatched(const NodeRef &localPrompt, + const ProtocolRequestId &requestId); + [[nodiscard]] PromptTransition + completePrompt(const NodeRef &localPrompt, bool accepted, + std::string error = {}, + std::optional turnId = std::nullopt); + [[nodiscard]] PromptTransition + completePromptResult(DecodedMessage result, const NodeRef &localPrompt, + bool accepted, std::string error = {}, + std::optional turnId = std::nullopt); + [[nodiscard]] PromptTransition failPrompt(const NodeRef &localPrompt, + std::string error); + [[nodiscard]] ChannelSendStatus + promptMaterialized(const NodeRef &localPrompt); + + // Qt has already cleared the opaque attachment and destroyed its QWidget. + // Releasing this recovery pin is deliberately revision-neutral. + [[nodiscard]] ChannelSendStatus acknowledgeUiDetached(NodeRef node); + + [[nodiscard]] ChannelSendStatus sendWorkerStopped(std::string reason); + +private: + struct PendingPrompt final { + NodeRef localPrompt; + NodeRef thread; + std::string clientUserMessageId; + std::string promptText; + std::vector attachments; + Value::Object options; + Value::Object turnOptions; + std::string requestedName; + std::string creationCorrelation; + bool createsThread = false; + }; + + [[nodiscard]] ChannelSendStatus publish(GraphChange change); + void forgetRemoved(const GraphChange &change); + void updateThreadHydration(NodeGraph::WriteAccess &write, + const NodeRef &thread, std::string state, + std::string error); + [[nodiscard]] bool attachCreatedThread(NodeGraph::WriteAccess &write, + PromptCommand &command, + std::string threadId, + NodeState providerState, + NodeRef &authoritative); + [[nodiscard]] std::optional + completePrompt(NodeGraph::WriteAccess &write, const NodeRef &localPrompt, + bool accepted, std::string error, + std::optional turnId); + [[nodiscard]] PromptTransition + admit(PendingPrompt pending, std::optional activityAt, + std::optional admittedAtMs); + [[nodiscard]] std::optional + takeNextPrompt(NodeGraph::WriteAccess &write, const NodeRef &thread); + [[nodiscard]] NodeRef activeTurn(NodeGraph::WriteAccess &write, + const NodeRef &thread) const; + void resetProviderDerived(NodeGraph::WriteAccess &write, + std::string_view reason); + void advancePromptActivity(NodeGraph::WriteAccess &write, + const NodeRef &thread, + std::int64_t proposedActivityAt); + void forgetPrompt(const NodeRef &localPrompt); + + NodeGraph &graph_; + ThreadChannels &channels_; + ProtocolUpdater updater_; + std::uint64_t nextSubmissionId_ = 1; + std::uint64_t nextNoticeSerial_ = 1; + std::uint64_t nextSelectionSerial_ = 1; + std::unordered_map> promptQueues_; + std::unordered_map promptInFlight_; + std::unordered_map creatingThreads_; +}; + +} // namespace codexui::nodegraph + +#endif // CODEXUI_CODEX_NODEGRAPH_WORKERLOGIC_H diff --git a/src/codex/ui/NodeGraphUiAdapter.cpp b/src/codex/ui/NodeGraphUiAdapter.cpp new file mode 100644 index 0000000..a25fb5f --- /dev/null +++ b/src/codex/ui/NodeGraphUiAdapter.cpp @@ -0,0 +1,1848 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/NodeGraphUiAdapter.h" + +#include "codex/UiStatus.h" +#include "codex/nodegraph/ProtocolUpdater.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex::ui { +namespace { +using namespace middle; + +const nodegraph::Value *graphField(const nodegraph::NodeState &state, + std::string_view name) { + const auto found = state.fields.find(name); + return found == state.fields.end() ? nullptr : &found->second; +} + +const nodegraph::Value *graphMember(const nodegraph::Value::Object &object, + std::string_view name) { + const auto found = object.find(name); + return found == object.end() ? nullptr : &found->second; +} + +std::string graphString(const nodegraph::Value *value) { + if (!value) + return {}; + if (const auto *text = value->asString()) + return *text; + if (const auto *number = value->asInt64()) + return std::to_string(*number); + if (const auto *number = value->asUInt64()) + return std::to_string(*number); + return {}; +} + +bool graphBool(const nodegraph::Value *value) { + if (!value) + return false; + if (const auto *boolean = value->asBool()) + return *boolean; + return false; +} + +std::optional graphSize(const nodegraph::Value *value) { + if (!value) + return std::nullopt; + if (const auto *number = value->asUInt64(); + number && *number <= std::numeric_limits::max()) + return static_cast(*number); + if (const auto *number = value->asInt64(); number && *number >= 0) + return static_cast(*number); + return std::nullopt; +} + +bool graphProviderHasMoreHistory(const nodegraph::NodeState &state) { + if (graphBool(graphField(state, "historyHasMore"))) + return true; + if (!graphString(graphField(state, "historyNextCursor")).empty()) + return true; + const nodegraph::Value *history = graphField(state, "history"); + const auto *object = history ? history->asObject() : nullptr; + return object && (graphBool(graphMember(*object, "hasMore")) || + !graphString(graphMember(*object, "nextCursor")).empty()); +} + +std::string graphStatus(const nodegraph::NodeState &state) { + if (const nodegraph::Value *value = graphField(state, "status")) { + if (const auto *object = value->asObject()) + return graphString(graphMember(*object, "type")); + if (std::string status = graphString(value); !status.empty()) + return status; + } + switch (state.status) { + case nodegraph::NodeStatus::Pending: + return "pending"; + case nodegraph::NodeStatus::Running: + return "inProgress"; + case nodegraph::NodeStatus::Completed: + return "completed"; + case nodegraph::NodeStatus::Failed: + return "failed"; + case nodegraph::NodeStatus::Interrupted: + return "interrupted"; + case nodegraph::NodeStatus::NotLoaded: + return "notLoaded"; + case nodegraph::NodeStatus::Connected: + return "connected"; + case nodegraph::NodeStatus::Disconnected: + return "disconnected"; + case nodegraph::NodeStatus::Unknown: + return {}; + } + return {}; +} + +bool graphTurnIsActive(const nodegraph::NodeState &state) { + if (state.status == nodegraph::NodeStatus::Running) + return true; + // Once Send has been admitted, its provisional local turn is the user's + // active Turn/You surface even before the app-server returns the canonical + // turn id. Do not extend this optimistic presentation to provider-owned + // pending history. + return state.status == nodegraph::NodeStatus::Pending && + graphBool(graphField(state, "local")); +} + +std::optional graphInteger(const nodegraph::Value *value) { + if (!value) + return std::nullopt; + if (const auto *number = value->asInt64()) + return *number; + if (const auto *number = value->asUInt64(); + number && *number <= static_cast( + std::numeric_limits::max())) + return static_cast(*number); + return std::nullopt; +} + +std::vector graphStrings(const nodegraph::Value *value) { + std::vector result; + const auto *array = value ? value->asArray() : nullptr; + if (!array) + return result; + result.reserve(array->size()); + for (const nodegraph::Value &entry : *array) + if (const auto *text = entry.asString()) + result.push_back(*text); + return result; +} + +std::uint64_t graphOmittedTextBytes(const nodegraph::NodeState &state, + std::string_view field) { + const nodegraph::Value *retention = graphField(state, "textRetention"); + const auto *retentionObject = retention ? retention->asObject() : nullptr; + const nodegraph::Value *entry = + retentionObject ? graphMember(*retentionObject, field) : nullptr; + const auto *entryObject = entry ? entry->asObject() : nullptr; + const nodegraph::Value *discarded = + entryObject ? graphMember(*entryObject, "discardedBytes") : nullptr; + if (const auto *number = discarded ? discarded->asUInt64() : nullptr) + return *number; + if (const auto *number = discarded ? discarded->asInt64() : nullptr; + number && *number >= 0) + return static_cast(*number); + return 0; +} + +std::string withTruncationNotice(std::string value, std::uint64_t omitted, + std::string_view subject, bool markdown) { + if (omitted == 0) + return value; + const std::string notice = "Earlier " + std::string(subject) + + " was truncated (" + std::to_string(omitted) + + " bytes omitted)."; + return markdown ? "> " + notice + "\n\n" + value + : '[' + notice + "]\n" + value; +} + +std::string graphMessageText(const nodegraph::NodeState &state) { + std::string result; + if (const auto *content = graphField(state, "content"); + content && content->asArray()) { + for (const nodegraph::Value &entry : *content->asArray()) { + const auto *object = entry.asObject(); + const std::string text = + object ? graphString(graphMember(*object, "text")) : std::string{}; + if (text.empty()) + continue; + if (!result.empty()) + result.push_back('\n'); + result += text; + } + } + return result.empty() ? graphString(graphField(state, "text")) : result; +} + +std::vector graphImagePaths(const nodegraph::NodeState &state) { + std::vector result; + const auto *content = graphField(state, "content"); + const auto *array = content ? content->asArray() : nullptr; + if (!array) + return result; + for (const nodegraph::Value &entry : *array) { + const auto *object = entry.asObject(); + if (!object || graphString(graphMember(*object, "type")) != "localImage") + continue; + if (std::string path = graphString(graphMember(*object, "path")); + !path.empty()) + result.push_back(std::move(path)); + } + return result; +} + +std::vector +graphLocalPromptImagePaths(const nodegraph::NodeState &state) { + std::vector result; + const nodegraph::Value *attachments = graphField(state, "attachments"); + const auto *array = attachments ? attachments->asArray() : nullptr; + if (!array) + return result; + for (const nodegraph::Value &entry : *array) { + const auto *object = entry.asObject(); + if (!object || + !graphString(graphMember(*object, "mimeType")).starts_with("image/")) + continue; + if (std::string path = graphString(graphMember(*object, "path")); + !path.empty()) + result.emplace_back(std::move(path)); + } + return result; +} + +std::string graphJoinedText(const nodegraph::Value *value) { + const auto *array = value ? value->asArray() : nullptr; + if (!array) + return graphString(value); + std::string result; + for (const nodegraph::Value &entry : *array) { + std::string text = graphString(&entry); + if (text.empty()) + if (const auto *object = entry.asObject()) + text = graphString(graphMember(*object, "text")); + if (text.empty()) + continue; + if (!result.empty()) + result += ", "; + result += text; + } + return result; +} + +std::pair graphDiffCounts(std::string_view diff) { + int additions = 0; + int deletions = 0; + for (std::size_t offset = 0; offset <= diff.size();) { + const std::size_t end = diff.find('\n', offset); + const std::string_view line = + diff.substr(offset, end == std::string_view::npos ? diff.size() - offset + : end - offset); + if (!line.starts_with("+++ ") && !line.starts_with("--- ")) { + if (line.starts_with('+')) + ++additions; + else if (line.starts_with('-')) + ++deletions; + } + if (end == std::string_view::npos) + break; + offset = end + 1; + } + return {additions, deletions}; +} + +constexpr std::size_t MaximumGraphActivityDetailCharacters = 4000; + +class GraphDetailBuilder final { +public: + void append(std::string_view text) { + if (text.empty() || truncated_) + return; + const std::size_t remaining = + MaximumGraphActivityDetailCharacters - value_.size(); + if (text.size() <= remaining) { + value_.append(text); + return; + } + value_.append(text.substr(0, remaining)); + truncated_ = true; + } + + void indent(int depth) { + for (int index = 0; index < depth; ++index) + append(" "); + } + + [[nodiscard]] bool truncated() const noexcept { return truncated_; } + + [[nodiscard]] std::string finish() && { + if (truncated_) + value_ += "\n\n[Activity details truncated]"; + return std::move(value_); + } + +private: + std::string value_; + bool truncated_ = false; +}; + +void appendGraphDetail(GraphDetailBuilder &builder, + const nodegraph::Value &value, int depth) { + if (builder.truncated()) + return; + if (value.isNull()) { + builder.append("none"); + return; + } + if (const auto *boolean = value.asBool()) { + builder.append(*boolean ? "true" : "false"); + return; + } + if (const auto *number = value.asInt64()) { + builder.append(std::to_string(*number)); + return; + } + if (const auto *number = value.asUInt64()) { + builder.append(std::to_string(*number)); + return; + } + if (const auto *number = value.asDouble()) { + builder.append(std::to_string(*number)); + return; + } + if (const auto *text = value.asString()) { + builder.append(*text); + return; + } + if (depth >= 4) { + builder.append("nested detail omitted"); + return; + } + if (const auto *array = value.asArray()) { + if (array->empty()) { + builder.append("none"); + return; + } + for (const nodegraph::Value &entry : *array) { + builder.append("\n"); + builder.indent(depth + 1); + builder.append("- "); + appendGraphDetail(builder, entry, depth + 1); + if (builder.truncated()) + return; + } + return; + } + const auto *object = value.asObject(); + if (!object || object->empty()) { + builder.append("none"); + return; + } + for (const auto &[key, entry] : *object) { + builder.append("\n"); + builder.indent(depth + 1); + builder.append(key); + builder.append(": "); + appendGraphDetail(builder, entry, depth + 1); + if (builder.truncated()) + return; + } +} + +std::string graphDisplayDetail(const nodegraph::NodeState &state) { + GraphDetailBuilder builder; + for (const auto &[key, value] : state.fields) { + builder.append(key); + builder.append(": "); + appendGraphDetail(builder, value, 0); + if (builder.truncated()) + break; + builder.append("\n"); + } + return std::move(builder).finish(); +} + +bool graphHasStructuredPlan(const nodegraph::NodeState &state) { + if (!graphString(graphField(state, "planExplanation")).empty()) + return true; + const nodegraph::Value *plan = graphField(state, "plan"); + if (!plan) + return false; + if (const auto *steps = plan->asArray()) + return !steps->empty(); + const auto *object = plan->asObject(); + if (!object) + return false; + if (!graphString(graphMember(*object, "explanation")).empty()) + return true; + const nodegraph::Value *steps = graphMember(*object, "steps"); + return steps && steps->asArray() && !steps->asArray()->empty(); +} + +PlanData graphPlanData(const nodegraph::NodeState &state) { + PlanData result; + result.explanation = graphString(graphField(state, "planExplanation")); + const nodegraph::Value *plan = graphField(state, "plan"); + const nodegraph::Value::Array *steps = plan ? plan->asArray() : nullptr; + if (const auto *object = plan ? plan->asObject() : nullptr) { + if (result.explanation.empty()) + result.explanation = graphString(graphMember(*object, "explanation")); + const nodegraph::Value *nested = graphMember(*object, "steps"); + steps = nested ? nested->asArray() : nullptr; + } + if (!steps) + return result; + result.steps.reserve(steps->size()); + for (const nodegraph::Value &entry : *steps) { + const auto *object = entry.asObject(); + if (!object) + continue; + std::string text = graphString(graphMember(*object, "step")); + if (text.empty()) + text = graphString(graphMember(*object, "text")); + if (!text.empty()) + result.steps.push_back( + {std::move(text), graphString(graphMember(*object, "status"))}); + } + return result; +} + +CardKind graphCardKind(const nodegraph::NodeState &state) { + const std::string type = graphString(graphField(state, "type")); + if (type == "localPrompt") + return CardKind::LocalPrompt; + if (type == "userMessage") + return CardKind::UserMessage; + if (type == "agentMessage") + return CardKind::AgentMessage; + if (type == "commandExecution") + return CardKind::CommandExecution; + if (type == "collabAgentToolCall" || type == "subAgentActivity") + return CardKind::AgentActivity; + if (type == "reasoning") + return CardKind::Reasoning; + if (type == "fileChange") + return CardKind::FileChanges; + if (type == "imageGeneration" || type == "imageView") + return CardKind::ImageGeneration; + if ((type == "plan" && !graphMessageText(state).empty()) || + graphHasStructuredPlan(state)) + return CardKind::Plan; + return CardKind::GenericActivity; +} + +bool graphCardVisible(const nodegraph::NodeState &state, + const NodeGraphUiAdapter::ConversationOptions &options) { + const CardKind kind = graphCardKind(state); + if (kind == CardKind::Reasoning) + return options.showReasoning; + if (kind != CardKind::AgentMessage) + return true; + return graphString(graphField(state, "phase")) == "final_answer" || + options.showCodexUpdates; +} + +VisibleCardData graphCardData(const nodegraph::NodeRef &item, + std::string threadId, std::string turnId, + const nodegraph::NodeState &state) { + const std::string itemId = + item ? nodegraph::protocolCanonicalId(state, item) : std::string{}; + const std::string type = graphString(graphField(state, "type")); + GenericActivityData generic; + generic.type = type; + generic.status = graphStatus(state); + generic.displayDetail = graphDisplayDetail(state); + VisibleCardData result{AuthoritativeItemKey{threadId, turnId, itemId}, + CardKind::GenericActivity, + std::move(threadId), + std::move(turnId), + itemId, + std::move(generic)}; + + result.kind = graphCardKind(state); + switch (result.kind) { + case CardKind::UserMessage: { + result.payload = + UserMessageData{graphMessageText(state), graphImagePaths(state)}; + if (const auto submission = + graphInteger(graphField(state, "localSubmissionId")); + submission && *submission >= 0) + result.key = LocalPromptKey{static_cast(*submission)}; + break; + } + case CardKind::AgentMessage: + result.payload = AgentMessageData{ + withTruncationNotice(graphMessageText(state), + graphOmittedTextBytes(state, "text"), + "Codex response", true), + graphString(graphField(state, "phase")) == "final_answer"}; + break; + case CardKind::CommandExecution: { + std::string outputField = "aggregatedOutput"; + std::string output = graphString(graphField(state, "aggregatedOutput")); + if (output.empty()) { + outputField = "output"; + output = graphString(graphField(state, "output")); + } + output = withTruncationNotice(std::move(output), + graphOmittedTextBytes(state, outputField), + "command output", false); + if (!terminalOutputHasVisibleText(output)) + output.clear(); + const auto exit = graphInteger(graphField(state, "exitCode")); + auto duration = graphInteger(graphField(state, "durationMs")); + if (!duration) + duration = graphInteger(graphField(state, "duration_ms")); + result.payload = CommandExecutionData{ + graphString(graphField(state, "command")), + std::move(output), + graphStatus(state), + graphString(graphField(state, "cwd")), + exit ? std::optional(static_cast(*exit)) : std::nullopt, + duration}; + break; + } + case CardKind::AgentActivity: + result.payload = + AgentActivityData{graphString(graphField(state, "tool")), + graphStatus(state), + graphString(graphField(state, "kind")), + graphString(graphField(state, "prompt")), + graphString(graphField(state, "resultText")), + graphStrings(graphField(state, "receiverThreadIds")), + graphString(graphField(state, "model")), + graphString(graphField(state, "reasoningEffort")), + graphString(graphField(state, "agentThreadId")), + graphString(graphField(state, "agentPath")), + graphString(graphField(state, "senderThreadId"))}; + break; + case CardKind::Reasoning: + result.payload = ReasoningData{withTruncationNotice( + graphJoinedText(graphField(state, "summary")), + graphOmittedTextBytes(state, "summary"), "reasoning", true)}; + break; + case CardKind::FileChanges: { + FileChangesData projected{graphStatus(state), {}}; + const auto *changes = graphField(state, "changes"); + const auto *array = changes ? changes->asArray() : nullptr; + if (array) { + projected.changes.reserve(array->size()); + for (const nodegraph::Value &value : *array) { + const auto *change = value.asObject(); + if (!change) + continue; + FileChangeData entry{graphString(graphMember(*change, "path")), + graphString(graphMember(*change, "kind")), + std::nullopt, std::nullopt}; + if (std::string diff = graphString(graphMember(*change, "diff")); + !diff.empty()) { + const auto [additions, deletions] = graphDiffCounts(diff); + entry.additions = additions; + entry.deletions = deletions; + } + projected.changes.push_back(std::move(entry)); + } + } + result.payload = std::move(projected); + break; + } + case CardKind::ImageGeneration: { + std::string path = graphString(graphField(state, "path")); + if (path.empty()) + path = graphString(graphField(state, "savedPath")); + if (path.empty()) + path = graphString(graphField(state, "saved_path")); + std::string prompt = graphString(graphField(state, "revisedPrompt")); + if (prompt.empty()) + prompt = graphString(graphField(state, "revised_prompt")); + result.payload = ImageGenerationData{ + std::move(path), type == "imageView" ? "completed" : graphStatus(state), + std::move(prompt)}; + break; + } + case CardKind::Plan: + if (graphHasStructuredPlan(state)) + result.payload = graphPlanData(state); + else + result.payload = + PlanData{{}, + {}, + withTruncationNotice(graphMessageText(state), + graphOmittedTextBytes(state, "text"), + "plan text", true)}; + if (item && item->id().kind == nodegraph::NodeKind::Turn) { + result.key = TurnPlanKey{result.threadId, result.turnId}; + result.itemId.clear(); + } + break; + case CardKind::GenericActivity: + break; + case CardKind::LocalPrompt: { + const std::string dispatch = + graphString(graphField(state, "dispatchState")); + PromptState promptState = PromptState::Queued; + if (dispatch == "dispatching" || dispatch == "inFlight") + promptState = PromptState::InFlight; + else if (dispatch == "awaitingMaterialization") + promptState = PromptState::InFlight; + else if (dispatch == "failed" || dispatch == "uncertain") + promptState = PromptState::Failed; + const std::int64_t rawId = + graphInteger(graphField(state, "submissionId")).value_or(0); + const std::uint64_t submissionId = + rawId < 0 ? 0 : static_cast(rawId); + result.key = LocalPromptKey{submissionId}; + result.itemId.clear(); + result.payload = LocalPromptData{ + submissionId, + graphString(graphField(state, "text")), + promptState, + graphField(state, "showPendingAnimation") + ? graphBool(graphField(state, "showPendingAnimation")) + : false, + graphString(graphField(state, "error")), + graphLocalPromptImagePaths(state), + graphInteger(graphField(state, "admittedAtMs")), + graphBool(graphField(state, "requiresExplicitRecovery"))}; + break; + } + } + switch (result.kind) { + case CardKind::CommandExecution: + case CardKind::AgentActivity: + case CardKind::Reasoning: + case CardKind::FileChanges: + case CardKind::ImageGeneration: + case CardKind::Plan: + case CardKind::GenericActivity: + result.activeWork = state.status == nodegraph::NodeStatus::Pending || + state.status == nodegraph::NodeStatus::Running; + break; + case CardKind::UserMessage: + case CardKind::AgentMessage: + case CardKind::LocalPrompt: + break; + } + result.target = item; + return result; +} + +bool terminalStatus(std::string_view status) { + const StatusKind kind = classifyStatus(status).kind; + return kind == StatusKind::Completed || kind == StatusKind::Failed || + kind == StatusKind::Interrupted; +} + +void updateAgentStatus(std::string ¤t, std::string candidate) { + if (candidate.empty() || + (terminalStatus(current) && isActiveStatus(candidate))) + return; + current = std::move(candidate); +} + +bool spawnAgentTool(std::string_view tool) { + return tool == "spawn_agent" || tool == "spawnAgent" || + tool == "spawn_agents_on_csv" || tool == "spawnAgentsOnCsv"; +} + +std::string agentActivityStatus(const nodegraph::NodeState &state) { + const std::string kind = graphString(graphField(state, "kind")); + if (kind == "completed" || kind == "interrupted" || kind == "failed") + return kind; + if (kind == "interacted") + return {}; + if (std::string status = graphString(graphField(state, "status")); + !status.empty()) + return status; + const std::string published = graphStatus(state); + if (terminalStatus(published)) + return published; + if (kind == "started" || kind == "progress") + return "inProgress"; + return published; +} + +std::string effectivePlanStepStatus(const std::string &stepStatus, + const std::string &turnStatus, + const std::string &threadStatus) { + if (!isActiveStatus(stepStatus)) + return stepStatus; + StatusKind outcome = classifyStatus(turnStatus).kind; + if (outcome != StatusKind::Completed && outcome != StatusKind::Failed && + outcome != StatusKind::Interrupted) + outcome = classifyStatus(threadStatus).kind; + if (outcome == StatusKind::Completed) + return "completed"; + if (outcome == StatusKind::Failed) + return "failed"; + if (outcome == StatusKind::Interrupted) + return "interrupted"; + return stepStatus; +} + +std::string requestKind(std::string_view method) { + if (method == "item/commandExecution/requestApproval") + return "command-approval"; + if (method == "item/fileChange/requestApproval") + return "file-change-approval"; + if (method == "item/tool/requestUserInput") + return "user-input"; + if (method == "mcpServer/elicitation/request") + return "mcp-elicitation"; + if (method == "item/permissions/requestApproval") + return "permissions-approval"; + if (method == "item/tool/call") + return "dynamic-tool-call"; + if (method == "account/chatgptAuthTokens/refresh") + return "authentication-refresh"; + if (method == "attestation/generate") + return "attestation"; + if (method == "applyPatchApproval") + return "legacy-patch-approval"; + if (method == "execCommandApproval") + return "legacy-command-approval"; + return "unsupported"; +} + +void appendUniqueBounded(std::vector &values, std::string value, + std::size_t maximum) { + if (value.empty() || + std::ranges::find(values, value) != values.end()) + return; + if (values.size() == maximum) + values.erase(values.begin()); + values.emplace_back(std::move(value)); +} + +std::string_view nodeKindName(nodegraph::NodeKind kind) { + using nodegraph::NodeKind; + switch (kind) { + case NodeKind::Runtime: return "Runtime"; + case NodeKind::Connection: return "Connection"; + case NodeKind::Thread: return "Thread"; + case NodeKind::Turn: return "Turn"; + case NodeKind::Item: return "Item"; + case NodeKind::Interaction: return "Interaction"; + case NodeKind::Operation: return "Operation"; + case NodeKind::Catalog: return "Catalog"; + case NodeKind::CatalogEntry: return "CatalogEntry"; + case NodeKind::Account: return "Account"; + case NodeKind::Configuration: return "Configuration"; + case NodeKind::PermissionProfile: return "PermissionProfile"; + case NodeKind::Skill: return "Skill"; + case NodeKind::Hook: return "Hook"; + case NodeKind::Plugin: return "Plugin"; + case NodeKind::App: return "App"; + case NodeKind::McpServer: return "McpServer"; + case NodeKind::Project: return "Project"; + case NodeKind::ThreadSection: return "ThreadSection"; + case NodeKind::Process: return "Process"; + case NodeKind::RealtimeSession: return "RealtimeSession"; + case NodeKind::FilesystemWatch: return "FilesystemWatch"; + case NodeKind::ExternalAgentImport: return "ExternalAgentImport"; + case NodeKind::FuzzyFileSearchSession: return "FuzzyFileSearchSession"; + case NodeKind::LoginAttempt: return "LoginAttempt"; + case NodeKind::Notice: return "Notice"; + case NodeKind::UnknownProtocol: return "UnknownProtocol"; + } + return "Unknown"; +} + +bool sensitiveStateField(std::string_view key) { + std::string normalized; + normalized.reserve(key.size()); + for (const unsigned char character : key) + if (std::isalnum(character)) + normalized.push_back(static_cast(std::tolower(character))); + return normalized == "payload" || normalized == "requestpayload" || + normalized == "responsepayload" || + normalized == "retainedresponsepayload" || normalized == "raw" || + normalized == "private" || normalized == "bytes" || + normalized == "command" || normalized == "prompt" || + normalized == "input" || normalized == "output" || + normalized == "delta" || normalized == "error" || + normalized == "message" || normalized == "token" || + normalized.ends_with("token") || + normalized.find("password") != std::string::npos || + normalized.find("secret") != std::string::npos || + normalized.find("credential") != std::string::npos || + normalized.find("authorization") != std::string::npos || + normalized.find("cookie") != std::string::npos || + normalized.find("apikey") != std::string::npos || + normalized.find("privatekey") != std::string::npos; +} + +nlohmann::json safeStateValue(const nodegraph::Value &value, int depth = 0) { + if (depth > 8) + return ""; + if (value.isNull()) + return nullptr; + if (const auto *boolean = value.asBool()) + return *boolean; + if (const auto *number = value.asInt64()) + return *number; + if (const auto *number = value.asUInt64()) + return *number; + if (const auto *number = value.asDouble()) + return *number; + if (const auto *string = value.asString()) { + if (string->size() > 1024) + return string->substr(0, 1024) + "..."; + return *string; + } + if (const auto *array = value.asArray()) { + nlohmann::json result = nlohmann::json::array(); + const std::size_t retained = std::min(array->size(), 32); + for (std::size_t index = 0; index < retained; ++index) + result.push_back(safeStateValue(array->at(index), depth + 1)); + if (array->size() > retained) + result.push_back(""); + return result; + } + nlohmann::json result = nlohmann::json::object(); + const auto *object = value.asObject(); + std::size_t retained = 0; + for (const auto &[key, entry] : *object) { + if (retained++ == 64) { + result[""] = "omitted"; + break; + } + result[key] = sensitiveStateField(key) + ? nlohmann::json("") + : safeStateValue(entry, depth + 1); + } + return result; +} + +nlohmann::json safeStateObject(const nodegraph::Value::Object &fields) { + return safeStateValue(nodegraph::Value(fields)); +} + +std::string sectionComponent(std::string_view prefix, std::string_view threadId, + std::string_view suffix) { + std::string result(prefix); + result += std::to_string(threadId.size()); + result.push_back(':'); + result.append(threadId); + result += std::to_string(suffix.size()); + result.push_back(':'); + result.append(suffix); + return result; +} + + +} // namespace + +using namespace middle; + + +NodeGraphUiAdapter::NodeGraphUiAdapter( + const nodegraph::NodeGraph &graph) noexcept + : graph_(&graph) {} + +std::optional +NodeGraphUiAdapter::threadRow(const nodegraph::NodeRef &thread) const { + if (!graph_ || !thread || thread->id().kind != nodegraph::NodeKind::Thread) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || read->removed(thread)) + return std::nullopt; + const auto state = read->state(thread); + if (!state) + return std::nullopt; + const auto timestamp = [&state](std::string_view field) { + return graphInteger(graphField(*state, field)); + }; + ThreadListRow row; + row.id = thread->id().canonical; + row.title = graphString(graphField(*state, "name")); + if (row.title.empty()) + row.title = graphString(graphField(*state, "preview")); + if (row.title.empty()) + row.title = row.id.substr(0, std::min(12, row.id.size())); + row.cwd = graphString(graphField(*state, "cwd")); + row.status = graphStatus(*state); + row.createdAt = timestamp("createdAt"); + row.updatedAt = timestamp("updatedAt"); + row.recencyAt = timestamp("recencyAt"); + for (const std::string_view field : { + std::string_view("lastActivityAt"), std::string_view("updatedAt"), + std::string_view("recencyAt"), + std::string_view("localActivityAt"), + std::string_view("localPromptActivityAt")}) { + const std::optional candidate = timestamp(field); + if (candidate && (!row.lastActivityAt || *candidate > *row.lastActivityAt)) + row.lastActivityAt = candidate; + } + row.pending = + graphSize(graphField(*state, "pendingInteractionCount")).value_or(0); + row.archived = graphBool(graphField(*state, "archived")); + return row; +} + +std::optional +NodeGraphUiAdapter::threads(const nodegraph::NodeRef &selectedThread) const { + if (!graph_) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read) + return std::nullopt; + + ThreadListSnapshot result; + if (selectedThread && read->contains(selectedThread) && + !read->removed(selectedThread)) + result.selectedThreadId = selectedThread->id().canonical; + + const nodegraph::NodeRef connection = + read->find({nodegraph::NodeKind::Connection, "connection"}); + if (connection) { + const auto state = read->state(connection); + if (state) { + const std::string provider = + graphString(graphField(*state, "providerState")); + const std::string transport = + graphString(graphField(*state, "transportState")); + const bool connected = + state->status == nodegraph::NodeStatus::Connected || + transport == "connected"; + result.providerReady = connected && provider == "ready"; + result.canControl = + result.providerReady && + graphString(graphField(*state, "role")) == "controller"; + } + } + + std::vector allThreads; + std::unordered_set childThreads; + for (const nodegraph::NodeRef &node : read->orderedNodes()) { + if (!node || node->id().kind != nodegraph::NodeKind::Thread || + read->removed(node)) + continue; + allThreads.push_back(node); + for (const nodegraph::RelationKind kind : + {nodegraph::RelationKind::StructuralChildThread, + nodegraph::RelationKind::AgentChildThread, + nodegraph::RelationKind::ForkChildThread}) + for (const nodegraph::NodeRef &child : read->related(node, kind)) + if (child && read->contains(child) && !read->removed(child) && + child->id().kind == nodegraph::NodeKind::Thread) + childThreads.insert(child.get()); + } + + const auto timestamp = [](const nodegraph::NodeState &state, + std::string_view field) { + return graphInteger(graphField(state, field)); + }; + std::unordered_set emitted; + const auto buildRow = [&](const auto &self, + const nodegraph::NodeRef &node) -> ThreadListRow { + ThreadListRow row; + if (!node || !read->contains(node) || read->removed(node) || + !emitted.insert(node.get()).second) + return row; + const auto state = read->state(node); + if (!state) + return row; + row.id = node->id().canonical; + row.title = graphString(graphField(*state, "name")); + if (row.title.empty()) + row.title = graphString(graphField(*state, "preview")); + if (row.title.empty()) + row.title = row.id.substr(0, std::min(12, row.id.size())); + row.cwd = graphString(graphField(*state, "cwd")); + row.status = graphStatus(*state); + row.createdAt = timestamp(*state, "createdAt"); + row.updatedAt = timestamp(*state, "updatedAt"); + row.recencyAt = timestamp(*state, "recencyAt"); + for (const std::string_view field : { + std::string_view("lastActivityAt"), + std::string_view("updatedAt"), std::string_view("recencyAt"), + std::string_view("localActivityAt"), + std::string_view("localPromptActivityAt")}) { + const std::optional candidate = timestamp(*state, field); + if (candidate && + (!row.lastActivityAt || *candidate > *row.lastActivityAt)) + row.lastActivityAt = candidate; + } + row.pending = graphSize(graphField(*state, "pendingInteractionCount")) + .value_or(0); + row.archived = graphBool(graphField(*state, "archived")); + std::unordered_set localChildren; + for (const nodegraph::RelationKind kind : + {nodegraph::RelationKind::StructuralChildThread, + nodegraph::RelationKind::AgentChildThread, + nodegraph::RelationKind::ForkChildThread}) { + for (const nodegraph::NodeRef &child : read->related(node, kind)) { + if (!child || child->id().kind != nodegraph::NodeKind::Thread || + !localChildren.insert(child.get()).second) + continue; + ThreadListRow projected = self(self, child); + if (!projected.id.empty()) + row.children.push_back(std::move(projected)); + } + } + return row; + }; + + std::vector roots; + const nodegraph::NodeRef runtime = + read->find({nodegraph::NodeKind::Runtime, "runtime"}); + if (runtime) + roots = read->related(runtime, nodegraph::RelationKind::RootThread); + for (const nodegraph::NodeRef &thread : allThreads) + if (!childThreads.contains(thread.get()) && + std::ranges::find(roots, thread) == roots.end()) + roots.push_back(thread); + for (const nodegraph::NodeRef &root : roots) { + ThreadListRow row = buildRow(buildRow, root); + if (!row.id.empty()) + result.roots.push_back(std::move(row)); + } + // Malformed or partially paged ownership must not make a canonical thread + // disappear. Keep any still-unreachable thread as a root until its owner is + // available. + for (const nodegraph::NodeRef &thread : allThreads) { + if (emitted.contains(thread.get())) + continue; + ThreadListRow row = buildRow(buildRow, thread); + if (!row.id.empty()) + result.roots.push_back(std::move(row)); + } + return result; +} + +std::optional +NodeGraphUiAdapter::conversationInfo( + const nodegraph::NodeRef &thread) const { + if (!graph_ || !thread) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || read->removed(thread) || + thread->id().kind != nodegraph::NodeKind::Thread) + return std::nullopt; + const auto state = read->state(thread); + if (!state) + return std::nullopt; + + ConversationInfo result; + if (const auto count = + graphSize(graphField(*state, "historyLoadedItemCount"))) { + result.authoritativeItemCount = *count; + } else { + const std::size_t turnCount = read->childCount(thread); + for (std::size_t turnIndex = 0; turnIndex < turnCount; ++turnIndex) { + const nodegraph::NodeRef turn = read->childAt(thread, turnIndex); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn) + continue; + const std::size_t itemCount = read->childCount(turn); + for (std::size_t itemIndex = 0; itemIndex < itemCount; ++itemIndex) { + const nodegraph::NodeRef item = read->childAt(turn, itemIndex); + if (!item || item->id().kind != nodegraph::NodeKind::Item) + continue; + const auto itemState = read->state(item); + if (itemState && + graphString(graphField(*itemState, "type")) != "localPrompt") + ++result.authoritativeItemCount; + } + } + } + + const std::string hydration = + graphString(graphField(*state, "hydrationState")); + const bool local = graphBool(graphField(*state, "local")); + const bool recoveryOnly = graphBool(graphField(*state, "recoveryOnly")); + result.readyForDisplay = hydration == "ready" || local || recoveryOnly; + result.hydrationFailed = hydration == "failed"; + result.providerHasMore = graphProviderHasMoreHistory(*state); + return result; +} + +std::optional +NodeGraphUiAdapter::inspector( + const nodegraph::NodeRef &selectedThread, + InspectorProjection projection) const { + if (!graph_) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read) + return std::nullopt; + + nodegraph::NodeRef thread; + if (selectedThread && + selectedThread->id().kind == nodegraph::NodeKind::Thread && + read->contains(selectedThread) && !read->removed(selectedThread)) + thread = selectedThread; + + InspectorSnapshot result; + const bool wantPlan = projection == InspectorProjection::All || + projection == InspectorProjection::Plan; + const bool wantAgents = projection == InspectorProjection::All || + projection == InspectorProjection::Agents; + const bool wantChanges = projection == InspectorProjection::All || + projection == InspectorProjection::Changes; + const bool wantRequests = projection == InspectorProjection::All || + projection == InspectorProjection::Requests; + const bool wantState = projection == InspectorProjection::All || + projection == InspectorProjection::State; + result.plan.threadId = thread ? thread->id().canonical : std::string{}; + result.plan.threadPresent = static_cast(thread); + result.agents.threadId = result.plan.threadId; + result.agents.threadPresent = result.plan.threadPresent; + result.changes.threadId = result.plan.threadId; + + const nodegraph::NodeRef connection = + wantRequests || wantState + ? read->find({nodegraph::NodeKind::Connection, "connection"}) + : nodegraph::NodeRef{}; + bool canControl = false; + std::uint64_t generation = 0; + if (connection) { + const auto state = read->state(connection); + const std::string transport = + graphString(graphField(*state, "transportState")); + const std::string provider = + graphString(graphField(*state, "providerState")); + canControl = + (transport == "connected" || + state->status == nodegraph::NodeStatus::Connected) && + provider == "ready" && + graphString(graphField(*state, "role")) == "controller"; + generation = graphInteger(graphField(*state, "connectionGeneration")) + .value_or(graphInteger( + graphField(*state, "providerGeneration")) + .value_or(0)); + } + + std::map> kindCounts; + nlohmann::json domains = nlohmann::json::array(); + std::size_t omittedDomains = 0; + if (wantState) + for (const nodegraph::NodeRef &node : read->orderedNodes()) { + if (!node || read->removed(node)) + continue; + ++kindCounts[std::string(nodeKindName(node->id().kind))]; + if (node->id().kind == nodegraph::NodeKind::Thread) + ++result.state.threadCount; + else if (node->id().kind == nodegraph::NodeKind::UnknownProtocol) + ++result.state.telemetryCount; + else if (node->id().kind == nodegraph::NodeKind::Catalog && + node->id().canonical == "model") { + const auto state = read->state(node); + const nodegraph::Value *data = graphField(*state, "data"); + if (const auto *models = data ? data->asArray() : nullptr) + result.state.modelCount = models->size(); + } + + const bool domain = node->id().kind != nodegraph::NodeKind::Thread && + node->id().kind != nodegraph::NodeKind::Turn && + node->id().kind != nodegraph::NodeKind::Item && + node->id().kind != nodegraph::NodeKind::Interaction && + node->id().kind != nodegraph::NodeKind::Operation && + node->id().kind != + nodegraph::NodeKind::UnknownProtocol; + if (!domain) + continue; + if (domains.size() >= 96) { + ++omittedDomains; + continue; + } + const auto state = read->state(node); + domains.push_back({{"kind", nodeKindName(node->id().kind)}, + {"id", node->id().canonical}, + {"status", graphStatus(*state)}, + {"changedRevision", read->changedRevision(node)}, + {"fields", safeStateObject(state->fields)}}); + } + + const nodegraph::NodeRef runtime = + read->find({nodegraph::NodeKind::Runtime, "runtime"}); + nlohmann::json pendingMetadata = nlohmann::json::array(); + if (runtime && (wantRequests || wantState)) { + for (const nodegraph::NodeRef &interaction : + read->related(runtime, + nodegraph::RelationKind::PendingInteraction)) { + if (!interaction || read->removed(interaction) || + interaction->id().kind != nodegraph::NodeKind::Interaction) + continue; + const auto state = read->state(interaction); + if (state->status != nodegraph::NodeStatus::Pending && + state->status != nodegraph::NodeStatus::Failed) + continue; + + InspectorRequestRow row; + row.id = interaction->id().canonical; + const std::string method = graphString(graphField(*state, "method")); + row.kind = requestKind(method); + row.generation = generation; + const bool recoveryOnly = graphBool(graphField(*state, "recoveryOnly")); + row.actionable = canControl && !recoveryOnly; + const nodegraph::Value *payloadValue = graphField(*state, "payload"); + const auto *payload = payloadValue ? payloadValue->asObject() : nullptr; + if (payload) { + row.command = graphString(graphMember(*payload, "command")); + row.reason = graphString(graphMember(*payload, "reason")); + row.message = graphString(graphMember(*payload, "message")); + row.threadContext = graphString(graphMember(*payload, "threadId")); + if (const nodegraph::Value *questions = + graphMember(*payload, "questions"); + questions && questions->asArray()) + row.questionCount = questions->asArray()->size(); + } + if (row.message.empty()) + row.message = graphString(graphField(*state, "error")); + if (row.threadContext.empty()) { + for (nodegraph::NodeRef target : read->related( + interaction, nodegraph::RelationKind::InteractionTarget)) { + for (std::size_t depth = 0; target && depth < 16; + ++depth, target = read->parent(target)) { + if (target->id().kind != nodegraph::NodeKind::Thread) + continue; + row.threadContext = target->id().canonical; + const auto targetState = read->state(target); + if (std::string title = + graphString(graphField(*targetState, "name")); + !title.empty()) + row.threadContext = std::move(title); + break; + } + if (!row.threadContext.empty()) + break; + } + } + if (wantRequests) + result.requests.requests.push_back(row); + ++result.state.pendingRequestCount; + if (pendingMetadata.size() < 64) + pendingMetadata.push_back( + {{"id", interaction->id().canonical}, + {"method", method}, + {"category", row.kind}, + {"thread", row.threadContext}, + {"status", graphStatus(*state)}}); + } + } + + if (thread) { + const auto threadState = read->state(thread); + if (wantChanges) + result.changes.cwd = graphString(graphField(*threadState, "cwd")); + if (wantState) + result.state.selectedThreadTurnCount = read->childCount(thread); + if (wantChanges || wantState) + for (std::size_t turnIndex = 0; turnIndex < read->childCount(thread); + ++turnIndex) { + const nodegraph::NodeRef turn = read->childAt(thread, turnIndex); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn) + continue; + if (wantState) + result.state.selectedThreadItemCount += read->childCount(turn); + for (std::size_t itemIndex = 0; itemIndex < read->childCount(turn); + ++itemIndex) { + const nodegraph::NodeRef item = read->childAt(turn, itemIndex); + if (!item || item->id().kind != nodegraph::NodeKind::Item) + continue; + const auto state = read->state(item); + const std::string type = graphString(graphField(*state, "type")); + if (wantChanges && type == "commandExecution") { + appendUniqueBounded(result.changes.commandCwds, + graphString(graphField(*state, "cwd")), 64); + } else if (wantChanges && type == "fileChange") { + const nodegraph::Value *changes = graphField(*state, "changes"); + if (const auto *array = changes ? changes->asArray() : nullptr) + for (const nodegraph::Value &change : *array) + if (const auto *object = change.asObject()) + appendUniqueBounded( + result.changes.changedPaths, + graphString(graphMember(*object, "path")), 512); + } + } + } + + const std::string threadStatus = graphStatus(*threadState); + if (wantPlan) + for (std::size_t offset = 0; + offset < read->childCount(thread) && !result.plan.plan && + !result.plan.planItem; + ++offset) { + const nodegraph::NodeRef turn = read->childAt( + thread, read->childCount(thread) - offset - 1); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn) + continue; + const auto turnState = read->state(turn); + const nodegraph::Value *planValue = graphField(*turnState, "plan"); + const nodegraph::Value::Array *steps = + planValue ? planValue->asArray() : nullptr; + const nodegraph::Value *explanation = + graphField(*turnState, "planExplanation"); + bool structured = steps != nullptr; + if (const auto *object = planValue ? planValue->asObject() : nullptr) { + const nodegraph::Value *nested = graphMember(*object, "steps"); + structured = nested != nullptr; + steps = nested ? nested->asArray() : nullptr; + if (graphString(explanation).empty()) + explanation = graphMember(*object, "explanation"); + } + if (structured) { + InspectorPlan plan; + plan.explanation = graphString(explanation); + if (steps) { + plan.steps.reserve(steps->size()); + for (const nodegraph::Value &entry : *steps) { + const auto *object = entry.asObject(); + if (!object) { + plan.steps.emplace_back(); + continue; + } + const std::string status = + graphString(graphMember(*object, "status")); + plan.steps.push_back( + {graphString(graphMember(*object, "step")), + effectivePlanStepStatus(status, graphStatus(*turnState), + threadStatus)}); + } + } + result.plan.plan = std::move(plan); + break; + } + for (std::size_t itemOffset = 0; + itemOffset < read->childCount(turn); ++itemOffset) { + const nodegraph::NodeRef item = read->childAt( + turn, read->childCount(turn) - itemOffset - 1); + if (!item || item->id().kind != nodegraph::NodeKind::Item) + continue; + const auto state = read->state(item); + if (graphString(graphField(*state, "type")) == "plan") { + result.plan.planItem = graphString(graphField(*state, "text")); + break; + } + } + } + + if (wantAgents) { + struct LogicalAgent final { + InspectorAgentRow row; + std::string status; + }; + std::vector logicalAgents; + std::unordered_map logicalIndexes; + for (std::size_t turnIndex = 0; turnIndex < read->childCount(thread); + ++turnIndex) { + const nodegraph::NodeRef turn = read->childAt(thread, turnIndex); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn) + continue; + const auto turnState = read->state(turn); + for (std::size_t itemIndex = 0; itemIndex < read->childCount(turn); + ++itemIndex) { + const nodegraph::NodeRef item = read->childAt(turn, itemIndex); + if (!item || item->id().kind != nodegraph::NodeKind::Item) + continue; + const auto state = read->state(item); + const std::string type = graphString(graphField(*state, "type")); + if (type != "subAgentActivity" && type != "collabAgentToolCall") + continue; + const std::string activityKind = + graphString(graphField(*state, "kind")); + const bool canCreate = + type == "subAgentActivity" + ? (activityKind.empty() || activityKind == "started") + : spawnAgentTool(graphString(graphField(*state, "tool"))); + + struct SourceChild final { + std::string key; + std::string id; + nodegraph::NodeRef thread; + std::string status; + std::string result; + bool canonical = true; + }; + std::vector children; + std::unordered_map childIndexes; + const auto addChild = [&](std::string id, + nodegraph::NodeRef childThread = {}, + std::string status = {}, + std::string childResult = {}) { + if (id.empty()) + return; + const std::string key = "child\n" + id; + const auto [found, inserted] = + childIndexes.try_emplace(key, children.size()); + if (inserted) { + children.push_back({key, std::move(id), std::move(childThread), + std::move(status), std::move(childResult), + true}); + return; + } + SourceChild &child = children.at(found->second); + if (childThread) + child.thread = std::move(childThread); + updateAgentStatus(child.status, std::move(status)); + if (!childResult.empty()) + child.result = std::move(childResult); + }; + + for (const nodegraph::NodeRef &child : read->related( + item, nodegraph::RelationKind::AgentChildThread)) + if (child && child->id().kind == nodegraph::NodeKind::Thread) + addChild(child->id().canonical, child); + addChild(graphString(graphField(*state, "agentThreadId"))); + const std::vector receivers = + graphStrings(graphField(*state, "receiverThreadIds")); + for (const std::string &receiver : receivers) + addChild(receiver); + if (const nodegraph::Value *statesValue = + graphField(*state, "agentsStates")) { + if (const auto *states = statesValue->asObject()) { + for (const auto &[id, value] : *states) { + const auto *childState = value.asObject(); + addChild(id, {}, + childState + ? graphString(graphMember(*childState, "status")) + : std::string{}, + childState + ? graphString(graphMember(*childState, "message")) + : std::string{}); + } + } + } + if (children.empty() && canCreate) { + std::string id = nodegraph::protocolCanonicalId(*state, item); + if (id.empty()) + id = item->id().canonical; + children.push_back({"source\n" + item->id().canonical, + std::move(id), {}, {}, {}, false}); + } + + for (SourceChild &child : children) { + auto found = logicalIndexes.find(child.key); + if (found == logicalIndexes.end()) { + if (!canCreate) + continue; + LogicalAgent logical; + logical.row.id = child.id; + if (child.canonical) + logical.row.childThreadId = child.id; + found = logicalIndexes + .emplace(child.key, logicalAgents.size()) + .first; + logicalAgents.push_back(std::move(logical)); + } + LogicalAgent &logical = logicalAgents.at(found->second); + const bool carriesFields = type == "subAgentActivity" || canCreate; + if (carriesFields) { + const auto update = [](std::string &target, std::string value) { + if (!value.empty()) + target = std::move(value); + }; + update(logical.row.agentPath, + graphString(graphField(*state, "agentPath"))); + update(logical.row.tool, + graphString(graphField(*state, "tool"))); + update(logical.row.model, + graphString(graphField(*state, "model"))); + update(logical.row.reasoningEffort, + graphString(graphField(*state, "reasoningEffort"))); + update(logical.row.prompt, + graphString(graphField(*state, "prompt"))); + update(logical.row.resultText, + graphString(graphField(*state, "resultText"))); + update(logical.row.senderThreadId, + graphString(graphField(*state, "senderThreadId"))); + if (!receivers.empty()) + logical.row.receiverThreadIds = receivers; + std::string activityStatus = agentActivityStatus(*state); + if (child.canonical && terminalStatus(graphStatus(*turnState)) && + isActiveStatus(activityStatus) && child.status.empty()) + activityStatus = "notLoaded"; + updateAgentStatus(logical.status, std::move(activityStatus)); + } + updateAgentStatus(logical.status, child.status); + + nodegraph::NodeRef childThread = child.thread; + if (!childThread && child.canonical) + childThread = read->find( + {nodegraph::NodeKind::Thread, child.id}); + if (childThread && read->contains(childThread) && + !read->removed(childThread)) { + const auto childState = read->state(childThread); + updateAgentStatus(logical.status, graphStatus(*childState)); + for (std::size_t childTurnOffset = 0; + childTurnOffset < read->childCount(childThread) && + logical.row.resultText.empty(); + ++childTurnOffset) { + const nodegraph::NodeRef childTurn = read->childAt( + childThread, + read->childCount(childThread) - childTurnOffset - 1); + if (!childTurn) + continue; + for (std::size_t childItemOffset = 0; + childItemOffset < read->childCount(childTurn); + ++childItemOffset) { + const nodegraph::NodeRef childItem = read->childAt( + childTurn, + read->childCount(childTurn) - childItemOffset - 1); + if (!childItem) + continue; + const auto childItemState = read->state(childItem); + if (graphString(graphField(*childItemState, "type")) != + "agentMessage") + continue; + const std::string value = + graphString(graphField(*childItemState, "text")); + if (!value.empty()) { + logical.row.resultText = value; + break; + } + } + } + } + if (!child.result.empty()) + logical.row.resultText = child.result; + logical.row.status = logical.status; + } + } + } + result.agents.agents.reserve(logicalAgents.size()); + for (LogicalAgent &logical : logicalAgents) + result.agents.agents.push_back(std::move(logical.row)); + } + } + + nlohmann::json selected = nullptr; + if (wantState && thread) { + const auto state = read->state(thread); + selected = {{"id", thread->id().canonical}, + {"status", graphStatus(*state)}, + {"changedRevision", read->changedRevision(thread)}, + {"turns", result.state.selectedThreadTurnCount}, + {"items", result.state.selectedThreadItemCount}, + {"fields", safeStateObject(state->fields)}}; + if (const nodegraph::NodeRef parent = read->parent(thread)) + selected["parent"] = parent->id().canonical; + } + if (wantState) { + nlohmann::json counts = nlohmann::json::object(); + for (const auto &[kind, count] : kindCounts) + counts[kind] = count; + result.state.state = + {{"sharedNodeGraph", + {{"revision", read->revision()}, + {"nodes", read->orderedNodes().size()}, + {"nodeKinds", std::move(counts)}, + {"selectedThread", std::move(selected)}, + {"currentDomains", std::move(domains)}, + {"omittedDomainCount", omittedDomains}, + {"pendingInteractions", std::move(pendingMetadata)}}}}; + } + return result; +} + +std::optional +NodeGraphUiAdapter::card(const nodegraph::NodeRef &thread, + const nodegraph::NodeRef &item, + ConversationOptions options) const { + static_cast(options); + if (!graph_ || !thread || !item) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || !read->contains(item) || + read->removed(thread) || read->removed(item) || + thread->id().kind != nodegraph::NodeKind::Thread) + return std::nullopt; + nodegraph::NodeRef turn = read->parent(item); + if (!turn || turn->id().kind != nodegraph::NodeKind::Turn || + read->parent(turn) != thread) + return std::nullopt; + const auto state = read->state(item); + const auto turnState = read->state(turn); + if (!state || !turnState) + return std::nullopt; + return graphCardData(item, thread->id().canonical, + nodegraph::protocolCanonicalId(*turnState, turn), *state); +} + +std::optional +NodeGraphUiAdapter::conversation(const nodegraph::NodeRef &thread, + std::size_t itemLimit, + ConversationOptions options) const { + static_cast(options); + if (!graph_ || !thread) + return std::nullopt; + auto read = graph_->tryRead(); + if (!read || !read->contains(thread) || read->removed(thread) || + thread->id().kind != nodegraph::NodeKind::Thread) + return std::nullopt; + + struct TurnInput { + nodegraph::NodeRef turn; + std::shared_ptr state; + std::string id; + nodegraph::NodeRef root; + std::vector items; + }; + + const auto threadState = read->state(thread); + if (!threadState) + return std::nullopt; + itemLimit = std::max(1, itemLimit); + const std::optional retainedAuthoritativeCount = + graphSize(graphField(*threadState, "historyLoadedItemCount")); + + std::vector turns; + std::unordered_map turnPositions; + std::size_t itemCount = retainedAuthoritativeCount.value_or(0); + const std::size_t turnCount = read->childCount(thread); + turns.reserve(turnCount); + for (std::size_t turnIndex = 0; turnIndex < turnCount; ++turnIndex) { + nodegraph::NodeRef turn = read->childAt(thread, turnIndex); + if (!turn || !read->contains(turn) || read->removed(turn) || + turn->id().kind != nodegraph::NodeKind::Turn) + continue; + const auto state = read->state(turn); + if (!state) + continue; + TurnInput input; + input.turn = turn; + input.state = state; + input.id = nodegraph::protocolCanonicalId(*state, turn); + const auto roots = + read->related(turn, nodegraph::RelationKind::TurnRootItem); + if (!roots.empty() && roots.front() && read->contains(roots.front()) && + !read->removed(roots.front())) + input.root = roots.front(); + + turnPositions.emplace(turn.get(), turns.size()); + turns.push_back(std::move(input)); + } + + std::unordered_set boundedAuthoritativeItems; + if (retainedAuthoritativeCount) { + std::size_t remaining = itemLimit; + for (std::size_t turnOffset = turns.size(); turnOffset > 0 && remaining > 0; + --turnOffset) { + TurnInput &input = turns[turnOffset - 1]; + const std::size_t childCount = read->childCount(input.turn); + for (std::size_t itemOffset = childCount; + itemOffset > 0 && remaining > 0; --itemOffset) { + nodegraph::NodeRef item = read->childAt(input.turn, itemOffset - 1); + if (!item || !read->contains(item) || read->removed(item) || + item->id().kind != nodegraph::NodeKind::Item) + continue; + const auto state = read->state(item); + if (!state || graphString(graphField(*state, "type")) == "localPrompt") + continue; + input.items.push_back(item); + boundedAuthoritativeItems.insert(item.get()); + --remaining; + } + std::ranges::reverse(input.items); + } + + // User-authored optimistic/recovery prompts are explicitly protected from + // history paging. The worker maintains this narrow relation, so retaining + // them does not require scanning all historical items. + for (const nodegraph::NodeRef &prompt : + read->related(thread, nodegraph::RelationKind::PendingPrompt)) { + if (!prompt || !read->contains(prompt) || read->removed(prompt) || + prompt->id().kind != nodegraph::NodeKind::Item) + continue; + const nodegraph::NodeRef turn = read->parent(prompt); + const auto position = turnPositions.find(turn.get()); + if (position == turnPositions.end()) + continue; + std::vector &items = turns[position->second].items; + if (std::ranges::find(items, prompt) == items.end()) + items.push_back(prompt); + } + } else { + for (TurnInput &input : turns) { + const std::size_t childCount = read->childCount(input.turn); + input.items.reserve(childCount); + for (std::size_t itemIndex = 0; itemIndex < childCount; ++itemIndex) { + nodegraph::NodeRef item = read->childAt(input.turn, itemIndex); + if (!item || !read->contains(item) || read->removed(item) || + item->id().kind != nodegraph::NodeKind::Item) + continue; + input.items.push_back(item); + const auto itemState = read->state(item); + if (itemState && graphString(graphField(*itemState, "type")) != + "localPrompt") + ++itemCount; + } + } + } + + const std::size_t skip = itemCount > itemLimit ? itemCount - itemLimit : 0; + std::size_t visited = 0; + + ConversationSnapshot result; + result.threadId = thread->id().canonical; + std::size_t pinnedRoots = 0; + if (retainedAuthoritativeCount) { + for (TurnInput &input : turns) { + if (input.items.empty() || !input.root || + boundedAuthoritativeItems.contains(input.root.get())) + continue; + if (std::ranges::find(input.items, input.root) == input.items.end()) + input.items.insert(input.items.begin(), input.root); + const auto rootState = read->state(input.root); + if (skip != 0 && read->parent(input.root) == input.turn && rootState && + graphString(graphField(*rootState, "type")) != "localPrompt") + ++pinnedRoots; + } + } else { + std::unordered_set representedTurns; + std::size_t authoritativeIndex = 0; + for (const TurnInput &input : turns) { + for (const nodegraph::NodeRef &item : input.items) { + const auto state = read->state(item); + if (!state || graphString(graphField(*state, "type")) == "localPrompt") + continue; + if (authoritativeIndex++ >= skip) + representedTurns.insert(input.turn.get()); + } + } + authoritativeIndex = 0; + for (const TurnInput &input : turns) { + for (const nodegraph::NodeRef &item : input.items) { + const auto state = read->state(item); + if (!state || graphString(graphField(*state, "type")) == "localPrompt") + continue; + if (authoritativeIndex < skip && item == input.root && + representedTurns.contains(input.turn.get())) + ++pinnedRoots; + ++authoritativeIndex; + } + } + } + result.hiddenAuthoritativeItemCount = + pinnedRoots < skip ? skip - pinnedRoots : 0; + result.hasMore = skip != 0 || graphProviderHasMoreHistory(*threadState); + + const auto activeTurns = + read->related(thread, nodegraph::RelationKind::ActiveTurn); + if (!activeTurns.empty() && activeTurns.front() && + read->contains(activeTurns.front()) && !read->removed(activeTurns.front())) { + const auto state = read->state(activeTurns.front()); + if (state) + result.activeTurnId = + nodegraph::protocolCanonicalId(*state, activeTurns.front()); + } + + for (TurnInput &input : turns) { + TurnSection section; + section.key = + sectionComponent("turn:", result.threadId, input.id); + section.turnId = input.id; + bool rootAdded = false; + std::unordered_set readyPrompts; + for (const nodegraph::NodeRef &candidate : input.items) { + for (const nodegraph::NodeRef &prompt : read->related( + candidate, nodegraph::RelationKind::PromptMaterialization)) { + if (!prompt || !read->contains(prompt) || read->removed(prompt)) + continue; + const auto promptState = read->state(prompt); + if (promptState && + graphString(graphField(*promptState, "type")) == "localPrompt" && + graphString(graphField(*promptState, "dispatchState")) == + "awaitingMaterialization") + readyPrompts.insert(prompt.get()); + } + } + + const auto append = [&](const nodegraph::NodeRef &item, bool root) { + if (!item || !read->contains(item) || read->removed(item)) + return; + auto state = read->state(item); + if (!state) + return; + if (graphString(graphField(*state, "type")) == "localPrompt" && + readyPrompts.contains(item.get())) + return; + + nodegraph::NodeRef projectedNode = item; + nodegraph::NodeRef actionTarget = item; + std::shared_ptr projectedState = state; + std::optional promptVisualId; + const auto promptRelations = + read->related(item, nodegraph::RelationKind::PromptMaterialization); + for (const nodegraph::NodeRef &prompt : promptRelations) { + if (!prompt || !read->contains(prompt) || read->removed(prompt) || + prompt->id().kind != nodegraph::NodeKind::Item) + continue; + const auto promptState = read->state(prompt); + if (!promptState || + graphString(graphField(*promptState, "type")) != "localPrompt") + continue; + const std::int64_t rawId = + graphInteger(graphField(*promptState, "submissionId")).value_or(0); + promptVisualId = + rawId < 0 ? 0 : static_cast(rawId); + actionTarget = prompt; + if (graphString(graphField(*promptState, "dispatchState")) != + "awaitingMaterialization") + return; + break; + } + + VisibleCardData card = graphCardData(projectedNode, result.threadId, + input.id, *projectedState); + if (promptVisualId) + card.key = LocalPromptKey{*promptVisualId}; + card.target = std::move(actionTarget); + if (root) { + section.rootCardKey = card.key; + rootAdded = true; + } + section.cards.push_back(std::move(card)); + }; + + if (input.root && read->parent(input.root) != input.turn) + append(input.root, true); + + for (const nodegraph::NodeRef &item : input.items) { + const auto itemState = read->state(item); + const bool localPrompt = + itemState && + graphString(graphField(*itemState, "type")) == "localPrompt"; + const bool selected = retainedAuthoritativeCount || localPrompt || + visited++ >= skip; + const bool root = item == input.root; + if (!selected && !root) + continue; + append(item, root); + } + + if (!section.cards.empty()) { + if (!rootAdded && input.root) { + const auto rootState = read->state(input.root); + if (rootState) { + const std::string rootId = + nodegraph::protocolCanonicalId(*rootState, input.root); + section.rootCardKey = AuthoritativeItemKey{ + result.threadId, input.id, rootId}; + } + } + result.sections.push_back(std::move(section)); + } + + if (!result.activeTurnId && graphTurnIsActive(*input.state)) + result.activeTurnId = input.id; + } + + return result; +} + + +} // namespace codexui::codex::ui diff --git a/src/codex/ui/NodeGraphUiAdapter.h b/src/codex/ui/NodeGraphUiAdapter.h new file mode 100644 index 0000000..bf46975 --- /dev/null +++ b/src/codex/ui/NodeGraphUiAdapter.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_UI_NODEGRAPHUIADAPTER_H +#define CODEXUI_CODEX_UI_NODEGRAPHUIADAPTER_H + +#include "codex/middle/MiddleTypes.h" +#include "codex/nodegraph/NodeGraph.h" +#include "codex/ui/UiViewState.h" + +#include +#include + +namespace codexui::codex::ui { + +// Narrow compatibility seam between the canonical shared graph and the +// established Qt UI contract. It owns no nodes and retains no projected +// state. A successful call holds one short graph read, returns plain render +// values, and releases the graph before any QWidget code runs. +class NodeGraphUiAdapter final { +public: + struct ConversationOptions { + bool showReasoning = true; + bool showCodexUpdates = true; + }; + + struct ConversationInfo { + std::size_t authoritativeItemCount = 0; + bool readyForDisplay = false; + bool hydrationFailed = false; + bool providerHasMore = false; + }; + + explicit NodeGraphUiAdapter(const nodegraph::NodeGraph &graph) noexcept; + + [[nodiscard]] std::optional + conversation(const nodegraph::NodeRef &thread, std::size_t itemLimit, + ConversationOptions options) const; + + [[nodiscard]] std::optional + conversationInfo(const nodegraph::NodeRef &thread) const; + + [[nodiscard]] std::optional + card(const nodegraph::NodeRef &thread, const nodegraph::NodeRef &item, + ConversationOptions options) const; + + [[nodiscard]] std::optional + threads(const nodegraph::NodeRef &selectedThread) const; + + [[nodiscard]] std::optional + threadRow(const nodegraph::NodeRef &thread) const; + + [[nodiscard]] std::optional + inspector(const nodegraph::NodeRef &selectedThread, + InspectorProjection projection = InspectorProjection::All) const; + +private: + const nodegraph::NodeGraph *graph_; +}; + +} // namespace codexui::codex::ui + +#endif // CODEXUI_CODEX_UI_NODEGRAPHUIADAPTER_H diff --git a/src/codex/ui/QtNodeAttachment.h b/src/codex/ui/QtNodeAttachment.h new file mode 100644 index 0000000..485e139 --- /dev/null +++ b/src/codex/ui/QtNodeAttachment.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#ifndef CODEXUI_CODEX_UI_QTNODEATTACHMENT_H +#define CODEXUI_CODEX_UI_QTNODEATTACHMENT_H + +#include + +#include + +class QWidget; + +namespace codexui::codex::ui { + +// Qt-main owns this opaque record and stores its address in Node's single UI +// attachment slot. The shared node graph deliberately knows nothing about +// QWidget or about this type. +enum class NodeMaterialization : std::uint8_t { + Placeholder, + Overscan, + ViewportVisible, +}; + +struct QtNodeAttachment final { + QPointer widget; + // Optional toolkit-owned binding (for example the lightweight list item + // that owns this attachment). It is never inspected by the worker. + void *binding = nullptr; + std::uint64_t renderedRevision = 0; + NodeMaterialization materialization = NodeMaterialization::Placeholder; + bool viewportVisible = false; +}; + +} // namespace codexui::codex::ui + +#endif // CODEXUI_CODEX_UI_QTNODEATTACHMENT_H diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index 37200bb..b5d9780 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -248,7 +248,8 @@ QString applicationStyleSheet() { QFrame[messageRole="user"][nestedConversationCard="true"] QLabel[kind="title"] { color: #146f73; } QFrame[messageRole="agent"][messagePhase="final"] { background: #f4f0ff; border: 1px solid #d4c5f2; border-radius: 8px; } QFrame[messageRole="agent"][messagePhase="final"] QLabel[kind="title"] { color: #53389e; } - QFrame[messageRole="agent"][messagePhase="update"] { background: #ffffff; border: 1px solid #d7dee8; border-radius: 8px; } + QFrame[messageRole="agent"][messagePhase="update"] { background: #fff9db; border: 1px solid #e4ca62; border-radius: 8px; } + QFrame[messageRole="agent"][messagePhase="update"] QLabel[kind="title"] { color: #765c00; } QFrame[kind="summary"] { background: #f8fafc; border: 1px solid #d7dee8; border-radius: 7px; } QFrame[kind="standardDivider"] { background: #d7dee8; border: none; } QFrame[kind="greenBadge"] { background: #e9f7f0; border: 1px solid #a9d8c1; border-radius: 6px; } diff --git a/src/codex/ui/UiStyle.h b/src/codex/ui/UiStyle.h index 59eebbd..80fce47 100644 --- a/src/codex/ui/UiStyle.h +++ b/src/codex/ui/UiStyle.h @@ -34,6 +34,14 @@ inline constexpr auto greenPressed = "#105f41"; inline constexpr auto greenSurface = "#e9f7f0"; inline constexpr auto greenBorder = "#a9d8c1"; inline constexpr auto greenText = "#176b45"; +inline constexpr auto yellow = "#b58900"; +inline constexpr auto yellowHover = "#9b7500"; +inline constexpr auto yellowPressed = "#805f00"; +inline constexpr auto yellowSurface = "#fff9db"; +inline constexpr auto yellowSurfaceHover = "#fff3b0"; +inline constexpr auto yellowBorder = "#e4ca62"; +inline constexpr auto yellowBorderStrong = "#cfb33f"; +inline constexpr auto yellowText = "#765c00"; inline constexpr auto orange = "#a85d0c"; inline constexpr auto orangeHover = "#8e4d09"; inline constexpr auto orangePressed = "#743e07"; diff --git a/src/codex/ui/UiViewProjection.cpp b/src/codex/ui/UiViewProjection.cpp deleted file mode 100644 index 4cd0c46..0000000 --- a/src/codex/ui/UiViewProjection.cpp +++ /dev/null @@ -1,258 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ui/UiViewProjection.h" - -#include "codex/PresentationModel.h" -#include "codex/PresentationStatus.h" - -#include -#include -#include - -namespace codexui::codex::ui { -namespace { - -std::string stringValue(const nlohmann::json &object, const char *key) { - if (!object.is_object()) - return {}; - const auto found = object.find(key); - return found != object.end() && found->is_string() ? found->get() - : std::string{}; -} - -std::string effectivePlanStepStatus(const std::string &stepStatus, - const std::string &turnStatus, - const std::string &threadStatus) { - if (!isActiveStatus(stepStatus)) - return stepStatus; - StatusKind outcome = classifyStatus(turnStatus).kind; - if (outcome != StatusKind::Completed && outcome != StatusKind::Failed && - outcome != StatusKind::Interrupted) - outcome = classifyStatus(threadStatus).kind; - if (outcome == StatusKind::Completed) - return "completed"; - if (outcome == StatusKind::Failed) - return "failed"; - if (outcome == StatusKind::Interrupted) - return "interrupted"; - return stepStatus; -} - -std::optional projectThread( - const PresentationModel &model, const std::string &threadId, - const std::unordered_map &pendingByThread, - std::unordered_set &visited) { - if (!visited.insert(threadId).second) - return std::nullopt; - const ThreadPresentation *thread = model.thread(threadId); - if (!thread) - return std::nullopt; - - ThreadListRow row; - row.id = thread->id; - row.title = thread->title; - row.cwd = thread->cwd; - row.status = thread->status; - row.createdAt = thread->createdAt; - row.updatedAt = thread->updatedAt; - row.recencyAt = thread->recencyAt; - row.lastActivityAt = thread->lastActivityAt; - if (const auto pending = pendingByThread.find(threadId); - pending != pendingByThread.end()) - row.pending = pending->second; - row.archived = thread->archived; - row.children.reserve(thread->childThreadOrder.size()); - for (const std::string &childId : thread->childThreadOrder) { - if (auto child = projectThread(model, childId, pendingByThread, visited)) - row.children.push_back(std::move(*child)); - } - return row; -} - -InspectorPlanSnapshot projectPlan(const PresentationModel &model, - const std::string &threadId) { - InspectorPlanSnapshot result; - result.threadId = threadId; - const ThreadPresentation *thread = model.thread(threadId); - result.threadPresent = thread != nullptr; - if (!thread) - return result; - - for (auto id = thread->turnOrder.rbegin(); id != thread->turnOrder.rend(); - ++id) { - const auto turn = thread->turns.find(*id); - if (turn == thread->turns.end()) - continue; - if (turn->second.plan.is_object() && turn->second.plan.contains("steps")) { - InspectorPlan plan; - plan.explanation = stringValue(turn->second.plan, "explanation"); - for (const auto &step : - turn->second.plan.value("steps", nlohmann::json::array())) { - const std::string status = stringValue(step, "status"); - plan.steps.push_back( - {stringValue(step, "step"), - effectivePlanStepStatus(status, turn->second.status, - thread->status)}); - } - result.plan = std::move(plan); - break; - } - for (auto itemId = turn->second.itemOrder.rbegin(); - itemId != turn->second.itemOrder.rend(); ++itemId) { - const auto item = turn->second.items.find(*itemId); - if (item != turn->second.items.end() && - stringValue(item->second.raw, "type") == "plan") { - result.planItem = stringValue(item->second.raw, "text"); - break; - } - } - if (result.planItem) - break; - } - return result; -} - -InspectorAgentsSnapshot projectAgents(const PresentationModel &model, - const std::string &threadId) { - InspectorAgentsSnapshot result; - result.threadId = threadId; - const ThreadPresentation *thread = model.thread(threadId); - result.threadPresent = thread != nullptr; - if (!thread) - return result; - - result.agents.reserve(thread->agentOrder.size()); - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent == thread->agents.end()) - continue; - InspectorAgentRow row; - row.id = id; - row.status = agent->second.status; - row.childThreadId = agent->second.childThreadId; - row.agentPath = stringValue(agent->second.raw, "agentPath"); - row.tool = stringValue(agent->second.raw, "tool"); - row.model = stringValue(agent->second.raw, "model"); - row.reasoningEffort = stringValue(agent->second.raw, "reasoningEffort"); - row.prompt = stringValue(agent->second.raw, "prompt"); - row.resultText = stringValue(agent->second.raw, "resultText"); - row.senderThreadId = stringValue(agent->second.raw, "senderThreadId"); - const auto receivers = agent->second.raw.find("receiverThreadIds"); - if (receivers != agent->second.raw.end() && receivers->is_array()) { - for (const auto &receiver : *receivers) { - if (receiver.is_string()) - row.receiverThreadIds.push_back(receiver.get()); - } - } - result.agents.push_back(std::move(row)); - } - return result; -} - -InspectorRequestsSnapshot -projectRequests(const PresentationModel &model, - const std::function &requestEligible) { - InspectorRequestsSnapshot result; - result.requests.reserve(model.pendingRequestCount()); - for (const auto &[id, request] : model.pendingRequestPresentations()) { - InspectorRequestRow row; - row.id = id; - row.kind = request.kind; - row.threadContext = request.threadId; - if (const ThreadPresentation *thread = model.thread(request.threadId); - thread && !thread->title.empty()) - row.threadContext = thread->title; - row.generation = request.generation; - row.command = stringValue(request.raw, "command"); - row.reason = stringValue(request.raw, "reason"); - row.message = stringValue(request.raw, "message"); - const auto questions = request.raw.find("questions"); - if (questions != request.raw.end() && questions->is_array()) - row.questionCount = questions->size(); - row.actionable = requestEligible && requestEligible(id); - result.requests.push_back(std::move(row)); - } - return result; -} - -InspectorChangesSnapshot projectChanges(const PresentationModel &model, - const std::string &threadId) { - InspectorChangesSnapshot result; - result.threadId = threadId; - if (const ThreadPresentation *thread = model.thread(threadId)) { - result.cwd = thread->cwd; - result.commandCwds = thread->commandCwds; - result.changedPaths = thread->changedPaths; - } - return result; -} - -InspectorStateSnapshot projectState(const PresentationModel &model, - const std::string &threadId) { - InspectorStateSnapshot result; - nlohmann::json domains = nlohmann::json::object(); - for (const auto &[name, value] : model.globalDomains()) - domains[name] = value; - nlohmann::json pending = nlohmann::json::object(); - for (const auto &[id, request] : model.pendingRequestPresentations()) - pending[id] = {{"category", request.kind}, - {"threadId", request.threadId}, - {"generation", request.generation}}; - result.state = {{"models", model.modelCatalog()}, - {"pendingRequests", std::move(pending)}, - {"domains", std::move(domains)}}; - result.threadCount = model.threadOrder().size(); - result.modelCount = model.modelCatalog().size(); - result.pendingRequestCount = model.pendingRequestCount(); - result.telemetryCount = model.telemetry().size(); - if (const ThreadPresentation *thread = model.thread(threadId)) { - result.selectedThreadTurnCount = thread->turnOrder.size(); - for (const auto &[id, turn] : thread->turns) { - static_cast(id); - result.selectedThreadItemCount += turn.itemOrder.size(); - } - } - return result; -} - -} // namespace - -ThreadListSnapshot projectThreadListSnapshot(const PresentationModel &model, - std::string selectedThreadId) { - ThreadListSnapshot result; - result.selectedThreadId = std::move(selectedThreadId); - const ConnectionPresentation &connection = model.connection(); - result.providerReady = - connection.connected && connection.providerState == "ready"; - result.canControl = result.providerReady && connection.role == "controller"; - - std::unordered_map pendingByThread; - pendingByThread.reserve(model.pendingRequestCount()); - for (const auto &[id, request] : model.pendingRequestPresentations()) { - static_cast(id); - ++pendingByThread[request.threadId]; - } - - std::unordered_set visited; - visited.reserve(model.threadOrder().size()); - result.roots.reserve(model.threadOrder().size()); - for (const std::string &id : model.threadOrder()) { - if (auto row = projectThread(model, id, pendingByThread, visited)) - result.roots.push_back(std::move(*row)); - } - return result; -} - -InspectorSnapshot projectInspectorSnapshot( - const PresentationModel &model, std::string selectedThreadId, - const std::function &requestEligible) { - InspectorSnapshot result; - result.plan = projectPlan(model, selectedThreadId); - result.agents = projectAgents(model, selectedThreadId); - result.changes = projectChanges(model, selectedThreadId); - result.requests = projectRequests(model, requestEligible); - result.state = projectState(model, selectedThreadId); - return result; -} - -} // namespace codexui::codex::ui diff --git a/src/codex/ui/UiViewProjection.h b/src/codex/ui/UiViewProjection.h deleted file mode 100644 index 5b4a584..0000000 --- a/src/codex/ui/UiViewProjection.h +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#ifndef CODEXUI_CODEX_UI_UIVIEWPROJECTION_H -#define CODEXUI_CODEX_UI_UIVIEWPROJECTION_H - -#include "codex/ui/UiViewState.h" - -#include -#include -#include - -namespace codexui::codex { - -class PresentationModel; - -namespace ui { - -[[nodiscard]] ThreadListSnapshot -projectThreadListSnapshot(const PresentationModel &model, - std::string selectedThreadId); - -[[nodiscard]] InspectorSnapshot projectInspectorSnapshot( - const PresentationModel &model, std::string selectedThreadId, - const std::function &requestEligible = {}); - -} // namespace ui -} // namespace codexui::codex - -#endif // CODEXUI_CODEX_UI_UIVIEWPROJECTION_H diff --git a/src/codex/ui/UiViewState.h b/src/codex/ui/UiViewState.h index 356b97b..e87c448 100644 --- a/src/codex/ui/UiViewState.h +++ b/src/codex/ui/UiViewState.h @@ -13,6 +13,8 @@ namespace codexui::codex::ui { +enum class InspectorProjection { All, Plan, Agents, Changes, Requests, State }; + // Toolkit-neutral inputs for the concrete thread-list renderer. Expansion, // sorting, and optimistic rows deliberately remain local to that renderer. struct ThreadListRow { diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp deleted file mode 100644 index d5b6ceb..0000000 --- a/tests/codex/ApplicationLayoutTest.cpp +++ /dev/null @@ -1,2483 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/GitDiffProvider.h" -#include "codex/PresentationModel.h" -#include "codex/PresentationProtocol.h" -#include "codex/TurnSettingsWidget.h" -#include "codex/middle/ComposerPane.h" -#include "codex/middle/ConversationCards.h" -#include "codex/middle/ConversationView.h" -#include "codex/middle/InspectorPane.h" -#include "codex/middle/MiddleRegionWidget.h" -#include "codex/middle/ThreadPane.h" -#include "codex/ui/ExpandingPromptEditor.h" -#include "codex/ui/UiStyle.h" -#include "codex/ui/UiViewProjection.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include - -namespace codexui::codex::middle { -namespace { - -class LayoutRequestCounter final : public QObject { -public: - int count = 0; - -protected: - bool eventFilter(QObject *watched, QEvent *event) override { - static_cast(watched); - if (event->type() == QEvent::LayoutRequest) - ++count; - return false; - } -}; - -bool expect(bool condition, const char *message) { - if (condition) - return true; - std::cerr << "FAILED: " << message << '\n'; - return false; -} - -std::string utf8(const QString &value) { return value.toUtf8().toStdString(); } - -void refresh(ThreadPane &pane, const PresentationModel &model, - std::string selectedThreadId) { - pane.refresh( - ui::projectThreadListSnapshot(model, std::move(selectedThreadId))); -} - -void refresh(InspectorPane &pane, const PresentationModel &model, - std::string selectedThreadId) { - pane.refresh( - ui::projectInspectorSnapshot(model, std::move(selectedThreadId))); -} - -void sendPromptKey(codexui::ExpandingPromptEditor &editor, int key, - Qt::KeyboardModifiers modifiers = Qt::NoModifier, - bool autoRepeat = false) { - QKeyEvent event(QEvent::KeyPress, key, modifiers, QString(), autoRepeat, 1); - QCoreApplication::sendEvent(&editor, &event); -} - -bool testPromptKeyboardSubmission() { - codexui::ExpandingPromptEditor editor; - editor.resize(480, 80); - editor.show(); - editor.setFocus(); - QCoreApplication::processEvents(); - - int submissions = 0; - QObject::connect(&editor, &codexui::ExpandingPromptEditor::submitRequested, - [&submissions] { ++submissions; }); - const auto resetDraft = [&editor] { - editor.setPlainText(QStringLiteral("draft")); - editor.moveCursor(QTextCursor::End); - }; - - bool result = - expect(editor.accessibleName() == QStringLiteral("Message Codex") && - editor.accessibleDescription().contains( - QStringLiteral("Shift+Enter")), - "the prompt editor exposes its name and keyboard hint"); - - resetDraft(); - sendPromptKey(editor, Qt::Key_Return); - result &= - expect(submissions == 1, "Return submits the focused prompt editor"); - resetDraft(); - sendPromptKey(editor, Qt::Key_Enter, Qt::KeypadModifier); - result &= expect(submissions == 2, - "keypad Enter submits the focused prompt editor"); - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, Qt::ControlModifier); - result &= expect(submissions == 3, - "Control+Enter remains a prompt submission alias"); - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, Qt::MetaModifier); - result &= expect(submissions == 4, "Meta+Enter is a prompt submission alias"); - - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, Qt::ShiftModifier); - result &= expect(submissions == 4 && - editor.toPlainText() == QStringLiteral("draft\n"), - "Shift+Enter inserts a newline without submitting"); - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, - Qt::ControlModifier | Qt::ShiftModifier); - result &= expect( - submissions == 4 && editor.toPlainText() == QStringLiteral("draft\n"), - "Shift takes precedence over the Control+Enter submission alias"); - - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, Qt::AltModifier); - result &= expect(submissions == 4, "Alt+Enter does not submit a prompt"); - resetDraft(); - sendPromptKey(editor, Qt::Key_Return, Qt::ControlModifier, true); - result &= expect(submissions == 4 && - editor.toPlainText() == QStringLiteral("draft"), - "an auto-repeated Enter chord neither submits nor inserts"); - - resetDraft(); - QInputMethodEvent preedit(QStringLiteral("candidate"), {}); - QCoreApplication::sendEvent(&editor, &preedit); - sendPromptKey(editor, Qt::Key_Return); - result &= expect(submissions == 4, - "Enter does not submit while IME preedit is active"); - QInputMethodEvent commit; - commit.setCommitString(QStringLiteral("candidate")); - QCoreApplication::sendEvent(&editor, &commit); - resetDraft(); - sendPromptKey(editor, Qt::Key_Return); - result &= expect(submissions == 5, - "Enter submits again after IME composition completes"); - - editor.setPlainText(QStringLiteral("one\ntwo\nthree\nfour")); - QCoreApplication::processEvents(); - editor.verticalScrollBar()->setValue(editor.verticalScrollBar()->maximum()); - result &= expect( - editor.verticalScrollBarPolicy() == Qt::ScrollBarAlwaysOff && - editor.verticalScrollBar()->value() == - editor.verticalScrollBar()->minimum(), - "a fully visible multiline draft has no hidden empty-line scroll tail"); - - editor.clear(); - editor.resize(280, codexui::ExpandingPromptEditor::compactHeight()); - QCoreApplication::processEvents(); - const int compactWidth = 170; - QString boundary; - while (boundary.size() < 100 && - !editor.requiresExpandedLayout(compactWidth)) { - boundary += QLatin1Char('W'); - editor.setPlainText(boundary); - } - const QString beforeBoundary = boundary.chopped(1); - editor.setPlainText(beforeBoundary); - const bool beforeExpands = editor.requiresExpandedLayout(compactWidth); - editor.setPlainText(boundary); - QCoreApplication::processEvents(); - const qreal liveWidth = editor.document()->textWidth(); - int liveLayoutChanges = 0; - const QMetaObject::Connection layoutConnection = QObject::connect( - editor.document()->documentLayout(), - &QAbstractTextDocumentLayout::documentSizeChanged, &editor, - [&liveLayoutChanges] { ++liveLayoutChanges; }); - const bool boundaryExpands = editor.requiresExpandedLayout(compactWidth); - QObject::disconnect(layoutConnection); - result &= expect(!beforeBoundary.isEmpty(), - "the compact probe discovers a nonempty wrap boundary"); - result &= expect(!beforeExpands, - "the character before the wrap boundary remains compact"); - result &= expect(boundaryExpands, - "the first wrapped character enters multiline mode"); - result &= expect(editor.document()->textWidth() == liveWidth, - "compact layout probing preserves the live document width"); - result &= expect( - liveLayoutChanges == 0, - "compact layout probing does not relay out the visible document"); - return result; -} - -bool commitPath(git_repository *repository, const char *path) { - git_index *index = nullptr; - if (git_repository_index(&index, repository) < 0) - return false; - const bool indexed = - git_index_add_bypath(index, path) == 0 && git_index_write(index) == 0; - git_oid treeId{}; - const bool wroteTree = indexed && git_index_write_tree(&treeId, index) == 0; - git_index_free(index); - if (!wroteTree) - return false; - git_tree *tree = nullptr; - git_signature *signature = nullptr; - if (git_tree_lookup(&tree, repository, &treeId) < 0 || - git_signature_now(&signature, "CodexUI Test", "codexui@example.invalid") < - 0) { - git_tree_free(tree); - git_signature_free(signature); - return false; - } - git_oid commitId{}; - git_reference *head = nullptr; - git_commit *parent = nullptr; - if (git_repository_head(&head, repository) == 0) - git_commit_lookup(&parent, repository, git_reference_target(head)); - const git_commit *parents[] = {parent}; - const bool committed = - git_commit_create(&commitId, repository, "HEAD", signature, signature, - nullptr, "path baseline", tree, parent ? 1 : 0, - parent ? parents : nullptr) == 0; - git_commit_free(parent); - git_reference_free(head); - git_signature_free(signature); - git_tree_free(tree); - return committed; -} - -bool hasLabelContaining(const QWidget &root, const QString &text) { - for (const QLabel *label : root.findChildren()) { - if (label->text().contains(text)) - return true; - } - return false; -} - -bool hasButtonText(const QWidget &root, const QString &text) { - for (const QPushButton *button : root.findChildren()) { - if (button->text() == text) - return true; - } - return false; -} - -void spin(int milliseconds = 0) { - QElapsedTimer timer; - timer.start(); - do { - QCoreApplication::processEvents(QEventLoop::AllEvents, 10); - if (milliseconds > 0) - QThread::msleep(1); - } while (timer.elapsed() < milliseconds); -} - -VisibleCardData textCard(const std::string &thread, int index) { - const std::string turn = index < 15 ? "turn-1" : "turn-2"; - const std::string item = "item-" + std::to_string(index); - return {AuthoritativeItemKey{thread, turn, item}, - CardKind::AgentMessage, - thread, - turn, - item, - AgentMessageData{ - utf8(QStringLiteral("A materialized response line %1 with enough " - "content to occupy normal card height.") - .arg(index)), - false}}; -} - -ConversationSnapshot longConversation(const std::string &thread) { - ConversationSnapshot snapshot; - snapshot.threadId = thread; - snapshot.sections = {{"turn-one", "turn-1", {}}, {"turn-two", "turn-2", {}}}; - for (int index = 0; index < 30; ++index) - snapshot.sections[index < 15 ? 0 : 1].cards.push_back( - textCard(thread, index)); - return snapshot; -} - -QWheelEvent wheelFor(QWidget *target, int pixelDelta, - Qt::ScrollPhase phase = Qt::ScrollUpdate) { - const QPointF local(target->rect().center()); - return QWheelEvent(local, target->mapToGlobal(local.toPoint()), QPoint(), - QPoint(0, pixelDelta), Qt::NoButton, Qt::NoModifier, phase, - false); -} - -std::vector threadOrder(const ThreadPane &pane) { - const auto *list = - pane.findChild(QStringLiteral("threadList")); - std::vector result; - if (!list) - return result; - result.reserve(static_cast(list->count())); - for (int row = 0; row < list->count(); ++row) - result.push_back( - list->item(row)->data(Qt::UserRole).toString().toStdString()); - return result; -} - -QListWidgetItem *threadItem(QListWidget *list, std::string_view id) { - if (!list) - return nullptr; - for (int row = 0; row < list->count(); ++row) { - QListWidgetItem *item = list->item(row); - if (item && item->data(Qt::UserRole).toString().toStdString() == id) - return item; - } - return nullptr; -} - -bool testOverlayGeometryAndRegionRouting() { - MiddleRegionWidget region; - bool result = - expect(region.composer().extraOverlayHeight() == 0 && - region.conversation().trailingSpaceHeight() == 0, - "composer construction reports no pre-canonical trailing space"); - region.resize(1500, 820); - region.show(); - region.setThreadHeading( - QStringLiteral("Thread title"), QStringLiteral("/workspace"), - QStringLiteral("Last activity: 14:15:51"), QStringLiteral("completed"), - QStringLiteral("success")); - spin(20); - - QSplitter *splitter = region.splitterWidget(); - result &= expect(splitter->count() == 3 && splitter->handleWidth() == 8, - "middle region keeps the three-pane splitter geometry"); - result &= expect(splitter->widget(0)->minimumWidth() == 220 && - splitter->widget(0)->maximumWidth() == 440 && - splitter->widget(1)->minimumWidth() == 480 && - splitter->widget(2)->minimumWidth() == 300 && - splitter->widget(2)->maximumWidth() == 520, - "pane width constraints match the visual contract"); - - auto *threadHeaderDivider = splitter->widget(0)->findChild( - QStringLiteral("threadHeaderDivider")); - auto *conversationHeaderDivider = splitter->widget(1)->findChild( - QStringLiteral("conversationHeaderDivider")); - auto *conversationTitle = splitter->widget(1)->findChild( - QStringLiteral("conversationTitle")); - auto *conversationMetadata = splitter->widget(1)->findChild( - QStringLiteral("conversationMetadata")); - auto *conversationTrailingMetadata = - splitter->widget(1)->findChild( - QStringLiteral("conversationTrailingMetadata")); - auto *conversationState = splitter->widget(1)->findChild( - QStringLiteral("conversationState")); - auto *reasoningToggle = splitter->widget(1)->findChild( - QStringLiteral("conversationReasoningToggle")); - auto *updatesToggle = splitter->widget(1)->findChild( - QStringLiteral("conversationUpdatesToggle")); - auto *commandFoldingToggle = splitter->widget(1)->findChild( - QStringLiteral("conversationCommandFoldingToggle")); - auto *imageFoldingToggle = splitter->widget(1)->findChild( - QStringLiteral("conversationImageFoldingToggle")); - const auto paneRect = [](QWidget *widget, QWidget *pane) { - return QRect(widget->mapTo(pane, QPoint()), widget->size()); - }; - const QRect threadDividerRect = - threadHeaderDivider ? paneRect(threadHeaderDivider, splitter->widget(0)) - : QRect{}; - const QRect conversationDividerRect = - conversationHeaderDivider - ? paneRect(conversationHeaderDivider, splitter->widget(1)) - : QRect{}; - result &= expect( - threadHeaderDivider && conversationHeaderDivider && - threadDividerRect.left() == 10 && - threadDividerRect.right() == splitter->widget(0)->width() - 11 && - conversationDividerRect.left() == 10 && - conversationDividerRect.right() == splitter->widget(1)->width() - 11, - "Threads and Conversation header dividers share the 10 px inset"); - result &= expect( - conversationTitle && conversationMetadata && - conversationTrailingMetadata && conversationState && - conversationMetadata->geometry().left() > - conversationTitle->geometry().right() && - conversationTrailingMetadata->geometry().right() < - conversationState->geometry().left() && - conversationState->geometry().right() >= - conversationState->parentWidget()->width() - 16 && - conversationTrailingMetadata->text() == - QStringLiteral("Last activity: 14:15:51") && - conversationTrailingMetadata->property("tone").toString() == - QStringLiteral("strong") && - conversationState->text() == QStringLiteral("completed") && - conversationState->property("tone").toString() == - QStringLiteral("success") && - conversationState->width() >= - conversationState->fontMetrics().horizontalAdvance( - conversationState->text()) && - conversationTrailingMetadata->width() >= - conversationTrailingMetadata->fontMetrics().horizontalAdvance( - conversationTrailingMetadata->text()) && - std::abs((conversationMetadata->geometry().top() + - conversationMetadata->contentsMargins().top() + - conversationMetadata->fontMetrics().ascent()) - - (conversationTitle->geometry().top() + - conversationTitle->contentsMargins().top() + - conversationTitle->fontMetrics().ascent())) <= 1, - "thread title metadata align by baseline and activity aligns right"); - result &= expect( - reasoningToggle && updatesToggle && commandFoldingToggle && - imageFoldingToggle && !reasoningToggle->isChecked() && - updatesToggle->isChecked() && commandFoldingToggle->isChecked() && - imageFoldingToggle->isChecked() && - reasoningToggle->text().isEmpty() && - updatesToggle->text().isEmpty() && - commandFoldingToggle->text().isEmpty() && - imageFoldingToggle->text().isEmpty() && - reasoningToggle->accessibleName() == - QStringLiteral("Show reasoning cards") && - commandFoldingToggle->accessibleName() == - QStringLiteral("New command cards start expanded") && - imageFoldingToggle->accessibleName() == - QStringLiteral("New image cards start expanded"), - "Conversation header exposes the three canonical default presentation " - "controls"); - if (reasoningToggle && commandFoldingToggle) { - reasoningToggle->click(); - commandFoldingToggle->click(); - const auto options = region.conversation().presentationOptions(); - const QSettings persisted; - result &= expect( - options.showReasoning && options.showCodexUpdates && - !options.commandsInitiallyExpanded && - reasoningToggle->accessibleName() == - QStringLiteral("Hide reasoning cards") && - commandFoldingToggle->accessibleName() == - QStringLiteral("New command cards start collapsed") && - persisted.value(QStringLiteral("conversation/showReasoning"), false) - .toBool() && - !persisted - .value( - QStringLiteral("conversation/commandsInitiallyExpanded"), - true) - .toBool(), - "Conversation presentation controls update the view and persistent " - "settings together"); - reasoningToggle->click(); - commandFoldingToggle->click(); - } - - ConversationView &view = region.conversation(); - view.reconcile(longConversation("layout-thread")); - spin(20); - const QRect viewGeometry = view.geometry(); - const QRect viewportGeometry = view.viewport()->geometry(); - auto *notice = region.findChild( - QStringLiteral("conversationNoticeBar")); - auto *dismissNotice = - notice ? notice->findChild() : nullptr; - region.showNotice(QStringLiteral("Transient interaction notice"), false); - spin(10); - const auto regionRect = [®ion](QWidget *widget) { - return QRect(widget->mapTo(®ion, QPoint()), widget->size()); - }; - result &= expect( - notice && notice->isVisible() && dismissNotice && - view.geometry() == viewGeometry && - view.viewport()->geometry() == viewportGeometry && - regionRect(notice).intersects(regionRect(&view)), - "transient interaction notice overlays without shifting messages"); - if (dismissNotice) - dismissNotice->click(); - spin(10); - result &= expect(notice && notice->isHidden() && - view.geometry() == viewGeometry && - view.viewport()->geometry() == viewportGeometry, - "dismissing the notice preserves message geometry"); - const int canonical = region.composer().canonicalReserveHeight(); - result &= - expect(canonical > 0 && - region.composer().canonicalReserve()->height() == canonical, - "composer establishes one compact canonical reserve"); - - QFrame *boundary = nullptr; - QFrame *composerSurface = nullptr; - for (QFrame *frame : region.composer().findChildren()) { - const QString kind = frame->property("kind").toString(); - if (kind == QStringLiteral("standardDivider")) - boundary = frame; - else if (kind == QStringLiteral("composer")) - composerSurface = frame; - } - TurnSettingsWidget *settings = region.composer().turnSettings(); - auto *sendButton = region.composer().findChild( - QStringLiteral("composerSendButton")); - const auto overlayRect = [&](QWidget *widget) { - return QRect(widget->mapTo(®ion.composer(), QPoint()), widget->size()); - }; - const auto settingsToComposerGap = [&] { - return composerSurface ? overlayRect(composerSurface).top() - - overlayRect(settings).bottom() - 1 - : -1; - }; - const auto settingsToEditorGap = [&] { - return region.composer() - .promptEditor() - ->mapTo(®ion.composer(), QPoint()) - .y() - - overlayRect(settings).bottom() - 1; - }; - const auto stableComposerGeometry = [&] { - if (!boundary || !composerSurface) - return false; - const QRect boundaryRect = overlayRect(boundary); - const QRect settingsRect = overlayRect(settings); - const QRect composerRect = overlayRect(composerSurface); - return boundaryRect.top() == 8 && boundaryRect.height() == 1 && - settingsRect.top() - boundaryRect.bottom() - 1 == 8 && - settings->height() == settings->sizeHint().height() && - settingsToComposerGap() == 8 && boundaryRect.left() == 0 && - boundaryRect.right() == region.composer().width() - 1 && - settingsRect.left() == 10 && composerRect.left() == 10 && - settingsRect.right() == region.composer().width() - 11 && - composerRect.right() == region.composer().width() - 11 && - boundaryRect.width() == composerRect.width() + 20; - }; - const auto finalCardBottom = [&] { - int bottom = -1; - for (QFrame *frame : view.findChildren()) { - if (!frame->property("conversationAnchorKey").toString().isEmpty() && - frame->isVisible()) - bottom = std::max( - bottom, - frame->mapTo(view.viewport(), QPoint(0, frame->height())).y()); - } - return bottom; - }; - result &= expect( - boundary && composerSurface && - region.composer().testAttribute(Qt::WA_StyledBackground) && - UiStyle::applicationStyleSheet().contains( - QStringLiteral("QWidget#composerOverlay")) && - UiStyle::applicationStyleSheet().contains( - QStringLiteral("QLabel[tone=\"success\"]")) && - stableComposerGeometry(), - "compact composer has an opaque surface and canonical section gaps"); - region.composer().setCanSubmit(true); - result &= expect(sendButton && !sendButton->isEnabled(), - "an empty prompt cannot activate Send"); - region.composer().promptEditor()->setPlainText(QStringLiteral("draft")); - spin(10); - result &= expect(sendButton && sendButton->isEnabled(), - "non-blank input activates Send when admission is ready"); - region.composer().promptEditor()->setFocus(); - spin(10); - result &= expect(composerSurface->property("focused").toBool(), - "prompt focus activates the canonical composer focus state"); - QString submittedPrompt; - ComposerPane::Actions exactSubmission; - exactSubmission.submit = [&submittedPrompt]( - QString prompt, - std::vector) { - submittedPrompt = std::move(prompt); - return false; - }; - region.composer().setActions(std::move(exactSubmission)); - const QString exactPrompt = QStringLiteral(" indented Markdown\n\n"); - region.composer().promptEditor()->setPlainText(exactPrompt); - QMetaObject::invokeMethod(region.composer().promptEditor(), - "submitRequested", Qt::DirectConnection); - result &= expect(submittedPrompt == exactPrompt, - "submission validates whitespace without rewriting it"); - region.composer().clearDraft(); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - result &= expect(finalCardBottom() == view.viewport()->height(), - "compact bottom has no scroll-owned trailing gap"); - const QRect compactOverlayGeometry = region.composer().geometry(); - const QRect compactBoundaryGeometry = boundary->geometry(); - view.verticalScrollBar()->setValue( - std::max(0, view.verticalScrollBar()->maximum() - 80)); - spin(10); - result &= expect(region.composer().geometry() == compactOverlayGeometry && - boundary->geometry() == compactBoundaryGeometry, - "history scrolling leaves the composer boundary fixed"); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - const int compactEditorGap = settingsToEditorGap(); - region.composer().setActiveTurn(true); - spin(20); - result &= expect(stableComposerGeometry() && - settingsToEditorGap() == compactEditorGap, - "active-turn controls retain the compact composer gaps"); - - QString longPrompt; - for (int line = 0; line < 14; ++line) - longPrompt += QStringLiteral("A deliberately long prompt line %1 that " - "grows the editor upward.\n") - .arg(line); - region.composer().promptEditor()->setPlainText(longPrompt); - spin(30); - const int extra = region.composer().extraOverlayHeight(); - result &= expect(extra > 0 && view.trailingSpaceHeight() == extra, - "prompt growth is mirrored by exact trailing scroll space"); - result &= expect( - view.verticalScrollBar()->property("composerBottomInset").toInt() == - extra && - view.verticalScrollBar()->styleSheet().contains( - QStringLiteral("margin:2px 2px %1px 2px").arg(extra + 2)), - "prompt growth shortens the visible message scrollbar track"); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - result &= expect( - stableComposerGeometry() && settingsToEditorGap() == compactEditorGap && - view.viewport()->height() - finalCardBottom() == extra && - view.geometry() == viewGeometry && - view.viewport()->geometry() == viewportGeometry && - region.composer().canonicalReserve()->height() == canonical, - "prompt growth keeps gaps fixed without shifting the message viewport"); - region.composer().setAttachments( - {{"/tmp/layout-diagnostic.png", "layout-diagnostic.png", "image/png"}}); - spin(30); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - result &= expect(stableComposerGeometry() && - region.composer().extraOverlayHeight() > extra && - view.viewport()->height() - finalCardBottom() == - region.composer().extraOverlayHeight() && - view.trailingSpaceHeight() == - region.composer().extraOverlayHeight(), - "attachments retain the canonical settings-to-composer gap"); - region.composer().clearDraft(); - spin(30); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - result &= expect( - region.composer().extraOverlayHeight() == 0 && - view.trailingSpaceHeight() == 0 && view.geometry() == viewGeometry && - view.verticalScrollBar() - ->property("composerBottomInset") - .toInt() == 0 && - view.verticalScrollBar()->styleSheet().isEmpty() && - view.viewport()->geometry() == viewportGeometry && - finalCardBottom() == view.viewport()->height() && - stableComposerGeometry() && settingsToEditorGap() == compactEditorGap, - "prompt contraction restores canonical layout, gaps, and trailing space"); - region.composer().setActiveTurn(false); - spin(20); - result &= expect(view.isAtBottom(), "conversation begins at the bottom"); - region.composer().setAttentionRequest( - QStringLiteral("Command approval requested"), - QStringLiteral("Command: gh auth status | Reason: Verify GitHub " - "authentication"), - true, QStringLiteral("Accept")); - region.composer().setAttentionVisible(true); - spin(20); - result &= expect( - hasLabelContaining(region.composer(), - QStringLiteral("Command approval requested")) && - hasLabelContaining(region.composer(), - QStringLiteral("Command: gh auth status")) && - hasButtonText(region.composer(), QStringLiteral("Reject")) && - hasButtonText(region.composer(), QStringLiteral("Accept")), - "composer attention requests show details and direct semantic actions"); - region.composer().setAttentionVisible(false); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); - spin(10); - - ComposerPane::Actions rejected; - rejected.submit = [](QString, std::vector) { return false; }; - region.composer().setActions(std::move(rejected)); - region.composer().promptEditor()->setPlainText( - QStringLiteral("must survive rejected admission")); - QMetaObject::invokeMethod(region.composer().promptEditor(), "submitRequested", - Qt::DirectConnection); - result &= expect(region.composer().promptEditor()->toPlainText() == - QStringLiteral("must survive rejected admission"), - "rejected admission preserves the complete composer draft"); - ComposerPane::Actions accepted; - accepted.submit = [](QString, std::vector) { return true; }; - region.composer().setActions(std::move(accepted)); - QMetaObject::invokeMethod(region.composer().promptEditor(), "submitRequested", - Qt::DirectConnection); - result &= expect(region.composer().promptEditor()->toPlainText().isEmpty(), - "successful local admission clears the draft exactly once"); - - QString oversizedPrompt; - for (int line = 0; line < 30; ++line) - oversizedPrompt += QStringLiteral("scroll-owned prompt line %1\n").arg(line); - region.composer().promptEditor()->setPlainText(oversizedPrompt); - spin(20); - auto *promptScroll = region.composer().promptEditor()->verticalScrollBar(); - promptScroll->setValue(promptScroll->minimum()); - view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); - const int conversationBeforePromptWheel = - view.verticalScrollBar()->value(); - QWheelEvent promptRoute = - wheelFor(region.composer().promptEditor(), 120, Qt::ScrollBegin); - result &= expect( - !region.routeScrollEvent(region.composer().promptEditor(), &promptRoute), - "the prompt editor retains its own wheel origin"); - QWheelEvent promptNative = - wheelFor(region.composer().promptEditor(), 120, Qt::ScrollUpdate); - QCoreApplication::sendEvent(region.composer().promptEditor(), &promptNative); - result &= expect( - view.verticalScrollBar()->value() == conversationBeforePromptWheel, - "prompt overscroll cannot move the conversation"); - QWheelEvent settingsWheel = wheelFor(settings, 120, Qt::ScrollBegin); - result &= expect(region.routeScrollEvent(settings, &settingsWheel) && - view.verticalScrollBar()->value() == - conversationBeforePromptWheel, - "settings-originated scrolling is consumed locally"); - region.composer().clearDraft(); - spin(20); - - QWheelEvent overLeftHandle = wheelFor(splitter->handle(1), 180); - result &= - expect(region.routeScrollEvent(splitter->handle(1), &overLeftHandle) && - view.mode() == ConversationView::Mode::Paused, - "the left middle splitter handle routes wheel input"); - QWheelEvent overRightHandle = wheelFor(splitter->handle(2), 180); - const int beforeRight = view.verticalScrollBar()->value(); - result &= - expect(region.routeScrollEvent(splitter->handle(2), &overRightHandle) && - view.verticalScrollBar()->value() < beforeRight, - "the right middle splitter handle routes wheel input"); - return result; -} - -bool testStableComposerLayoutRequests() { - qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); - bool result = true; - { - MiddleRegionWidget region; - region.resize(1500, 820); - region.show(); - spin(20); - - LayoutRequestCounter composerLayoutRequests; - region.composer().installEventFilter(&composerLayoutRequests); - spin(80); - result = - expect(composerLayoutRequests.count <= 1, - "stable composer geometry does not perpetually request layout"); - } - qApp->setStyleSheet(QString{}); - return result; -} - -bool testThreadSelectionProjection() { - PresentationModel model; - model.applyEvent(presentation::event( - 1, 1, "thread.upsert", {{"thread", {{"id", "thread-a"}, {"name", "A"}}}}, - presentation::Authority::Merge, {{"threadId", "thread-a"}})); - model.applyEvent(presentation::event( - 2, 1, "thread.upsert", - {{"thread", - {{"id", "thread-b"}, {"name", "B"}, {"status", {{"type", "active"}}}}}}, - presentation::Authority::Merge, {{"threadId", "thread-b"}})); - - ThreadPane pane; - refresh(pane, model, "thread-a"); - bool result = expect(pane.visiblySelectedThreadId() == "thread-a", - "thread selection is projected from Shell state"); - refresh(pane, model, "draft:new-thread"); - result &= expect(pane.visiblySelectedThreadId().empty(), - "a New Thread draft cannot retain an old visible row"); - - model.applyEvent(presentation::event(3, 1, "agents.activity.upsert", - {{"activity", - {{"id", "thread-b"}, - {"type", "subAgentActivity"}, - {"status", "inProgress"}, - {"agentThreadId", "thread-b"}}}}, - presentation::Authority::Merge, - {{"threadId", "thread-a"}, - {"turnId", "turn-a"}, - {"itemId", "thread-b"}})); - refresh(pane, model, "thread-b"); - auto *list = pane.findChild(QStringLiteral("threadList")); - QListWidgetItem *selected = list ? list->currentItem() : nullptr; - result &= - expect(selected && - selected->data(Qt::UserRole).toString() == - QStringLiteral("thread-b") && - pane.visiblySelectedThreadId() == "thread-b", - "navigating to a nested thread reveals and selects it beneath its " - "parent"); - QWidget *row = selected && list ? list->itemWidget(selected) : nullptr; - auto *title = - row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; - auto *dot = row ? row->findChild(QStringLiteral("threadStatusDot")) - : nullptr; - auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; - auto *sortButton = - pane.findChild(QStringLiteral("threadSortButton")); - QListWidgetItem *parentItem = threadItem(list, "thread-a"); - QWidget *parentRow = - list && parentItem ? list->itemWidget(parentItem) : nullptr; - QWidget *disclosure = parentRow - ? parentRow->findChild( - QStringLiteral("threadExpansionIndicator")) - : nullptr; - auto *parentDot = - parentRow - ? parentRow->findChild(QStringLiteral("threadStatusDot")) - : nullptr; - const QString selectedAccessible = - selected ? selected->data(Qt::AccessibleTextRole).toString() : QString{}; - const QString parentAccessible = - parentItem ? parentItem->data(Qt::AccessibleTextRole).toString() - : QString{}; - result &= expect( - selected && selected->sizeHint().height() == 40 && rowLayout && - rowLayout->contentsMargins() == QMargins(0, 2, 0, 2) && - rowLayout->spacing() == 0 && title && dot && - dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && - disclosure && disclosure->size() == QSize(16, 24) && - disclosure->geometry().left() == 0 && parentDot && - parentDot->geometry().left() - disclosure->geometry().right() - 1 == - 2 && - disclosure->property("chevronDirection").toString() == - QStringLiteral("down") && - sortButton && - dynamic_cast(sortButton) && - sortButton->property("codexChevron").toBool() && - title->property("kind").toString() == QStringLiteral("title") && - selected->data(Qt::DisplayRole).toString().isEmpty() && - selectedAccessible.contains(QStringLiteral("B, running")) && - selectedAccessible.contains(QStringLiteral("level 2")) && - parentAccessible.contains(QStringLiteral("A")) && - parentAccessible.contains(QStringLiteral("expanded")) && - !title->wordWrap() && - title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && - selected->toolTip().contains(QStringLiteral("Workspace:")) && - selected->toolTip().contains(QStringLiteral("Status: running")) && - selected->toolTip().contains(QStringLiteral("Last activity:")) && - selected->toolTip().contains(QStringLiteral("Parent: A")), - "compact thread cards retain their status dot and expose canonical " - "details through hover and accessibility"); - refresh(pane, model, "thread-a"); - bool childPresent = false; - if (list) { - for (int index = 0; index < list->count(); ++index) { - childPresent |= list->item(index)->data(Qt::UserRole).toString() == - QStringLiteral("thread-b"); - } - } - result &= - expect(childPresent && pane.visiblySelectedThreadId() == "thread-a", - "a child thread remains nested while its parent is selected"); - model.applyEvent(presentation::event( - 4, 1, "thread.removed", nlohmann::json::object(), - presentation::Authority::Remove, {{"threadId", "thread-b"}})); - refresh(pane, model, "thread-a"); - bool retainedAfterRemoval = false; - if (list) { - for (int index = 0; index < list->count(); ++index) { - retainedAfterRemoval |= - list->item(index)->data(Qt::UserRole).toString() == - QStringLiteral("thread-b"); - } - } - result &= expect(!retainedAfterRemoval, - "an authoritative removal drops a retained thread"); - return result; -} - -bool testThreadRuntimeStatusColors() { - PresentationModel model; - const std::vector> statuses{ - {"thread-not-loaded", "notLoaded"}, - {"thread-completed", "idle"}, - {"thread-running", "active"}, - {"thread-failed", "systemError"}, - }; - std::uint64_t sequence = 1; - for (const auto &[id, status] : statuses) { - model.applyEvent(presentation::event( - sequence++, 1, "thread.upsert", - {{"thread", {{"id", id}, {"name", id}, - {"status", {{"type", status}}}}}}, - presentation::Authority::Merge, {{"threadId", id}})); - } - - ThreadPane pane; - refresh(pane, model, "thread-completed"); - auto *list = pane.findChild(QStringLiteral("threadList")); - const std::vector> expected{ - {"thread-not-loaded", UiStyle::threadInactive}, - {"thread-completed", UiStyle::green}, - {"thread-running", UiStyle::blue}, - {"thread-failed", UiStyle::red}, - }; - bool result = true; - for (const auto &[id, color] : expected) { - QListWidgetItem *item = threadItem(list, id); - QWidget *row = item && list ? list->itemWidget(item) : nullptr; - auto *dot = row ? row->findChild( - QStringLiteral("threadStatusDot")) - : nullptr; - const std::string message = - id + " uses its canonical app-server runtime-state color"; - result &= expect( - dot && dot->styleSheet().contains(QString::fromLatin1(color)), - message.c_str()); - } - return result; -} - -bool testIncrementalThreadSettings() { - TurnSettingsWidget settings; - const nlohmann::json models = - nlohmann::json::array({{{"model", "gpt-a"}, {"displayName", "A"}}, - {{"model", "gpt-b"}, {"displayName", "B"}}}); - settings.setContext("thread-a", - {{"model", "gpt-a"}, {"approvalPolicy", "never"}}, models, - nlohmann::json::array()); - auto *model = settings.findChild(QStringLiteral("codexModel")); - auto *approval = - settings.findChild(QStringLiteral("codexApproval")); - auto *personality = - settings.findChild(QStringLiteral("codexPersonality")); - auto *access = - settings.findChild(QStringLiteral("codexSandbox")); - auto *network = - settings.findChild(QStringLiteral("codexNetwork")); - auto *permissionProfile = - settings.findChild(QStringLiteral("codexPermissionProfile")); - if (!model || !approval || !personality || !access || !network || - !permissionProfile) - return expect(false, "thread settings controls are discoverable"); - - bool canonicalSettingsStyle = settings.styleSheet().isEmpty(); - for (const QLabel *label : settings.findChildren()) { - if (label->text() == QStringLiteral("Model")) - canonicalSettingsStyle &= label->property("kind") == "settingLabel" && - label->styleSheet().isEmpty(); - } - - model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); - settings.setContext( - "thread-a", {{"model", "gpt-a"}, {"approvalPolicy", "on-request"}}, - models, nlohmann::json::array(), 1, {{"approvalPolicy", "on-request"}}); - bool result = expect(canonicalSettingsStyle, - "thread settings use canonical application styling"); - result &= expect(UiStyle::humanizeLabel(QStringLiteral("xhigh")) == - QStringLiteral("Extra high"), - "the fallback reasoning effort uses a human-readable label"); - result &= expect( - model->currentData().toString() == QStringLiteral("gpt-b") && - approval->currentData().toString() == QStringLiteral("on-request"), - "a partial authoritative update preserves unrelated pending settings"); - - settings.setContext("thread-a", - {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}, - models, nlohmann::json::array(), 2, - {{"model", "gpt-b"}, {"approvalPolicy", "on-request"}}); - result &= expect(!settings.turnStartOptions().contains("model") && - !settings.turnStartOptions().contains("approvalPolicy"), - "authoritative settings clear their pending overrides"); - - settings.setContext("thread-b", - {{"model", "gpt-a"}, - {"reasoningEffort", "medium"}, - {"personality", "friendly"}, - {"sandboxPolicy", - {{"type", "workspaceWrite"}, {"networkAccess", false}}}, - {"approvalPolicy", "never"}, - {"approvalsReviewer", "user"}, - {"cwd", "/workspace"}, - {"activePermissionProfile", {{"id", "managed"}}}, - {"serviceTier", "priority"}, - {"summary", "concise"}, - {"collaborationMode", {{"mode", "default"}}}}, - models, nlohmann::json::array()); - result &= expect(model->currentData().toString() == QStringLiteral("gpt-a"), - "thread selection restores that thread's retained value"); - result &= - expect(settings.turnStartOptions() == - nlohmann::json{{"collaborationMode", - {{"mode", "default"}, - {"settings", - {{"model", "gpt-a"}, - {"developer_instructions", nullptr}, - {"reasoning_effort", "medium"}}}}}} && - settings.threadStartOptions().empty(), - "untouched settings emit only the displayed collaboration mode"); - result &= expect(access->isEnabled() && network->isEnabled(), - "a permission preset does not lock its effective access " - "controls"); - - settings.setContext("full-access-thread", - {{"sandboxPolicy", {{"type", "dangerFullAccess"}}}, - {"activePermissionProfile", {{"id", ":full-access"}}}}, - nlohmann::json::array(), - {{"data", nlohmann::json::array({{{"id", ":full-access"}, - {"allowed", true}}})}}); - result &= - expect(access->isEnabled() && !network->isEnabled() && - network->currentData().toString() == QStringLiteral("enabled"), - "only logically redundant network selection is disabled"); - - settings.setContext( - "workspace-profile-thread", - {{"sandboxPolicy", - {{"type", "workspaceWrite"}, {"networkAccess", false}}}, - {"activePermissionProfile", {{"id", ":workspace"}}}}, - nlohmann::json::array(), - {{"data", nlohmann::json::array( - {{{"id", ":workspace"}, {"allowed", true}}, - {{"id", ":read-only"}, {"allowed", true}}, - {{"id", ":danger-full-access"}, {"allowed", true}}})}}); - result &= expect( - permissionProfile->itemText(permissionProfile->findData( - QStringLiteral(":workspace"))) == QStringLiteral("Workspace") && - permissionProfile->itemText(permissionProfile->findData( - QStringLiteral(":read-only"))) == QStringLiteral("Read only") && - permissionProfile->itemText(permissionProfile->findData( - QStringLiteral(":danger-full-access"))) == - QStringLiteral("Full access"), - "built-in permission profiles have user-facing labels"); - - access->setCurrentIndex( - access->findData(QStringLiteral("danger-full-access"))); - const nlohmann::json explicitAccessTurn = settings.turnStartOptions(); - const nlohmann::json explicitAccessThread = settings.threadStartOptions(); - result &= expect( - permissionProfile->currentData().toString() == - QStringLiteral("default") && - !explicitAccessTurn.contains("permissions") && - explicitAccessTurn.value("sandboxPolicy", nlohmann::json(nullptr)) == - nlohmann::json({{"type", "dangerFullAccess"}}) && - !explicitAccessThread.contains("permissions") && - explicitAccessThread.value("sandbox", nlohmann::json(nullptr)) == - nlohmann::json("danger-full-access"), - "an explicit access choice replaces the active permission profile"); - - settings.setContext("individual-overrides-thread", - {{"model", "gpt-a"}, - {"approvalPolicy", "never"}, - {"personality", "friendly"}, - {"sandboxPolicy", - {{"type", "workspaceWrite"}, {"networkAccess", false}}}, - {"activePermissionProfile", {{"id", ":workspace"}}}}, - models, - {{"data", nlohmann::json::array({{{"id", ":workspace"}, - {"allowed", true}}})}}); - model->setCurrentIndex(model->findData(QStringLiteral("gpt-b"))); - approval->setCurrentIndex(approval->findData(QStringLiteral("on-request"))); - personality->setCurrentIndex( - personality->findData(QStringLiteral("pragmatic"))); - const nlohmann::json individualOverrides = settings.turnStartOptions(); - result &= expect( - permissionProfile->currentData().toString() == - QStringLiteral(":workspace") && - individualOverrides.value("model", "") == "gpt-b" && - individualOverrides.value("approvalPolicy", "") == "on-request" && - individualOverrides.value("personality", "") == "pragmatic" && - !individualOverrides.contains("sandboxPolicy") && - !individualOverrides.contains("permissions"), - "supported individual settings override retained thread values without " - "discarding its permission profile"); - - return result; -} - -bool testThreadHierarchyExpansionAndNavigation() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "hierarchy-roots", true, - {{"threads", - nlohmann::json::array({{{"id", "root-z"}, {"name", "Z root"}}, - {{"id", "root-a"}, {"name", "A root"}}})}}, - presentation::Authority::Merge)); - const auto addChild = - [&model](std::uint64_t sequence, const std::string &parent, - const std::string &child, const std::string &title) { - model.applyEvent(presentation::event( - sequence, 1, "thread.upsert", - {{"thread", {{"id", child}, {"name", title}}}}, - presentation::Authority::Merge, {{"threadId", child}})); - model.applyEvent(presentation::event(sequence + 1, 1, - "agents.activity.upsert", - {{"activity", - {{"id", "spawn-" + child}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", child}}}}, - presentation::Authority::Merge, - {{"threadId", parent}, - {"turnId", "turn-" + parent}, - {"itemId", "spawn-" + child}})); - }; - addChild(2, "root-a", "child-z", "Z child"); - addChild(4, "root-a", "child-a", "A child"); - addChild(6, "child-z", "grandchild", "Nested child"); - model.applyEvent(presentation::event( - 8, 1, "pending-request.upsert", - {{"requestId", "nested-request"}, - {"category", "userInput"}, - {"request", {{"message", "Review nested work"}}}}, - presentation::Authority::Merge, - {{"threadId", "grandchild"}, {"requestId", "nested-request"}})); - - ThreadPane pane; - pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); - std::string selectedThread; - int selections = 0; - ThreadPane::Actions actions; - actions.select = [&](const std::string &id) { - selectedThread = id; - ++selections; - refresh(pane, model, selectedThread); - }; - pane.setActions(std::move(actions)); - pane.resize(340, 620); - pane.show(); - refresh(pane, model, selectedThread); - spin(20); - - auto *list = pane.findChild(QStringLiteral("threadList")); - QListWidgetItem *rootA = threadItem(list, "root-a"); - QWidget *rootRow = list && rootA ? list->itemWidget(rootA) : nullptr; - QWidget *rootDisclosure = - rootRow ? rootRow->findChild( - QStringLiteral("threadExpansionIndicator")) - : nullptr; - bool result = expect( - list && - threadOrder(pane) == std::vector{"root-a", "root-z"} && - rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && - rootDisclosure && rootDisclosure->size() == QSize(16, 24) && - rootDisclosure->property("chevronDirection").toString() == - QStringLiteral("right") && - pane.visiblySelectedThreadId().empty(), - "thread branches default to a canonical collapsed disclosure without " - "selecting a hidden descendant"); - if (!list || !rootA) - return false; - - const auto clickExpansion = [list](QListWidgetItem *item) { - QWidget *row = item ? list->itemWidget(item) : nullptr; - QWidget *indicator = row ? row->findChild( - QStringLiteral("threadExpansionIndicator")) - : nullptr; - const QPoint position = - indicator - ? indicator->mapTo(list->viewport(), indicator->rect().center()) - : QPoint{}; - QMouseEvent press(QEvent::MouseButtonPress, position, - list->viewport()->mapToGlobal(position), Qt::LeftButton, - Qt::LeftButton, Qt::NoModifier); - QApplication::sendEvent(list->viewport(), &press); - spin(); - }; - - clickExpansion(rootA); - QListWidgetItem *childZ = threadItem(list, "child-z"); - QListWidgetItem *childA = threadItem(list, "child-a"); - rootA = threadItem(list, "root-a"); - rootRow = rootA ? list->itemWidget(rootA) : nullptr; - rootDisclosure = rootRow ? rootRow->findChild( - QStringLiteral("threadExpansionIndicator")) - : nullptr; - result &= expect( - threadOrder(pane) == std::vector{"root-a", "child-z", - "child-a", "root-z"} && - childZ && childZ->data(Qt::UserRole + 2).toInt() == 1 && childA && - rootDisclosure && - rootDisclosure->property("chevronDirection").toString() == - QStringLiteral("down") && - pane.visiblySelectedThreadId().empty() && selections == 0, - "expanding a root reveals its ordered children and updates the " - "canonical disclosure"); - if (!childZ) - return false; - - selectedThread = "grandchild"; - refresh(pane, model, selectedThread); - spin(); - QListWidgetItem *grandchild = threadItem(list, "grandchild"); - QWidget *grandchildRow = - list && grandchild ? list->itemWidget(grandchild) : nullptr; - QLabel *grandchildTitle = - grandchildRow - ? grandchildRow->findChild(QStringLiteral("threadTitle")) - : nullptr; - result &= expect( - threadOrder(pane) == std::vector{"root-a", "child-z", - "grandchild", "child-a", - "root-z"} && - grandchild && grandchild->data(Qt::UserRole + 2).toInt() == 2 && - grandchildTitle && grandchildTitle->text().startsWith("! ") && - pane.visiblySelectedThreadId() == "grandchild" && selections == 0, - "nested navigation expands only its ancestor path and restores nesting, " - "requests, and selection"); - if (!grandchild) - return false; - - childZ = threadItem(list, "child-z"); - clickExpansion(childZ); - result &= expect( - threadOrder(pane) == std::vector{"root-a", "child-z", - "child-a", "root-z"} && - pane.visiblySelectedThreadId().empty() && selections == 0, - "collapsing a nested parent hides descendants without selecting it"); - childZ = threadItem(list, "child-z"); - clickExpansion(childZ); - result &= expect( - threadOrder(pane) == std::vector{"root-a", "child-z", - "grandchild", "child-a", - "root-z"} && - pane.visiblySelectedThreadId() == "grandchild" && selections == 0, - "expanding restores arbitrary nesting and projected child selection"); - - rootA = threadItem(list, "root-a"); - clickExpansion(rootA); - result &= expect(threadOrder(pane) == - std::vector{"root-a", "root-z"} && - selections == 0, - "collapsing a root hides its complete descendant subtree"); - rootA = threadItem(list, "root-a"); - clickExpansion(rootA); - - childZ = threadItem(list, "child-z"); - list->setCurrentItem(childZ); - spin(); - const int beforeKeyboard = selections; - QKeyEvent rightToChild(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); - QApplication::sendEvent(list, &rightToChild); - spin(); - result &= - expect(selectedThread == "grandchild" && - pane.visiblySelectedThreadId() == "grandchild" && - selections == beforeKeyboard + 1, - "Right navigates from an expanded parent to its first child"); - QKeyEvent leftToParent(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); - QApplication::sendEvent(list, &leftToParent); - spin(); - result &= expect(selectedThread == "child-z" && - pane.visiblySelectedThreadId() == "child-z" && - selections == beforeKeyboard + 2, - "Left navigates from a nested child to its parent"); - QKeyEvent leftCollapse(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); - QApplication::sendEvent(list, &leftCollapse); - spin(); - result &= expect( - threadOrder(pane) == std::vector{"root-a", "child-z", - "child-a", "root-z"} && - pane.visiblySelectedThreadId() == "child-z" && - selections == beforeKeyboard + 2, - "Left collapses an expanded parent without changing selection"); - QKeyEvent rightExpand(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); - QApplication::sendEvent(list, &rightExpand); - spin(); - result &= - expect(threadOrder(pane) == - std::vector{"root-a", "child-z", "grandchild", - "child-a", "root-z"} && - pane.visiblySelectedThreadId() == "child-z" && - selections == beforeKeyboard + 2, - "Right expands a collapsed parent without changing selection"); - return result; -} - -bool testThreadAlphanumericSort() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "alpha-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "alpha"}, {"name", "Alpha"}}, - {{"id", "ten"}, {"name", "10 Release"}}, - {{"id", "two"}, {"name", "2 Review"}}, - {{"id", "one"}, {"name", "1 Setup"}}, - {{"id", "beta"}, {"name", "beta"}}})}}, - presentation::Authority::Merge)); - ThreadPane pane; - pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); - refresh(pane, model, "two"); - const std::vector order = threadOrder(pane); - const bool correct = order == std::vector( - {"one", "two", "ten", "alpha", "beta"}) && - pane.visiblySelectedThreadId() == "two"; - if (!correct) { - std::cerr << "Observed alphanumeric order:"; - for (const std::string &id : order) - std::cerr << ' ' << id; - std::cerr << "; selected=" << pane.visiblySelectedThreadId() << '\n'; - } - return expect(correct, - "Alphanumeric sorting is natural and preserves selection"); -} - -bool testThreadCreatedSort() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "created-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "old"}, {"createdAt", 10}}, - {{"id", "missing"}}, - {{"id", "new"}, {"createdAt", 30}}, - {{"id", "middle"}, {"createdAt", 20}}})}}, - presentation::Authority::Merge)); - ThreadPane pane; - pane.setSortCriterion(ThreadPane::SortCriterion::Created); - refresh(pane, model, {}); - return expect(threadOrder(pane) == std::vector( - {"new", "middle", "old", "missing"}), - "Created sorting is newest first with missing values last"); -} - -bool testThreadLastChangedSort() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "changed-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "first"}, {"updatedAt", 20}}, - {{"id", "second"}, {"updatedAt", 10}}, - {{"id", "third"}, {"updatedAt", 30}}})}}, - presentation::Authority::Merge)); - model.applyEvent(presentation::event( - 2, 1, "thread.upsert", - {{"thread", {{"id", "first"}, {"name", "Renamed"}}}}, - presentation::Authority::Merge, {{"threadId", "first"}})); - ThreadPane pane; - pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); - refresh(pane, model, {}); - return expect(threadOrder(pane) == - std::vector({"third", "first", "second"}), - "Last changed sorting uses retained updated timestamps"); -} - -bool testThreadRecencySort() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "recent-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "older"}, {"recencyAt", 10}}, - {{"id", "recent"}, {"recencyAt", 30}}, - {{"id", "middle"}, {"recencyAt", 20}}})}}, - presentation::Authority::Merge)); - ThreadPane pane; - refresh(pane, model, "older"); - return expect( - pane.currentSortCriterion() == ThreadPane::SortCriterion::Recency && - threadOrder(pane) == - std::vector({"recent", "middle", "older"}) && - pane.visiblySelectedThreadId() == "older", - "Recent is the default and preserves selection"); -} - -bool testThreadLastActivityRetention() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "activity-threads", true, - {{"threads", - nlohmann::json::array( - {{{"id", "tracked"}, {"updatedAt", 20}, {"recencyAt", 30}}, - {{"id", "updated-only"}, {"updatedAt", 25}}})}}, - presentation::Authority::Merge)); - const ThreadPresentation *thread = model.thread("tracked"); - bool result = - expect(thread && thread->lastActivityAt == 30, - "provider recency and update timestamps seed activity by maximum"); - const ThreadPresentation *updatedOnly = model.thread("updated-only"); - result &= expect(updatedOnly && updatedOnly->lastActivityAt == 25, - "provider update timestamp seeds activity without recency"); - model.noteThreadActivity("tracked", 25); - thread = model.thread("tracked"); - result &= expect(thread && thread->lastActivityAt == 30, - "older local traffic cannot move activity backwards"); - model.noteThreadActivity("tracked", 40); - model.applyEvent(presentation::event( - 2, 1, "thread.upsert", - {{"thread", {{"id", "tracked"}, {"recencyAt", 35}}}}, - presentation::Authority::Merge, {{"threadId", "tracked"}})); - thread = model.thread("tracked"); - result &= - expect(thread && thread->lastActivityAt == 40 && - thread->updatedAt == 20 && thread->recencyAt == 35, - "live protocol traffic updates activity without rewriting sort keys"); - return result; -} - -bool testPromptActivityNaturallyOrdersThreads() { - PresentationModel model; - model.applyEvent(presentation::result( - 1, 1, "threads.list", "prompt-promotion", true, - {{"threads", nlohmann::json::array({{{"id", "older"}, - {"name", "Older"}, - {"createdAt", 10}, - {"updatedAt", 10}, - {"recencyAt", 10}}, - {{"id", "recent"}, - {"name", "Recent"}, - {"createdAt", 30}, - {"updatedAt", 30}, - {"recencyAt", 30}}})}}, - presentation::Authority::Merge)); - ThreadPane pane; - refresh(pane, model, "older"); - bool result = - expect(threadOrder(pane) == std::vector({"recent", "older"}), - "provider recency initially determines thread order"); - - model.notePromptActivity("older", 40); - refresh(pane, model, "older"); - result &= - expect(threadOrder(pane) == std::vector({"older", "recent"}), - "prompt activity immediately updates natural Recent ordering"); - pane.setSortCriterion(ThreadPane::SortCriterion::LastChanged); - result &= - expect(threadOrder(pane) == std::vector({"older", "recent"}), - "the same activity updates natural Last changed ordering"); - pane.setSortCriterion(ThreadPane::SortCriterion::Created); - result &= - expect(threadOrder(pane) == std::vector({"recent", "older"}), - "prompt activity does not affect Created ordering"); - - pane.setSortCriterion(ThreadPane::SortCriterion::Recency); - model.notePromptActivity("recent", 40); - refresh(pane, model, "recent"); - result &= - expect(threadOrder(pane) == std::vector({"recent", "older"}), - "a later prompt moves its thread first without losing prior activity"); - model.applyEvent(presentation::event( - 2, 1, "thread.upsert", {{"thread", {{"id", "older"}, {"recencyAt", 20}}}}, - presentation::Authority::Merge, {{"threadId", "older"}})); - refresh(pane, model, "recent"); - const ThreadPresentation *older = model.thread("older"); - result &= - expect(older && older->recencyAt == 40 && older->updatedAt == 40 && - threadOrder(pane) == - std::vector({"recent", "older"}), - "stale provider timestamps cannot undo newer local ordering"); - return result; -} - -bool testOptimisticThreadRowLifecycle() { - PresentationModel model; - ThreadPane pane; - pane.resize(320, 520); - pane.show(); - pane.beginOptimisticThread("draft:new-thread", "Draft title", - "/workspace/draft"); - refresh(pane, model, "draft:new-thread"); - spin(); - - auto *list = pane.findChild(QStringLiteral("threadList")); - auto *animation = - pane.findChild(QStringLiteral("optimisticThreadAnimation")); - QListWidgetItem *draft = threadItem(list, "draft:new-thread"); - bool result = expect( - draft && pane.visiblySelectedThreadId() == "draft:new-thread" && - draft->data(Qt::UserRole + 6).toBool() && - !draft->data(Qt::UserRole + 7).toBool() && animation && - animation->isActive(), - "a new-thread intent immediately presents one selected animated row"); - if (!draft) - return false; - - model.applyEvent(presentation::event(1, 1, "thread.upsert", - {{"thread", - {{"id", "thread-created"}, - {"name", "Created title"}, - {"cwd", "/workspace/created"}, - {"status", "idle"}}}}, - presentation::Authority::Merge, - {{"threadId", "thread-created"}})); - pane.promoteOptimisticThread("draft:new-thread", "thread-created"); - refresh(pane, model, "thread-created"); - spin(); - QListWidgetItem *promoted = threadItem(list, "thread-created"); - result &= - expect(promoted == draft && promoted->data(Qt::UserRole + 6).toBool() && - pane.visiblySelectedThreadId() == "thread-created" && - animation->isActive(), - "thread/start rekeys the existing row without replacing its item " - "or animation"); - - pane.beginOptimisticThread("draft:second", "Second draft", - "/workspace/second"); - refresh(pane, model, "draft:second"); - spin(); - QListWidgetItem *second = threadItem(list, "draft:second"); - result &= expect(second && threadItem(list, "thread-created") == draft && - animation->isActive(), - "a second draft can animate while the first created thread " - "still awaits acknowledgment"); - - pane.confirmOptimisticThread("thread-created"); - refresh(pane, model, "draft:second"); - spin(); - result &= expect(threadItem(list, "thread-created") == draft && - !draft->data(Qt::UserRole + 6).toBool() && - !pane.isOptimisticThread("thread-created") && - threadItem(list, "draft:second") == second && - second->data(Qt::UserRole + 6).toBool() && - animation->isActive(), - "acknowledging one new thread canonicalizes only that row"); - - pane.failOptimisticThread("draft:second"); - refresh(pane, model, "draft:second"); - spin(); - result &= expect( - threadItem(list, "draft:second") == second && - second->data(Qt::UserRole + 7).toBool() && !animation->isActive(), - "a failed new thread retains its row and stops only its animation"); - return result; -} - -bool testThreadRowReorderOwnership() { - PresentationModel model; - model.applyEvent(presentation::event( - 1, 1, "thread.upsert", {{"thread", {{"id", "thread-a"}, {"name", "A"}}}}, - presentation::Authority::Merge, {{"threadId", "thread-a"}})); - model.applyEvent(presentation::event( - 2, 1, "thread.upsert", {{"thread", {{"id", "thread-b"}, {"name", "B"}}}}, - presentation::Authority::Merge, {{"threadId", "thread-b"}})); - - ThreadPane pane; - int selectedByUser = 0; - ThreadPane::Actions actions; - actions.select = [&](const std::string &) { ++selectedByUser; }; - pane.setActions(std::move(actions)); - pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); - pane.resize(320, 500); - pane.show(); - refresh(pane, model, "thread-a"); - spin(20); - auto *list = pane.findChild(QStringLiteral("threadList")); - QListWidgetItem *threadA = nullptr; - QListWidgetItem *threadB = nullptr; - if (list) { - for (int row = 0; row < list->count(); ++row) { - if (list->item(row)->data(Qt::UserRole).toString() == - QStringLiteral("thread-a")) { - threadA = list->item(row); - } else if (list->item(row)->data(Qt::UserRole).toString() == - QStringLiteral("thread-b")) { - threadB = list->item(row); - } - } - } - bool result = expect(list && threadA && threadB, - "the stable thread row exists before list reordering"); - if (!list || !threadA || !threadB) - return false; - const QPoint rightClickPosition = list->visualItemRect(threadB).center(); - QMouseEvent rightClick(QEvent::MouseButtonPress, rightClickPosition, - list->viewport()->mapToGlobal(rightClickPosition), - Qt::RightButton, Qt::RightButton, Qt::NoModifier); - QApplication::sendEvent(list->viewport(), &rightClick); - QContextMenuEvent contextMenuEvent( - QContextMenuEvent::Mouse, rightClickPosition, - list->viewport()->mapToGlobal(rightClickPosition)); - QApplication::sendEvent(list->viewport(), &contextMenuEvent); - result &= expect(pane.visiblySelectedThreadId() == "thread-a" && - selectedByUser == 0 && - threadB->data(Qt::UserRole + 1).toBool(), - "right-click highlights row actions without selecting a " - "thread"); - if (QWidget *popup = QApplication::activePopupWidget()) - popup->close(); - spin(); - result &= expect(!threadB->data(Qt::UserRole + 1).toBool(), - "closing row actions clears the native context hover"); - QPointer stableThreadARow = list->itemWidget(threadA); - QPointer originalRow = list->itemWidget(threadB); - - model.applyEvent(presentation::event( - 3, 1, "thread.status.changed", {{"status", "completed"}}, - presentation::Authority::Merge, {{"threadId", "thread-b"}})); - refresh(pane, model, "thread-a"); - result &= expect(stableThreadARow == list->itemWidget(threadA) && - originalRow == list->itemWidget(threadB), - "content-only refreshes preserve thread row widgets"); - - model.applyEvent(presentation::result( - 4, 1, "threads.list", "reordered-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "thread-a"}, {"name", "Z"}}, - {{"id", "thread-b"}, {"name", "B"}}})}}, - presentation::Authority::Replace)); - refresh(pane, model, "thread-a"); - QPointer movedRow = list->itemWidget(threadB); - result &= expect(originalRow && movedRow && originalRow != movedRow, - "moving an item never reattaches its deferred-delete row"); - if (!originalRow || !movedRow || originalRow == movedRow) - return false; - - QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); - spin(20); - result &= expect(originalRow.isNull() && movedRow && - list->itemWidget(threadB) == movedRow, - "deferred deletion cannot invalidate the moved thread row"); - list->setCurrentItem(threadA); - list->viewport()->repaint(); - spin(20); - result &= expect(pane.visiblySelectedThreadId() == "thread-a", - "the reordered row remains selectable after repaint"); - return result; -} - -bool testNestedCommandScrollOwnership() { - MiddleRegionWidget region; - region.resize(1500, 820); - region.show(); - ConversationSnapshot snapshot = longConversation("command-thread"); - QString output; - for (int line = 0; line < 100; ++line) - output += QStringLiteral("command output line %1\n").arg(line); - QString command; - for (int line = 0; line < 30; ++line) - command += QStringLiteral("command argument line %1\n").arg(line); - snapshot.sections.back().cards.push_back( - {AuthoritativeItemKey{"command-thread", "turn-2", "command"}, - CardKind::CommandExecution, "command-thread", "turn-2", "command", - CommandExecutionData{ - utf8(command), utf8(output), "inProgress", {}, std::nullopt}}); - region.conversation().reconcile(snapshot); - spin(30); - - CommandOutputView *commandOutput = nullptr; - ContentSizedTextView *commandText = nullptr; - for (QWidget *widget : region.findChildren()) - if (auto *candidate = dynamic_cast(widget)) { - commandOutput = candidate; - } else if (auto *candidate = dynamic_cast(widget); - candidate && - candidate->objectName() == QStringLiteral("commandTextView")) { - commandText = candidate; - } - bool result = expect( - commandOutput && commandOutput->verticalScrollBar()->maximum() > 0 && - commandText && commandText->verticalScrollBar()->maximum() > 0, - "long command and output own real nested scrollbars"); - if (!commandOutput || !commandText) - return false; - - auto verifyBoundaryOwnership = [&](ContentSizedTextView *view, - const char *description) { - QScrollBar *inner = view->verticalScrollBar(); - QScrollBar *outer = region.conversation().verticalScrollBar(); - - inner->setValue(inner->maximum() / 2); - outer->setValue(outer->maximum()); - spin(); - QWheelEvent begin = wheelFor(view, 0, Qt::ScrollBegin); - region.routeScrollEvent(view, &begin); - QWheelEvent firstUpdate = wheelFor(view, 120, Qt::ScrollUpdate); - bool passed = - expect(!region.routeScrollEvent(view, &firstUpdate), description); - - inner->setValue(inner->minimum()); - const int outerBeforeOverscroll = outer->value(); - QWheelEvent sameGesture = wheelFor(view, 120, Qt::ScrollUpdate); - passed &= - expect(!region.routeScrollEvent(view, &sameGesture) && - outer->value() == outerBeforeOverscroll, - "a gesture reaching the top cannot leak to the conversation"); - QWheelEvent end = wheelFor(view, 0, Qt::ScrollEnd); - region.routeScrollEvent(view, &end); - - QWheelEvent freshAtTop = wheelFor(view, 120, Qt::ScrollBegin); - passed &= - expect(region.routeScrollEvent(view, &freshAtTop) && - outer->value() < outerBeforeOverscroll, - "a fresh outward gesture at the top scrolls the conversation"); - QWheelEvent topEnd = wheelFor(view, 0, Qt::ScrollEnd); - region.routeScrollEvent(view, &topEnd); - - inner->setValue(inner->maximum() / 2); - outer->setValue(outer->minimum()); - QWheelEvent downBegin = wheelFor(view, -120, Qt::ScrollBegin); - passed &= expect(!region.routeScrollEvent(view, &downBegin), description); - inner->setValue(inner->maximum()); - const int outerBeforeBottomOverscroll = outer->value(); - QWheelEvent sameDownGesture = wheelFor(view, -120, Qt::ScrollUpdate); - passed &= - expect(!region.routeScrollEvent(view, &sameDownGesture) && - outer->value() == outerBeforeBottomOverscroll, - "a gesture reaching the bottom cannot leak to the conversation"); - QWheelEvent downEnd = wheelFor(view, 0, Qt::ScrollEnd); - region.routeScrollEvent(view, &downEnd); - - QWheelEvent freshAtBottom = wheelFor(view, -120, Qt::ScrollBegin); - passed &= expect( - region.routeScrollEvent(view, &freshAtBottom) && - outer->value() > outerBeforeBottomOverscroll, - "a fresh outward gesture at the bottom scrolls the conversation"); - QWheelEvent bottomEnd = wheelFor(view, 0, Qt::ScrollEnd); - region.routeScrollEvent(view, &bottomEnd); - - inner->setValue(inner->maximum() / 2); - outer->setValue(outer->maximum()); - const int outerBeforeMouseWheel = outer->value(); - QWheelEvent innerNotch = wheelFor(view, 120, Qt::NoScrollPhase); - passed &= expect(!region.routeScrollEvent(view, &innerNotch) && - outer->value() == outerBeforeMouseWheel, - "a mouse-wheel notch scrolls a movable nested view"); - inner->setValue(inner->minimum()); - QWheelEvent boundaryNotch = wheelFor(view, 120, Qt::NoScrollPhase); - passed &= - expect(region.routeScrollEvent(view, &boundaryNotch) && - outer->value() < outerBeforeMouseWheel, - "a mouse-wheel notch at the boundary scrolls the conversation"); - return passed; - }; - - result &= verifyBoundaryOwnership(commandText, - "command text owns a scrollable gesture"); - result &= verifyBoundaryOwnership(commandOutput, - "command output owns a scrollable gesture"); - return result; -} - -bool testInfoViewerLayout() { - InspectorPane inspector; - inspector.resize(420, 700); - inspector.show(); - PresentationModel model; - refresh(inspector, model, {}); - inspector.tabs()->setCurrentIndex(4); - auto *infoStack = - inspector.findChild(QStringLiteral("infoStack")); - auto *protocolChoice = - inspector.findChild(QStringLiteral("protocolInfoChoice")); - auto *protocol = - inspector.findChild(QStringLiteral("protocolInfoLog")); - auto *state = - inspector.findChild(QStringLiteral("stateInfoView")); - auto *statistics = - inspector.findChild(QStringLiteral("protocolInfoStats")); - bool result = - expect(infoStack && protocolChoice && protocol && state && statistics, - "Info exposes State and Protocol through choice navigation"); - if (!infoStack || !protocolChoice || !protocol || !state || !statistics) - return false; - const auto inspectorScrolls = inspector.findChildren(); - result &= expect( - inspectorScrolls.size() == 3 && - std::ranges::all_of( - inspectorScrolls, - [](QScrollArea *scroll) { - return scroll && - scroll->property("kind") == "inspectorScroll" && - scroll->verticalScrollBarPolicy() == - Qt::ScrollBarAsNeeded && - scroll->verticalScrollBar() - ->property("kind") - .toString() - .isEmpty() && - scroll->verticalScrollBar()->styleSheet().isEmpty(); - }), - "Plan, Agents, and Requests inherit the canonical application " - "scrollbar"); - protocolChoice->click(); - inspector.appendProtocolFrame( - {{"kind", "event"}, - {"type", "conversation.item.upsert"}, - {"sequence", 1}, - {"generation", 1}, - {"authority", "app-server"}, - {"scope", {{"threadId", "thread"}, {"itemId", "item"}}}}); - inspector.appendProtocolFrame( - {{"kind", "result"}, - {"action", "thread.read"}, - {"sequence", 2}, - {"generation", 1}, - {"authority", "app-server"}, - {"ok", false}, - {"error", {{"message", "thread hydration failed"}}}, - {"scope", {{"threadId", "thread"}}}}); - for (int sequence = 3; sequence <= 90; ++sequence) { - inspector.appendProtocolFrame( - {{"kind", "event"}, - {"type", - QStringLiteral("protocol.test.%1").arg(sequence).toStdString()}, - {"sequence", sequence}, - {"generation", 1}, - {"authority", "app-server"}, - {"scope", {{"threadId", "thread"}}}}); - } - refresh(inspector, model, {}); - spin(20); - result &= - expect(protocol->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded && - state->verticalScrollBarPolicy() == Qt::ScrollBarAsNeeded, - "both Info viewers use the common as-needed scrollbar policy"); - result &= expect( - protocol->verticalScrollBar()->property("kind").toString().isEmpty() && - state->verticalScrollBar()->property("kind").toString().isEmpty() && - protocol->verticalScrollBar()->styleSheet().isEmpty() && - state->verticalScrollBar()->styleSheet().isEmpty(), - "both Info viewer scrollbars inherit the shared visual style"); - result &= expect(protocol->toPlainText().contains( - QStringLiteral("thread hydration failed")), - "failed protocol results retain their error detail"); - QScrollBar *protocolScroll = protocol->verticalScrollBar(); - result &= expect(protocolScroll->maximum() > 0 && - protocolScroll->value() == protocolScroll->maximum(), - "Protocol follows new frames while already at the tail"); - protocolScroll->setValue(protocolScroll->maximum() / 3); - spin(); - const int pausedValue = protocolScroll->value(); - inspector.appendProtocolFrame({{"kind", "event"}, - {"type", "protocol.test.visible-append"}, - {"sequence", 91}, - {"generation", 1}, - {"authority", "app-server"}}); - refresh(inspector, model, {}); - spin(20); - result &= - expect(protocolScroll->value() == pausedValue, - "a visible Protocol append preserves a user-paused position"); - infoStack->setCurrentIndex(0); - inspector.appendProtocolFrame({{"kind", "event"}, - {"type", "protocol.test.hidden-append"}, - {"sequence", 92}, - {"generation", 1}, - {"authority", "app-server"}}); - protocolChoice->click(); - spin(20); - result &= - expect(protocolScroll->value() == pausedValue, - "Protocol refresh preserves its paused position across tabs"); - protocolScroll->setValue(protocolScroll->maximum()); - inspector.appendProtocolFrame({{"kind", "event"}, - {"type", "protocol.test.following-append"}, - {"sequence", 93}, - {"generation", 1}, - {"authority", "app-server"}}); - spin(20); - result &= - expect(protocolScroll->value() == protocolScroll->maximum(), - "Protocol continues following when an append starts at the tail"); - result &= - expect(!statistics->text().isEmpty() && - statistics->geometry().top() >= protocol->geometry().bottom(), - "Protocol statistics are laid out below the expanding log"); - return result; -} - -bool testInspectorDetailParity() { - const QString previousStyleSheet = qApp->styleSheet(); - qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); - PresentationModel model; - model.applyEvent(presentation::event( - 1, 1, "thread.upsert", - {{"thread", {{"id", "owner-thread"}, {"name", "Original title"}}}}, - presentation::Authority::Merge, {{"threadId", "owner-thread"}})); - model.applyEvent(presentation::event( - 2, 1, "agents.activity.upsert", - {{"activity", - {{"id", "agent-one"}, - {"type", "subAgentActivity"}, - {"status", "inProgress"}, - {"agentPath", "/root/lifecycle_review"}, - {"agentThreadId", "child-thread"}, - {"resultText", - "No blocking Inspector findings.\n\n" - "* Typed snapshots cover all rendered Plan, Agent, and Request " - "fields, with default equality and optional first-render state.\n" - "* Rendering now uses those typed projections directly.\n" - "* Request thread titles participate in equality, so rename-only " - "changes invalidate correctly.\n" - "* optional size correctly distinguishes absent questions from a " - "visible zero questions.\n" - "* The added Application Layout regression is minimal and well " - "targeted: render the original title, rename without changing the " - "request, refresh, then require the new label and reject the old " - "one.\n" - "* Application Layout tests pass offscreen.\n\n" - "No files were edited."}, - {"senderThreadId", "sender-thread"}, - {"receiverThreadIds", - nlohmann::json::array({"receiver-one", "receiver-two"})}}}}, - presentation::Authority::Merge, - {{"threadId", "owner-thread"}, - {"turnId", "turn-one"}, - {"itemId", "agent-one"}})); - model.applyEvent(presentation::event( - 3, 1, "agents.activity.upsert", - {{"activity", - {{"id", "agent-two"}, - {"type", "subAgentActivity"}, - {"status", "completed"}, - {"agentPath", "/root/hierarchy_ui_review"}, - {"agentThreadId", "child-thread-two"}, - {"resultText", - "No blocking Git snapshot issues found.\n\n" - "* Add defaulted equality to the file and snapshot records.\n" - "* Replace both retained snapshot hashes with optional typed " - "snapshots so an initial empty result still renders.\n" - "* Preserve the current snapshot fallback and repository context " - "behavior."}}}}, - presentation::Authority::Merge, - {{"threadId", "owner-thread"}, - {"turnId", "turn-one"}, - {"itemId", "agent-two"}})); - model.applyEvent(presentation::event( - 4, 1, "pending-request.upsert", - {{"requestId", "request-one"}, - {"category", "userInput"}, - {"request", - {{"message", "Choose an option"}, - {"questions", nlohmann::json::array({1, 2, 3})}}}}, - presentation::Authority::Merge, - {{"threadId", "owner-thread"}, {"requestId", "request-one"}})); - - InspectorPane inspector; - inspector.resize(420, 700); - inspector.show(); - refresh(inspector, model, "owner-thread"); - inspector.tabs()->setCurrentIndex(1); - spin(20); - bool result = expect( - hasLabelContaining( - inspector, - QStringLiteral("thread child-thread | sender sender-thread | " - "receivers receiver-one, receiver-two")), - "Agents show child, sender, and receiver thread identities"); - QLabel *agentStatus = nullptr; - for (QLabel *label : inspector.findChildren()) { - if (label->text() == QStringLiteral("running")) { - agentStatus = label; - break; - } - } - result &= expect(agentStatus && agentStatus->property("tone") == "active", - "running agent status uses the canonical active tone"); - auto *agentResult = - inspector.findChild(QStringLiteral("agentResult")); - QWidget *agentContent = agentResult ? agentResult->parentWidget() : nullptr; - auto *agentFrame = agentContent - ? qobject_cast(agentContent->parentWidget()) - : nullptr; - auto *agentTitle = - agentFrame ? agentFrame->findChild(QStringLiteral("agentTitle")) - : nullptr; - auto *agentName = - agentFrame ? agentFrame->findChild(QStringLiteral("agentName")) - : nullptr; - auto *agentCopy = agentFrame - ? agentFrame->findChild( - QStringLiteral("agentCopyButton")) - : nullptr; - auto *agentDisclosure = - agentFrame ? agentFrame->findChild( - QStringLiteral("agentDisclosureButton")) - : nullptr; - result &= expect(agentContent && !agentContent->isVisible() && - agentDisclosure && - agentDisclosure->accessibleName() == - QStringLiteral("Expand agent"), - "agent cards initially retain their content collapsed"); - if (agentDisclosure) { - agentDisclosure->click(); - spin(); - } - if (agentCopy) { - agentCopy->click(); - spin(); - } - result &= expect( - agentCopy && QApplication::clipboard()->text().contains( - QStringLiteral("No blocking Inspector findings.")), - "agent copy actions retain the complete agent content"); - const int statusBottom = - agentStatus && agentFrame - ? agentStatus->mapTo(agentFrame, QPoint()).y() + agentStatus->height() - : 0; - const int headingBottom = std::max( - {agentTitle && agentFrame - ? agentTitle->mapTo(agentFrame, QPoint()).y() + agentTitle->height() - : 0, - agentName && agentFrame - ? agentName->mapTo(agentFrame, QPoint()).y() + agentName->height() - : 0, - statusBottom, - agentCopy && agentFrame - ? agentCopy->mapTo(agentFrame, QPoint()).y() + - agentCopy->height() - : 0, - agentDisclosure && agentFrame - ? agentDisclosure->mapTo(agentFrame, QPoint()).y() + - agentDisclosure->height() - : 0}); - const int resultTop = agentResult && agentFrame - ? agentResult->mapTo(agentFrame, QPoint()).y() - : 0; - const int resultHeightForWidth = - agentResult ? agentResult->heightForWidth(agentResult->width()) : -1; - result &= expect(agentStatus && agentStatus->width() > 0 && - !agentStatus->visibleRegion().isEmpty(), - "agent status occupies the card heading instead of " - "leaving an invisible gap"); - result &= expect( - agentTitle && agentTitle->text() == QStringLiteral("Agent") && - agentTitle->property("kind").toString() == QStringLiteral("title") && - agentTitle->contentsMargins().bottom() == 0 && agentName && - agentName->text() == QStringLiteral("lifecycle_review") && - agentName->property("kind").toString() == QStringLiteral("code") && - agentName->sizePolicy().horizontalPolicy() == QSizePolicy::Ignored && - agentName->toolTip() == QStringLiteral("/root/lifecycle_review") && - agentStatus && - agentStatus->geometry().left() > agentName->geometry().left() && - agentCopy && agentDisclosure && - agentCopy->geometry().left() > agentStatus->geometry().left() && - agentDisclosure->geometry().left() > agentCopy->geometry().left() && - agentDisclosure->accessibleName() == - QStringLiteral("Collapse agent") && - !hasLabelContaining(inspector, - QStringLiteral("/root/lifecycle_review")), - "agent cards show identity then status, copy, and disclosure actions " - "while retaining the full path as a tooltip"); - const auto baseline = [agentFrame](QLabel *label) { - return label && agentFrame - ? label->mapTo(agentFrame, QPoint()).y() + - label->contentsMargins().top() + label->fontMetrics().ascent() - : -1000; - }; - result &= expect( - std::abs(baseline(agentTitle) - baseline(agentName)) <= 1 && - std::abs(baseline(agentTitle) - baseline(agentStatus)) <= 1 && - agentFrame && agentFrame->parentWidget() && - agentFrame->geometry().right() <= agentFrame->parentWidget()->width(), - "agent heading text shares one baseline and the card remains within the " - "available Inspector width"); - result &= expect( - agentFrame && agentFrame->layout() && agentResult && - resultTop - headingBottom <= agentFrame->layout()->spacing() && - agentResult->alignment().testFlag(Qt::AlignTop) && - resultHeightForWidth >= 0 && - agentResult->height() >= resultHeightForWidth - 1 && - agentResult->height() <= resultHeightForWidth + 1, - "long agent Markdown follows visible metadata without surplus height"); - const auto hasNativeBlackFrame = [](QScrollBar *scrollBar) { - if (!scrollBar) - return true; - const QImage rendered = scrollBar->grab().toImage(); - for (int y = 0; y < rendered.height(); ++y) { - for (int x = 0; x < rendered.width(); ++x) { - const QColor pixel = rendered.pixelColor(x, y); - if (pixel.red() < 16 && pixel.green() < 16 && pixel.blue() < 16) - return true; - } - } - return false; - }; - auto *agentsScroll = qobject_cast(inspector.tabs()->widget(1)); - QScrollBar *agentsScrollBar = - agentsScroll ? agentsScroll->verticalScrollBar() : nullptr; - if (agentsScroll) - agentsScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); - spin(20); - result &= expect(agentsScrollBar && agentsScrollBar->isVisible() && - agentsScrollBar->width() == 8 && - !hasNativeBlackFrame(agentsScrollBar), - "a visible Inspector scrollbar renders with the canonical " - "frameless style"); - auto *compactDiff = - inspector.findChild(QStringLiteral("codexDiffText")); - QStringList diffLines; - for (int line = 0; line < 80; ++line) - diffLines << QStringLiteral("+%1 a deliberately long changed line for " - "scrollbar verification") - .arg(line); - inspector.tabs()->setCurrentIndex(2); - spin(20); - if (compactDiff) - compactDiff->setPlainText(diffLines.join(QLatin1Char('\n'))); - spin(20); - QScrollBar *diffVertical = - compactDiff ? compactDiff->verticalScrollBar() : nullptr; - QScrollBar *diffHorizontal = - compactDiff ? compactDiff->horizontalScrollBar() : nullptr; - result &= expect( - diffVertical && diffHorizontal && diffVertical->isVisible() && - diffHorizontal->isVisible() && diffVertical->width() == 8 && - diffHorizontal->height() == 8 && !hasNativeBlackFrame(diffVertical) && - !hasNativeBlackFrame(diffHorizontal), - "Changes preview scrollbars retain overview rendering without native " - "frames"); - inspector.tabs()->setCurrentIndex(3); - spin(20); - result &= - expect(hasLabelContaining(inspector, QStringLiteral("User input")) && - hasLabelContaining(inspector, - QStringLiteral("thread Original title")) && - hasLabelContaining(inspector, QStringLiteral("3 questions")), - "Requests show their thread title and retained question count"); - model.applyEvent(presentation::event( - 5, 1, "thread.name.changed", {{"name", "Renamed title"}}, - presentation::Authority::Replace, {{"threadId", "owner-thread"}})); - refresh(inspector, model, "owner-thread"); - spin(20); - result &= expect( - hasLabelContaining(inspector, QStringLiteral("thread Renamed title")) && - !hasLabelContaining(inspector, - QStringLiteral("thread Original title")), - "Requests update their thread label after a thread rename"); - QFrame *requestFrame = nullptr; - for (QFrame *frame : inspector.findChildren()) { - if (frame->property("tone") == "warning") { - requestFrame = frame; - break; - } - } - QPushButton *rejectButton = nullptr; - QPushButton *reviewButton = nullptr; - for (QPushButton *button : inspector.findChildren()) { - if (button->text() == QStringLiteral("Reject")) - rejectButton = button; - else if (button->text() == QStringLiteral("Review")) - reviewButton = button; - } - result &= expect( - requestFrame && rejectButton && reviewButton && - rejectButton->property("kind") == "destructive" && - reviewButton->property("kind") == "request", - "complex pending requests use warning surfaces and a review action"); - model.applyEvent(presentation::event( - 6, 1, "pending-request.upsert", - {{"requestId", "request-two"}, - {"category", "command-approval"}, - {"request", - {{"command", "gh auth status"}, - {"reason", "Verify GitHub authentication"}, - {"cwd", "/home/voc/projects/drafts"}}}}, - presentation::Authority::Merge, - {{"threadId", "owner-thread"}, {"requestId", "request-two"}})); - refresh(inspector, model, "owner-thread"); - spin(20); - QPushButton *acceptButton = nullptr; - for (QPushButton *button : inspector.findChildren()) { - if (button->text() == QStringLiteral("Accept")) { - acceptButton = button; - break; - } - } - result &= expect( - acceptButton && - acceptButton->property("kind").toString() == - QStringLiteral("request") && - hasLabelContaining(inspector, - QStringLiteral("Command: gh auth status")) && - hasLabelContaining( - inspector, - QStringLiteral("Reason: Verify GitHub authentication")), - "simple approval requests show decision details and direct accept"); - qApp->setStyleSheet(previousStyleSheet); - return result; -} - -bool testTerminalPlanStatusReconciliation() { - PresentationModel model; - model.applyEvent(presentation::event( - 1, 1, "thread.upsert", - {{"thread", {{"id", "plan-thread"}, {"status", "active"}}}}, - presentation::Authority::Merge, {{"threadId", "plan-thread"}})); - model.applyEvent(presentation::event( - 2, 1, "turn.upsert", - {{"turn", {{"id", "plan-turn"}, {"status", "inProgress"}}}}, - presentation::Authority::Merge, - {{"threadId", "plan-thread"}, {"turnId", "plan-turn"}})); - model.applyEvent(presentation::event( - 3, 1, "plan.replaced", - {{"explanation", "Lifecycle [plan](https://example.com)"}, - {"steps", nlohmann::json::array( - {{{"step", "Active step"}, {"status", "inProgress"}}, - {{"step", "Pending step"}, {"status", "pending"}}})}}, - presentation::Authority::Replace, - {{"threadId", "plan-thread"}, {"turnId", "plan-turn"}})); - - InspectorPane inspector; - refresh(inspector, model, "plan-thread"); - const auto hasExactLabel = [&inspector](const QString &value) { - return std::ranges::any_of( - inspector.findChildren(), - [&value](const QLabel *label) { return label->text() == value; }); - }; - bool result = expect(hasExactLabel(QStringLiteral("running")) && - hasExactLabel(QStringLiteral("pending")), - "active plans preserve running and pending statuses"); - const auto markdownLabels = inspector.findChildren(); - result &= - expect(std::ranges::any_of( - markdownLabels, - [](const QLabel *label) { - return label->textFormat() == Qt::RichText && - label->text().contains(QStringLiteral("href=")) && - label->textInteractionFlags().testFlag( - Qt::LinksAccessibleByKeyboard); - }), - "Inspector Markdown links are keyboard accessible"); - - const auto setThreadStatus = [&](std::uint64_t sequence, const char *status) { - model.applyEvent(presentation::event( - sequence, 1, "thread.upsert", - {{"thread", {{"id", "plan-thread"}, {"status", status}}}}, - presentation::Authority::Merge, {{"threadId", "plan-thread"}})); - refresh(inspector, model, "plan-thread"); - }; - setThreadStatus(4, "completed"); - result &= expect(!hasExactLabel(QStringLiteral("running")) && - hasExactLabel(QStringLiteral("completed")) && - hasExactLabel(QStringLiteral("pending")), - "a terminal thread reconciles stale running to completed " - "without changing pending"); - setThreadStatus(5, "failed"); - result &= expect(hasExactLabel(QStringLiteral("failed")) && - hasExactLabel(QStringLiteral("pending")), - "a failed thread reconciles stale running to failed"); - setThreadStatus(6, "interrupted"); - result &= - expect(hasExactLabel(QStringLiteral("interrupted")) && - hasExactLabel(QStringLiteral("pending")), - "an interrupted thread reconciles stale running to interrupted"); - return result; -} - -bool testGitDiffScopes() { - QTemporaryDir repositoryDirectory; - if (!expect(repositoryDirectory.isValid(), - "Git diff test creates a temporary workspace")) - return false; - GitDiffProvider provider; - git_repository *repository = nullptr; - if (!expect(git_repository_init( - &repository, repositoryDirectory.path().toUtf8().constData(), - 0) == 0, - "Git diff test initializes an in-process repository")) - return false; - QFile file(repositoryDirectory.filePath(QStringLiteral("notes.txt"))); - if (!expect(file.open(QIODevice::WriteOnly | QIODevice::Truncate), - "Git diff test creates an untracked file")) { - git_repository_free(repository); - return false; - } - file.write("first line\nsecond line\n"); - file.close(); - - GitDiffSnapshot received; - bool ready = false; - QObject::connect(&provider, &GitDiffProvider::snapshotReady, - [&received, &ready](const GitDiffSnapshot &snapshot) { - received = snapshot; - ready = true; - }); - const auto request = - [&](const QString &workspace, const QStringList &directories, - const QStringList &paths, const QString &selectedRepository, - GitDiffScope scope, bool includeHiddenRepositories = false) { - ready = false; - provider.request(workspace, directories, paths, selectedRepository, - includeHiddenRepositories, scope, - GitDiffContext::Compact); - QElapsedTimer timeout; - timeout.start(); - while (!ready && timeout.elapsed() < 3000) - spin(1); - return ready; - }; - - bool result = expect( - request(repositoryDirectory.path(), {}, {}, {}, GitDiffScope::Unstaged) && - received.repository && received.error.isEmpty() && - received.files.size() == 1 && - received.files.front().status == QStringLiteral("Untracked") && - received.files.front().patch.contains(QStringLiteral("+first line")), - "Unstaged scope includes untracked file content"); - - git_index *index = nullptr; - if (git_repository_index(&index, repository) == 0) { - git_index_add_bypath(index, "notes.txt"); - git_index_write(index); - git_index_free(index); - } - result &= expect( - request(repositoryDirectory.path(), {}, {}, {}, GitDiffScope::Staged) && - received.files.size() == 1 && - received.files.front().status == QStringLiteral("Added"), - "Staged scope compares the index with HEAD"); - result &= expect( - request(repositoryDirectory.path(), {}, {}, {}, - GitDiffScope::Uncommitted) && - received.files.size() == 1 && - received.files.front().patch.contains(QStringLiteral("+second line")), - "Since-HEAD scope combines index and worktree state"); - - QTemporaryDir ordinaryDirectory; - result &= - expect(ordinaryDirectory.isValid() && - request(ordinaryDirectory.path(), {}, {}, {}, - GitDiffScope::Unstaged) && - !received.repository && - received.error.contains(QStringLiteral("Git repository")), - "ordinary folders expose an explicit non-repository state"); - - QTemporaryDir multiWorkspace; - const QString firstRoot = multiWorkspace.filePath(QStringLiteral("first")); - const QString secondRoot = multiWorkspace.filePath(QStringLiteral("second")); - const QString hiddenRoot = - multiWorkspace.filePath(QStringLiteral(".hidden/repository")); - git_repository *firstRepository = nullptr; - git_repository *secondRepository = nullptr; - git_repository *hiddenRepository = nullptr; - git_repository_init(&firstRepository, firstRoot.toUtf8().constData(), 0); - git_repository_init(&secondRepository, secondRoot.toUtf8().constData(), 0); - QDir().mkpath(hiddenRoot); - git_repository_init(&hiddenRepository, hiddenRoot.toUtf8().constData(), 0); - for (const QString &root : {firstRoot, secondRoot, hiddenRoot}) { - QFile shared(QDir(root).filePath(QStringLiteral("shared.txt"))); - if (shared.open(QIODevice::WriteOnly | QIODevice::Truncate)) - shared.write("shared path\n"); - } - QFile firstOnly(QDir(firstRoot).filePath(QStringLiteral("first-only.txt"))); - if (firstOnly.open(QIODevice::WriteOnly | QIODevice::Truncate)) - firstOnly.write("first repository\n"); - firstOnly.close(); - result &= expect( - request(multiWorkspace.path(), - {firstRoot, firstRoot, hiddenRoot, secondRoot}, - {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged) && - received.repositoryRoots.size() == 2 && received.files.size() == 3 && - !received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), - "duplicate directories are deduplicated, hidden roots are excluded, and " - "ambiguous paths retain visible matches"); - result &= expect( - request(multiWorkspace.path(), {firstRoot, secondRoot, hiddenRoot}, - {QStringLiteral("shared.txt")}, {}, GitDiffScope::Unstaged, - true) && - received.repositoryRoots.size() == 3 && received.files.size() == 4 && - received.repositoryRoots.contains(QDir::cleanPath(hiddenRoot)), - "the explicit hidden-repository option includes hidden candidates"); - result &= expect( - request(multiWorkspace.path(), {firstRoot, secondRoot}, - {QStringLiteral("shared.txt")}, firstRoot, - GitDiffScope::Unstaged) && - received.repositoryRoots.size() == 2 && received.files.size() == 2 && - received.files.front().repositoryRoot == QDir::cleanPath(firstRoot), - "repository selection filters files without losing the candidate set"); - result &= expect( - request(multiWorkspace.path(), {firstRoot, secondRoot}, - {QStringLiteral("first-only.txt")}, {}, GitDiffScope::Unstaged) && - received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && - received.files.size() == 2, - "a unique relative path resolves one repository and includes all of its " - "changes"); - result &= - expect(request(multiWorkspace.path(), {firstRoot, secondRoot}, - {QDir(secondRoot).filePath(QStringLiteral("shared.txt"))}, - {}, GitDiffScope::Unstaged) && - received.repositoryRoots == - QStringList{QDir::cleanPath(secondRoot)} && - received.files.size() == 1, - "an absolute path resolves only its owning repository"); - result &= expect( - request(multiWorkspace.path(), {firstRoot, secondRoot}, - {QStringLiteral("not-applied-yet.txt")}, - QStringLiteral("/stale/repository"), GitDiffScope::Unstaged) && - received.repositoryRoots.size() == 2 && received.files.size() == 3, - "an unmatched early path and stale selection safely fall back to all " - "candidate repositories"); - const QString priorityPath = QStringLiteral("priority.txt"); - QFile firstPriority(QDir(firstRoot).filePath(priorityPath)); - QFile secondPriority(QDir(secondRoot).filePath(priorityPath)); - const bool priorityFiles = - firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && - firstPriority.write("baseline\n") > 0; - firstPriority.close(); - const bool secondPriorityFile = - secondPriority.open(QIODevice::WriteOnly | QIODevice::Truncate) && - secondPriority.write("baseline\n") > 0; - secondPriority.close(); - const bool priorityCommitted = priorityFiles && secondPriorityFile && - commitPath(firstRepository, "priority.txt") && - commitPath(secondRepository, "priority.txt"); - if (firstPriority.open(QIODevice::WriteOnly | QIODevice::Truncate)) - firstPriority.write("changed\n"); - firstPriority.close(); - result &= expect( - priorityCommitted && - request(multiWorkspace.path(), {firstRoot, secondRoot}, - {priorityPath}, {}, GitDiffScope::Unstaged) && - received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && - received.files.size() == 3 && - std::any_of(received.files.begin(), received.files.end(), - [&](const GitDiffFile &file) { - return file.path == priorityPath && - file.status == QStringLiteral("Modified"); - }), - "a currently changed path is preferred over the same clean tracked path"); - const QString secondCleanPath = QStringLiteral("second-clean.txt"); - QFile secondClean(QDir(secondRoot).filePath(secondCleanPath)); - const bool secondCleanCreated = - secondClean.open(QIODevice::WriteOnly | QIODevice::Truncate) && - secondClean.write("clean unique path\n") > 0; - secondClean.close(); - result &= expect( - secondCleanCreated && commitPath(secondRepository, "second-clean.txt") && - request(multiWorkspace.path(), {firstRoot, secondRoot}, - {priorityPath, secondCleanPath}, {}, - GitDiffScope::Unstaged) && - received.repositoryRoots.size() == 2 && received.files.size() == 4, - "changed-file preference is applied independently for every hinted path"); - QFile::remove(QDir(firstRoot).filePath(priorityPath)); - result &= expect( - request(multiWorkspace.path(), {firstRoot, secondRoot}, {priorityPath}, - {}, GitDiffScope::Unstaged) && - received.repositoryRoots == QStringList{QDir::cleanPath(firstRoot)} && - std::any_of(received.files.begin(), received.files.end(), - [&](const GitDiffFile &file) { - return file.path == priorityPath && - file.status == QStringLiteral("Deleted"); - }), - "a deleted path is resolved from Git state and preferred over a clean " - "tracked match"); - git_repository_free(firstRepository); - git_repository_free(secondRepository); - git_repository_free(hiddenRepository); - git_repository_free(repository); - return result; -} - -} // namespace -} // namespace codexui::codex::middle - -int main(int argc, char **argv) { - QApplication application(argc, argv); - QTemporaryDir settingsDirectory; - QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, - settingsDirectory.path()); - using namespace codexui::codex::middle; - bool result = testPromptKeyboardSubmission(); - result &= testOverlayGeometryAndRegionRouting(); - result &= testThreadSelectionProjection(); - result &= testThreadRuntimeStatusColors(); - result &= testThreadHierarchyExpansionAndNavigation(); - result &= testIncrementalThreadSettings(); - result &= testThreadAlphanumericSort(); - result &= testThreadCreatedSort(); - result &= testThreadLastChangedSort(); - result &= testThreadRecencySort(); - result &= testThreadLastActivityRetention(); - result &= testPromptActivityNaturallyOrdersThreads(); - result &= testOptimisticThreadRowLifecycle(); - result &= testThreadRowReorderOwnership(); - result &= testNestedCommandScrollOwnership(); - result &= testInfoViewerLayout(); - result &= testInspectorDetailParity(); - result &= testTerminalPlanStatusReconciliation(); - result &= testGitDiffScopes(); - result &= testStableComposerLayoutRequests(); - if (result) - std::cout << "Application layout tests passed\n"; - return result ? 0 : 1; -} diff --git a/tests/codex/ClientRuntimeDispatchTest.cpp b/tests/codex/ClientRuntimeDispatchTest.cpp new file mode 100644 index 0000000..2e67245 --- /dev/null +++ b/tests/codex/ClientRuntimeDispatchTest.cpp @@ -0,0 +1,1845 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ClientRuntime.h" +#include "codex/Configuration.h" +#include "codex/NodeGraphJson.h" +#include "codex/nodegraph/Messages.h" +#include "codex/nodegraph/ProtocolUpdater.h" +#include "codex/nodegraph/ThreadChannels.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +using namespace std::chrono_literals; +using namespace nodegraph; + +int failures = 0; + +void expect(bool condition, std::string_view message) { + if (condition) + return; + ++failures; + std::cerr << "FAILED: " << message << '\n'; +} + +class UnixBridge final { +public: + UnixBridge() { + path_ = "/tmp/codexui-runtime-dispatch-" + + std::to_string(static_cast(::getpid())) + ".sock"; + static_cast(::unlink(path_.c_str())); + listener_ = + ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); + if (listener_ < 0) + return; + + sockaddr_un address{}; + address.sun_family = AF_UNIX; + if (path_.size() >= sizeof(address.sun_path)) + return; + std::memcpy(address.sun_path, path_.c_str(), path_.size() + 1U); + if (::bind(listener_, reinterpret_cast(&address), + sizeof(address)) != 0 || + ::listen(listener_, 1) != 0) { + static_cast(::close(listener_)); + listener_ = -1; + } + } + + ~UnixBridge() { + if (client_ >= 0) + static_cast(::close(client_)); + if (listener_ >= 0) + static_cast(::close(listener_)); + static_cast(::unlink(path_.c_str())); + } + + UnixBridge(const UnixBridge &) = delete; + UnixBridge &operator=(const UnixBridge &) = delete; + + [[nodiscard]] bool valid() const noexcept { return listener_ >= 0; } + [[nodiscard]] const std::string &path() const noexcept { return path_; } + + bool acceptClient(std::chrono::milliseconds timeout = 5s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + pollfd event{listener_, POLLIN, 0}; + const int ready = ::poll(&event, 1, 20); + if (ready < 0 && errno == EINTR) + continue; + if (ready <= 0) + continue; + client_ = + ::accept4(listener_, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC); + if (client_ >= 0) + return true; + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) + return false; + } + return false; + } + + bool send(nlohmann::json message, std::chrono::milliseconds timeout = 2s) { + std::string encoded = message.dump(); + encoded.push_back('\n'); + std::size_t offset = 0; + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (offset < encoded.size() && + std::chrono::steady_clock::now() < deadline) { + const ssize_t written = + ::write(client_, encoded.data() + offset, encoded.size() - offset); + if (written > 0) { + offset += static_cast(written); + continue; + } + if (written < 0 && errno == EINTR) + continue; + if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK) + return false; + pollfd event{client_, POLLOUT, 0}; + static_cast(::poll(&event, 1, 10)); + } + return offset == encoded.size(); + } + + std::optional + receive(std::chrono::milliseconds timeout = 2s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (const std::size_t newline = buffered_.find('\n'); + newline != std::string::npos) { + std::string line = buffered_.substr(0, newline); + buffered_.erase(0, newline + 1U); + try { + return nlohmann::json::parse(line); + } catch (...) { + return std::nullopt; + } + } + + std::array incoming{}; + const ssize_t received = + ::read(client_, incoming.data(), incoming.size()); + if (received > 0) { + buffered_.append(incoming.data(), static_cast(received)); + continue; + } + if (received == 0) + return std::nullopt; + if (errno == EINTR) + continue; + if (errno != EAGAIN && errno != EWOULDBLOCK) + return std::nullopt; + pollfd event{client_, POLLIN, 0}; + static_cast(::poll(&event, 1, 10)); + } + return std::nullopt; + } + + std::optional + receiveAppServer(std::chrono::milliseconds timeout = 2s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + const auto remaining = + std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()); + std::optional envelope = receive(remaining); + if (!envelope) + return std::nullopt; + if (envelope->value("kind", std::string{}) == "appserver" && + envelope->contains("payload")) + return envelope->at("payload"); + } + return std::nullopt; + } + + bool reply(const nlohmann::json &request, nlohmann::json result) { + return send({{"kind", "appserver"}, + {"connectionId", "runtime-test"}, + {"role", "controller"}, + {"seq", nextSequence_++}, + {"payload", + {{"jsonrpc", "2.0"}, + {"id", request.at("id")}, + {"result", std::move(result)}}}}); + } + + bool replyError(const nlohmann::json &request, int code, + std::string message) { + return send( + {{"kind", "appserver"}, + {"connectionId", "runtime-test"}, + {"role", "controller"}, + {"seq", nextSequence_++}, + {"payload", + {{"jsonrpc", "2.0"}, + {"id", request.at("id")}, + {"error", {{"code", code}, {"message", std::move(message)}}}}}}); + } + + bool appServerRequest(std::string id, std::string method, + nlohmann::json parameters, + std::string role = "controller") { + return send({{"kind", "appserver"}, + {"connectionId", "runtime-test"}, + {"role", std::move(role)}, + {"seq", nextSequence_++}, + {"payload", + {{"jsonrpc", "2.0"}, + {"id", std::move(id)}, + {"method", std::move(method)}, + {"params", std::move(parameters)}}}}); + } + + bool appServerNotification(std::string method, nlohmann::json parameters) { + return send({{"kind", "appserver"}, + {"connectionId", "runtime-test"}, + {"role", "controller"}, + {"seq", nextSequence_++}, + {"payload", + {{"jsonrpc", "2.0"}, + {"method", std::move(method)}, + {"params", std::move(parameters)}}}}); + } + + bool setRole(std::string role, std::string controllerConnectionId) { + if (!send({{"kind", "bridge.connection"}, + {"event", "opened"}, + {"connectionId", "runtime-test"}, + {"role", std::move(role)}, + {"seq", nextSequence_++}})) + return false; + return send({{"kind", "bridge.controller"}, + {"controllerConnectionId", std::move(controllerConnectionId)}, + {"seq", nextSequence_++}}); + } + + bool setProviderGeneration(std::uint64_t generation) { + return send({{"kind", "bridge.provider"}, + {"state", "ready"}, + {"providerGeneration", generation}, + {"seq", nextSequence_++}}); + } + +private: + std::string path_; + int listener_ = -1; + int client_ = -1; + std::uint64_t nextSequence_ = 4; + std::string buffered_; +}; + +class RunningRuntime final { +public: + explicit RunningRuntime(Configuration &configuration) + : configuration_(configuration) {} + + ~RunningRuntime() { stop(); } + + RunningRuntime(const RunningRuntime &) = delete; + RunningRuntime &operator=(const RunningRuntime &) = delete; + + void start() { + worker_ = std::thread([this] { + result_.store(runClientRuntime(configuration_, graph_, channels_, false), + std::memory_order_release); + }); + } + + void stop() { + if (!worker_.joinable()) + return; + ShutdownRequest shutdown; + for (int attempt = 0; attempt != 1000; ++attempt) { + if (messageAdmitted(channels_.sendShutdown(shutdown))) + break; + std::this_thread::sleep_for(1ms); + } + worker_.join(); + } + + NodeGraph &graph() noexcept { return graph_; } + ThreadChannels &channels() noexcept { return channels_; } + + std::optional workerCpuTime() { + if (!worker_.joinable()) + return std::nullopt; + clockid_t clock{}; + if (::pthread_getcpuclockid(worker_.native_handle(), &clock) != 0) + return std::nullopt; + timespec value{}; + if (::clock_gettime(clock, &value) != 0) + return std::nullopt; + return std::chrono::seconds(value.tv_sec) + + std::chrono::nanoseconds(value.tv_nsec); + } + + void drainNotifications() { + static_cast(channels_.drainWorkerToQtWake()); + WorkerToQtMessage message; + while (channels_.tryReceiveForQt(message)) + message = WorkerStopped{}; + } + + std::vector takeProtocolDiagnostics() { + std::vector diagnostics; + static_cast(channels_.drainWorkerToQtWake()); + WorkerToQtMessage message; + while (channels_.tryReceiveForQt(message)) { + if (UiEffect *effect = std::get_if(&message); + effect && effect->kind == UiEffectKind::ProtocolDiagnostic) + diagnostics.emplace_back(std::move(*effect)); + message = WorkerStopped{}; + } + return diagnostics; + } + +private: + Configuration &configuration_; + NodeGraph graph_; + ThreadChannels channels_; + std::thread worker_; + std::atomic result_{-1}; +}; + +template +bool waitUntil(Predicate predicate, std::chrono::milliseconds timeout = 2s) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) + return true; + std::this_thread::sleep_for(2ms); + } + return predicate(); +} + +NodeRef findNode(NodeGraph &graph, NodeId id) { + NodeRef found; + static_cast(waitUntil([&] { + std::optional read = graph.tryRead(); + if (!read) + return false; + found = read->find(id); + return static_cast(found); + })); + return found; +} + +NodeRef findRetiredNode(NodeGraph &graph, const NodeId &id) { + NodeRef found; + static_cast(waitUntil([&] { + std::optional read = graph.tryRead(); + if (!read) + return false; + for (const NodeRef &node : read->retiredNodes()) { + if (node && node->id() == id) { + found = node; + return true; + } + } + return false; + })); + return found; +} + +bool operationPending(NodeGraph &graph, const nlohmann::json &requestId) { + const ProtocolRequestId id = requestIdFromJson(requestId); + std::optional read = graph.tryRead(); + if (!read) + return false; + const NodeRef operation = read->find({NodeKind::Operation, id.canonical()}); + return operation && read->state(operation)->status == NodeStatus::Pending; +} + +bool operationTargets(NodeGraph &graph, const nlohmann::json &requestId, + const NodeRef &target) { + const ProtocolRequestId id = requestIdFromJson(requestId); + std::optional read = graph.tryRead(); + if (!read) + return false; + const NodeRef operation = read->find({NodeKind::Operation, id.canonical()}); + return operation && read->related(operation, RelationKind::OperationTarget) == + std::vector{target}; +} + +bool operationRetired(NodeGraph &graph, const nlohmann::json &requestId) { + const ProtocolRequestId id = requestIdFromJson(requestId); + std::optional read = graph.tryRead(); + return read && !read->find({NodeKind::Operation, id.canonical()}); +} + +bool interactionRetired(NodeGraph &graph, const NodeRef &interaction) { + if (!interaction) + return false; + std::optional read = graph.tryRead(); + if (!read || read->find(interaction->id())) + return false; + for (const NodeRef &retired : read->retiredNodes()) + if (retired == interaction) + return true; + return false; +} + +bool sendAction(ThreadChannels &channels, NodeAction action) { + return messageAdmitted(channels.sendNodeAction(action)); +} + +bool sendAction(ThreadChannels &channels, RuntimeAction action) { + return messageAdmitted(channels.sendRuntimeAction(action)); +} + +nlohmann::json listedThread() { + return {{"id", "runtime-thread"}, {"name", "Runtime dispatch"}, + {"status", "idle"}, {"createdAt", 1}, + {"updatedAt", 2}, {"turns", nlohmann::json::array()}}; +} + +bool rejectedAutomaticCurrentTimeResponseIsTerminal(UnixBridge &bridge, + RunningRuntime &runtime) { + constexpr std::string_view RequestId = "clock-before-identity"; + if (!bridge.appServerRequest(std::string(RequestId), "currentTime/read", + {{"threadId", "pre-identity-thread"}})) + return false; + + // The transport is attached, but CodexBridge has not received its + // bridge.connection identity yet. Its sender therefore deterministically + // rejects the automatic response without disconnecting the worker. + const NodeId interactionId{ + NodeKind::Interaction, + ProtocolRequestId(std::string(RequestId)).canonical()}; + const NodeRef retired = findRetiredNode(runtime.graph(), interactionId); + std::optional read = runtime.graph().tryRead(); + const bool terminal = retired && read && !read->find(interactionId); + read.reset(); + return terminal && !bridge.receiveAppServer(100ms); +} + +bool establishProvider(UnixBridge &bridge, RunningRuntime &runtime) { + RuntimeAction configure; + configure.kind = RuntimeActionKind::ConfigureConnection; + configure.payload = {{"transport", Value("unix")}, + {"path", Value(bridge.path())}}; + if (!sendAction(runtime.channels(), std::move(configure))) + return false; + if (!bridge.acceptClient()) + return false; + + expect(rejectedAutomaticCurrentTimeResponseIsTerminal(bridge, runtime), + "a rejected automatic current-time response retires its interaction " + "instead of exposing an unresolvable UI action"); + + if (!bridge.send({{"kind", "bridge.connection"}, + {"event", "opened"}, + {"connectionId", "runtime-test"}, + {"role", "controller"}, + {"seq", 1}}) || + !bridge.send({{"kind", "bridge.controller"}, + {"controllerConnectionId", "runtime-test"}, + {"seq", 2}}) || + !bridge.send({{"kind", "bridge.provider"}, + {"state", "ready"}, + {"providerGeneration", 1}, + {"seq", 3}})) + return false; + + std::unordered_map methods; + for (int count = 0; count != 3; ++count) { + std::optional request = bridge.receiveAppServer(); + if (!request || !request->contains("id")) + return false; + const std::string method = request->value("method", std::string{}); + ++methods[method]; + nlohmann::json result{{"data", nlohmann::json::array()}}; + if (method == "thread/list") { + result["data"].push_back(listedThread()); + result["nextCursor"] = nullptr; + } + if (!bridge.reply(*request, std::move(result))) + return false; + } + return methods == + std::unordered_map{ + {"thread/list", 1}, + {"model/list", 1}, + {"permissionProfile/list", 1}} && + static_cast( + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"})); +} + +std::string diagnosticField(const UiEffect &effect, std::string_view key) { + const auto found = effect.details.find(key); + if (found == effect.details.end() || !found->second.asString()) + return {}; + return *found->second.asString(); +} + +void protocolDiagnosticsPreserveMetadataWithoutPayloads( + UnixBridge &bridge, RunningRuntime &runtime) { + const std::vector initialDiagnostics = + runtime.takeProtocolDiagnostics(); + expect(std::ranges::any_of(initialDiagnostics, + [](const UiEffect &effect) { + return diagnosticField(effect, "subject") == + "connection.lifecycle"; + }) && + std::ranges::any_of(initialDiagnostics, + [](const UiEffect &effect) { + return diagnosticField(effect, "subject") == + "connection.provider" && + diagnosticField( + effect, "authority") == "replace"; + }), + "transport lifecycle and bridge provider diagnostics remain visible"); + const NodeRef thread = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + if (!thread) + return; + + NodeAction rename{thread, NodeActionKind::Rename}; + rename.payload = {{"name", Value("authored-name-must-not-appear")}}; + expect(sendAction(runtime.channels(), std::move(rename)), + "diagnostic rename enters the typed mailbox"); + const std::optional request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/name/set", + "diagnostic fixture receives the direct request"); + if (!request) + return; + expect(bridge.replyError(*request, -32041, "rename rejected safely"), + "diagnostic fixture receives a benign JSON-RPC error"); + + std::vector diagnostics; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + diagnostics.insert(diagnostics.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::any_of(diagnostics, [](const UiEffect &effect) { + return diagnosticField(effect, "direction") == "client error" && + diagnosticField(effect, "subject") == "thread/name/set"; + }); + }), + "request and response diagnostics cross the typed worker queue"); + + const UiEffect *sent = nullptr; + const UiEffect *failed = nullptr; + for (const UiEffect &effect : diagnostics) { + if (diagnosticField(effect, "subject") != "thread/name/set") + continue; + if (diagnosticField(effect, "direction") == "client request") + sent = &effect; + if (diagnosticField(effect, "direction") == "client error") + failed = &effect; + } + expect(sent && failed && diagnosticField(*sent, "source") == "CodexUI" && + diagnosticField(*sent, "authority") == "none" && + diagnosticField(*sent, "threadId") == "runtime-thread" && + diagnosticField(*failed, "source") == "app-server" && + diagnosticField(*failed, "authority") == "none" && + diagnosticField(*failed, "threadId") == "runtime-thread" && + diagnosticField(*failed, "outcome") == "ERROR" && + diagnosticField(*failed, "errorCategory") == "json-rpc" && + diagnosticField(*failed, "errorCode") == "-32041" && + diagnosticField(*failed, "error") == "rename rejected safely" && + diagnosticField(*sent, "correlation") == + diagnosticField(*failed, "correlation"), + "diagnostics preserve direction, source, semantic authority, scope, " + "correlation, and safe errors"); + + for (const UiEffect &effect : diagnostics) { + const std::string rendered = diagnosticField(effect, "subject") + + diagnosticField(effect, "error") + + diagnosticField(effect, "threadId"); + expect(rendered.find("authored-name-must-not-appear") == std::string::npos, + "diagnostics never copy an authored request payload"); + } + + RuntimeAction refresh{RuntimeActionKind::RefreshThreads}; + expect(sendAction(runtime.channels(), std::move(refresh)), + "secret-error fixture enters the typed mailbox"); + const std::optional secretRequest = bridge.receiveAppServer(); + expect(secretRequest && + secretRequest->value("method", std::string{}) == "thread/list", + "secret-error fixture receives the direct request"); + if (secretRequest) { + expect(bridge.replyError(*secretRequest, -32042, + "Bearer sk-runtime-secret eyJabc.def.ghi"), + "secret-shaped error reaches the runtime"); + std::vector secretDiagnostics; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + secretDiagnostics.insert(secretDiagnostics.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::any_of( + secretDiagnostics, [](const UiEffect &effect) { + return diagnosticField(effect, "direction") == + "client error" && + diagnosticField(effect, "subject") == "thread/list"; + }); + }), + "secret-shaped error produces bounded metadata"); + bool redactedSecretError = false; + for (const UiEffect &effect : secretDiagnostics) { + if (diagnosticField(effect, "subject") != "thread/list" || + diagnosticField(effect, "direction") != "client error") + continue; + redactedSecretError = + diagnosticField(effect, "error") == "[redacted error detail]"; + } + expect(redactedSecretError, + "credential-shaped protocol error detail is redacted"); + } + + const auto readAuthority = [&](bool insertInterveningFrame) { + runtime.drainNotifications(); + NodeAction reload{thread, NodeActionKind::Reload}; + expect(sendAction(runtime.channels(), std::move(reload)), + "thread/read diagnostic reload enters the typed mailbox"); + const std::optional readRequest = bridge.receiveAppServer(); + expect(readRequest && + readRequest->value("method", std::string{}) == "thread/read", + "diagnostic reload emits thread/read"); + if (!readRequest) + return std::string{}; + if (insertInterveningFrame) { + expect(bridge.appServerNotification( + "thread/name/updated", + {{"threadId", "runtime-thread"}, + {"name", "Changed while thread/read was pending"}}), + "intervening provider delta is delivered before thread/read"); + } + expect(bridge.reply(*readRequest, {{"thread", listedThread()}}), + "thread/read diagnostic response is delivered"); + + // Interactive hydration preserves the existing settings-refresh behavior. + // Complete that follow-up so this authority check leaves no wire request + // behind for the rest of the runtime integration test. + const std::optional resumeRequest = + bridge.receiveAppServer(); + expect(resumeRequest && + resumeRequest->value("method", std::string{}) == "thread/resume", + "interactive thread/read is followed by one settings refresh"); + if (resumeRequest) + expect(bridge.reply(*resumeRequest, nlohmann::json::object()), + "settings refresh response is delivered"); + + std::string authority; + expect(waitUntil([&] { + for (UiEffect &effect : runtime.takeProtocolDiagnostics()) { + if (diagnosticField(effect, "direction") == "client result" && + diagnosticField(effect, "subject") == "thread/read") + authority = diagnosticField(effect, "authority"); + } + return !authority.empty(); + }), + "thread/read result retains its diagnostic authority"); + return authority; + }; + expect(readAuthority(true) == "merge", + "an intervening provider frame makes stale thread/read diagnostics " + "field-aware merge authority"); + expect(readAuthority(false) == "replace", + "an immediately correlated thread/read retains replacement " + "authority"); + + runtime.drainNotifications(); + expect( + bridge.appServerNotification("skills/changed", nlohmann::json::object()), + "state-neutral catalog invalidation is delivered"); + expect(bridge.appServerNotification("thread/goal/cleared", + {{"threadId", "runtime-thread"}}), + "authoritative removal notification is delivered"); + std::vector notificationDiagnostics; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + notificationDiagnostics.insert( + notificationDiagnostics.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::any_of(notificationDiagnostics, + [](const UiEffect &effect) { + return diagnosticField(effect, + "subject") == + "skills/changed"; + }) && + std::ranges::any_of( + notificationDiagnostics, [](const UiEffect &effect) { + return diagnosticField(effect, "subject") == + "thread/goal/cleared"; + }); + }), + "notification diagnostics preserve semantic authority"); + const auto authorityFor = [&](std::string_view subject) { + const auto found = std::ranges::find_if( + notificationDiagnostics, [subject](const UiEffect &effect) { + return diagnosticField(effect, "subject") == subject; + }); + return found == notificationDiagnostics.end() + ? std::string{} + : diagnosticField(*found, "authority"); + }; + expect(authorityFor("skills/changed") == "none" && + authorityFor("thread/goal/cleared") == "remove", + "state-neutral and removal notifications remain distinguishable"); + + runtime.drainNotifications(); + expect(bridge.appServerRequest("diagnostic-clock", "currentTime/read", + {{"threadId", "runtime-thread"}}), + "reverse-request diagnostic fixture is delivered"); + const std::optional automaticClockResponse = + bridge.receiveAppServer(); + expect(automaticClockResponse && + !automaticClockResponse->contains("method") && + automaticClockResponse->value("id", std::string{}) == + "diagnostic-clock", + "reverse-request diagnostic fixture consumes its automatic response"); + std::vector reverseDiagnostics; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + reverseDiagnostics.insert(reverseDiagnostics.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::any_of( + reverseDiagnostics, [](const UiEffect &effect) { + return diagnosticField(effect, "direction") == + "server result" && + diagnosticField(effect, "correlation") == + "diagnostic-clock"; + }); + }), + "reverse request and automatic response are both diagnosed"); + const UiEffect *reverseRequest = nullptr; + const UiEffect *reverseResult = nullptr; + for (const UiEffect &effect : reverseDiagnostics) { + if (diagnosticField(effect, "correlation") != "diagnostic-clock") + continue; + if (diagnosticField(effect, "direction") == "server request") + reverseRequest = &effect; + if (diagnosticField(effect, "direction") == "server result") + reverseResult = &effect; + } + expect(reverseRequest && reverseResult && + diagnosticField(*reverseRequest, "authority") == "merge" && + diagnosticField(*reverseResult, "authority") == "remove" && + diagnosticField(*reverseRequest, "threadId") == "runtime-thread" && + diagnosticField(*reverseRequest, "correlation") == + diagnosticField(*reverseResult, "correlation"), + "reverse interaction diagnostics preserve scope and correlation"); + + runtime.drainNotifications(); + constexpr std::string_view SensitiveRequestId = + "failed:sk-sensitive-correlation"; + expect(bridge.appServerRequest(std::string(SensitiveRequestId), + "currentTime/read", + {{"threadId", "runtime-thread"}}), + "sensitive request-id diagnostic fixture is delivered"); + const std::optional sensitiveClockResponse = + bridge.receiveAppServer(); + expect(sensitiveClockResponse && + !sensitiveClockResponse->contains("method") && + sensitiveClockResponse->value("id", std::string{}) == + SensitiveRequestId, + "sensitive request-id fixture consumes its automatic response"); + std::vector sensitiveIdDiagnostics; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + sensitiveIdDiagnostics.insert(sensitiveIdDiagnostics.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::any_of( + sensitiveIdDiagnostics, [](const UiEffect &effect) { + return diagnosticField(effect, "direction") == + "server result" && + diagnosticField(effect, "correlation") == + ""; + }); + }), + "sensitive request ids remain visible only as redacted chronology"); + for (const UiEffect &effect : sensitiveIdDiagnostics) { + std::string visibleMetadata; + for (const auto &[key, value] : effect.details) { + visibleMetadata += key; + if (value.asString()) + visibleMetadata += *value.asString(); + } + expect(visibleMetadata.find(SensitiveRequestId) == std::string::npos, + "sensitive request ids are neither retained nor correlated"); + } + runtime.drainNotifications(); +} + +void directNodeActionsUseOneCorrelatedRequest(UnixBridge &bridge, + RunningRuntime &runtime) { + const NodeRef thread = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + expect(static_cast(thread), "provider hydration creates action target"); + if (!thread) + return; + + NodeAction missingName{thread, NodeActionKind::Rename}; + missingName.correlation = "missing-name-action"; + expect(sendAction(runtime.channels(), std::move(missingName)), + "a missing-name rename can enter the typed mailbox"); + expect(!bridge.receiveAppServer(100ms), + "worker validation blocks a missing rename name"); + + NodeAction emptyName{thread, NodeActionKind::Rename}; + emptyName.payload = {{"name", Value(" \t\n")}}; + emptyName.correlation = "blank-name-action"; + expect(sendAction(runtime.channels(), std::move(emptyName)), + "a blank-name rename can enter the typed mailbox"); + expect(!bridge.receiveAppServer(100ms), + "worker validation blocks an empty rename name"); + std::vector localRejections; + expect(waitUntil([&] { + std::vector batch = runtime.takeProtocolDiagnostics(); + localRejections.insert(localRejections.end(), + std::make_move_iterator(batch.begin()), + std::make_move_iterator(batch.end())); + return std::ranges::count_if( + localRejections, [](const UiEffect &effect) { + return diagnosticField(effect, "direction") == + "local result" && + diagnosticField(effect, "subject") == + "thread/name/set"; + }) >= 2; + }), + "local validation failures reach the bounded Protocol chronology"); + const auto localRenameRejection = [&localRejections]( + std::string_view correlation) { + return std::ranges::any_of(localRejections, [correlation]( + const UiEffect &effect) { + return diagnosticField(effect, "direction") == "local result" && + diagnosticField(effect, "subject") == "thread/name/set" && + diagnosticField(effect, "authority") == "none" && + diagnosticField(effect, "outcome") == "ERROR" && + diagnosticField(effect, "errorCategory") == "local-validation" && + diagnosticField(effect, "correlation") == correlation && + diagnosticField(effect, "threadId") == "runtime-thread" && + diagnosticField(effect, "targetId") == "runtime-thread" && + diagnosticField(effect, "error") == + "A non-empty thread name is required"; + }); + }; + expect(localRenameRejection("missing-name-action") && + localRenameRejection("blank-name-action"), + "local rejection diagnostics preserve action correlation and safe " + "error metadata without sending a wire operation"); + + NodeAction rename{thread, NodeActionKind::Rename}; + rename.payload = {{"name", Value("Renamed once")}, + {"turnId", Value("payload-decoy-turn")}, + {"itemId", Value("payload-decoy-item")}}; + expect(sendAction(runtime.channels(), std::move(rename)), + "rename action enters the worker mailbox"); + std::optional request = bridge.receiveAppServer(); + expect( + request && request->value("method", std::string{}) == "thread/name/set" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread" && + request->at("params").value("name", std::string{}) == "Renamed once", + "rename action emits one addressed CodexBridge request"); + if (!request) + return; + expect(waitUntil([&] { + return operationPending(runtime.graph(), request->at("id")); + }), + "emitted request has one pending graph correlation"); + expect(operationTargets(runtime.graph(), request->at("id"), thread), + "the operation retains the exact NodeRef selected by the UI action"); + { + std::optional read = runtime.graph().tryRead(); + const NodeId decoyTurn = + scopedTurnNodeId("runtime-thread", "payload-decoy-turn"); + expect(read && !read->find(decoyTurn) && + !read->find(scopedItemNodeId(decoyTurn, "payload-decoy-item")), + "payload identifiers cannot synthesize a replacement operation " + "target when an exact action target was supplied"); + } + expect(!bridge.receiveAppServer(100ms), + "non-idempotent rename is not dual-sent"); + expect(bridge.reply(*request, nlohmann::json::object()), + "matching rename response is delivered"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "matching response retires the exact pending operation"); + + NodeAction archive{thread, NodeActionKind::Archive}; + expect(sendAction(runtime.channels(), std::move(archive)), + "archive action enters the worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/archive" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread", + "archive action emits one addressed CodexBridge request"); + if (request) { + expect(!bridge.receiveAppServer(100ms), + "non-idempotent archive is not dual-sent"); + expect(bridge.reply(*request, nlohmann::json::object()), + "archive response is delivered"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "archive response correlates to and retires its operation"); + } + runtime.drainNotifications(); +} + +void remainingUiCommandFamiliesUseExactWirePaths(UnixBridge &bridge, + RunningRuntime &runtime) { + const NodeRef thread = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + expect(static_cast(thread), + "remaining UI command coverage has a stable thread target"); + if (!thread) + return; + + NodeAction history{thread, NodeActionKind::LoadHistory}; + history.payload = {{"cursor", Value("history-cursor")}, + {"limit", Value(std::uint64_t{23})}}; + expect(sendAction(runtime.channels(), std::move(history)), + "history paging enters the typed worker mailbox"); + std::optional request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/turns/list" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread" && + request->at("params").value("cursor", std::string{}) == + "history-cursor" && + request->at("params").value("limit", 0) == 23 && + request->at("params").value("sortDirection", std::string{}) == + "desc" && + request->at("params").value("itemsView", std::string{}) == "full", + "Load More encodes one scoped thread/turns/list request"); + if (request) { + expect(operationTargets(runtime.graph(), request->at("id"), thread), + "history paging preserves its exact thread NodeRef"); + expect(bridge.reply(*request, {{"data", nlohmann::json::array()}, + {"nextCursor", nullptr}}), + "history paging decodes its typed result"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "history paging result retires its exact operation"); + } + expect(!bridge.receiveAppServer(100ms), + "history paging is never duplicated on the wire"); + + NodeAction fork{thread, NodeActionKind::Fork}; + expect(sendAction(runtime.channels(), std::move(fork)), + "fork enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/fork" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread", + "fork encodes one request addressed by the supplied NodeRef"); + if (request) { + expect(operationTargets(runtime.graph(), request->at("id"), thread), + "fork preserves its exact thread NodeRef"); + expect(bridge.replyError(*request, -32043, "focused fork rejection"), + "fork decodes a typed provider error"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "fork error retires its correlated operation"); + } + expect(!bridge.receiveAppServer(100ms), "fork is never dual-sent"); + + const auto archivedIs = [&](bool expected) { + std::optional read = runtime.graph().tryRead(); + if (!read || read->find(thread->id()) != thread) + return false; + const auto state = read->state(thread); + const auto archived = state->fields.find("archived"); + return archived != state->fields.end() && archived->second.asBool() && + *archived->second.asBool() == expected; + }; + expect(bridge.appServerNotification("thread/archived", + {{"threadId", "runtime-thread"}}) && + waitUntil([&] { return archivedIs(true); }), + "unarchive coverage starts from authoritative archived state"); + NodeAction unarchive{thread, NodeActionKind::Unarchive}; + expect(sendAction(runtime.channels(), std::move(unarchive)), + "unarchive enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/unarchive" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread", + "unarchive encodes one request addressed by the supplied NodeRef"); + if (request) { + expect(operationTargets(runtime.graph(), request->at("id"), thread), + "unarchive preserves its exact thread NodeRef"); + expect(bridge.reply(*request, nlohmann::json::object()), + "unarchive decodes its typed result"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "unarchive result retires its correlated operation"); + } + expect(!bridge.receiveAppServer(100ms), "unarchive is never dual-sent"); + expect(bridge.appServerNotification("thread/unarchived", + {{"threadId", "runtime-thread"}}) && + waitUntil([&] { return archivedIs(false); }), + "authoritative unarchive restores the thread for prompt coverage"); + + RuntimeAction create{RuntimeActionKind::CreateThread}; + create.correlation = "wire-create-correlation"; + create.promptText = "Create and send exactly once"; + create.attachments.push_back( + {"/tmp/wire-image.png", "wire-image.png", "image/png", std::nullopt}); + create.payload = { + {"threadStart", Value(Value::Object{{"cwd", Value("/tmp/wire-create")}})}, + {"turnStart", + Value(Value::Object{{"approvalPolicy", Value("on-request")}})}}; + expect(sendAction(runtime.channels(), std::move(create)), + "new-thread prompt enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/start" && + request->at("params").value("cwd", std::string{}) == + "/tmp/wire-create", + "Create Thread encodes its thread options exactly once"); + if (!request) { + runtime.drainNotifications(); + return; + } + expect(bridge.reply(*request, {{"thread", + {{"id", "wire-created-thread"}, + {"name", "Wire created"}, + {"status", "idle"}, + {"turns", nlohmann::json::array()}}}}), + "thread/start decodes the canonical created thread"); + + request = bridge.receiveAppServer(); + const nlohmann::json input = + request && request->contains("params") + ? request->at("params").value("input", nlohmann::json::array()) + : nlohmann::json::array(); + expect(request && request->value("method", std::string{}) == "turn/start" && + request->at("params").value("threadId", std::string{}) == + "wire-created-thread" && + request->at("params").value("approvalPolicy", std::string{}) == + "on-request" && + input.size() == 2 && + input.at(0).value("type", std::string{}) == "text" && + input.at(0) + .value("text", std::string{}) + .find("Create and send exactly once") != + std::string::npos && + input.at(1).value("type", std::string{}) == "localImage" && + input.at(1).value("path", std::string{}) == "/tmp/wire-image.png", + "the first prompt moves text, options, and attachment into one " + "turn/start request"); + if (request) { + expect(bridge.reply(*request, {{"turn", + {{"id", "wire-created-turn"}, + {"status", "inProgress"}, + {"items", nlohmann::json::array()}}}}), + "turn/start decodes its canonical turn result"); + } + expect(!bridge.receiveAppServer(100ms), + "the first new-thread prompt is never dual-sent"); + + const NodeRef created = + findNode(runtime.graph(), {NodeKind::Thread, "wire-created-thread"}); + const NodeRef active = + findNode(runtime.graph(), + scopedTurnNodeId("wire-created-thread", "wire-created-turn")); + expect(created && active, + "created-thread command results retain natural thread and turn nodes"); + if (created && active) { + NodeAction steer{created, NodeActionKind::SubmitPrompt}; + steer.promptText = "Steer the exact active turn"; + expect(sendAction(runtime.channels(), std::move(steer)), + "active-turn steering enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "turn/steer" && + request->at("params").value("threadId", std::string{}) == + "wire-created-thread" && + request->at("params").value("expectedTurnId", std::string{}) == + "wire-created-turn", + "Submit while active encodes one turn/steer for the exact turn"); + if (request) + expect(bridge.reply(*request, {{"turnId", "wire-created-turn"}}), + "turn/steer decodes its typed result"); + expect(!bridge.receiveAppServer(100ms), "steering is never dual-sent"); + } + + expect(bridge.appServerNotification("turn/completed", + {{"threadId", "wire-created-thread"}, + {"turn", + {{"id", "wire-created-turn"}, + {"status", "completed"}, + {"items", nlohmann::json::array()}}}}), + "created active turn receives its authoritative completion"); + + if (created) { + NodeAction remove{created, NodeActionKind::Delete}; + expect(sendAction(runtime.channels(), std::move(remove)), + "delete enters the typed worker mailbox"); + request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "thread/delete" && + request->at("params").value("threadId", std::string{}) == + "wire-created-thread", + "delete encodes one request addressed by the supplied NodeRef"); + if (request) { + expect(operationTargets(runtime.graph(), request->at("id"), created), + "delete preserves its exact thread NodeRef"); + expect(bridge.reply(*request, nlohmann::json::object()), + "delete decodes its typed result"); + expect(waitUntil([&] { + return operationRetired(runtime.graph(), request->at("id")); + }), + "delete result retires its correlated operation"); + } + expect(!bridge.receiveAppServer(100ms), "delete is never dual-sent"); + expect(bridge.appServerNotification( + "thread/deleted", {{"threadId", "wire-created-thread"}}) && + waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + return read && !read->find(created->id()); + }), + "authoritative delete retires the exact created thread"); + } + runtime.drainNotifications(); +} + +void runtimeRefreshActionsHaveExactRequestCardinality(UnixBridge &bridge, + RunningRuntime &runtime) { + RuntimeAction refresh{RuntimeActionKind::RefreshThreads}; + refresh.payload = {{"limit", Value(std::uint64_t{17})}}; + expect(sendAction(runtime.channels(), std::move(refresh)), + "thread refresh enters the worker mailbox"); + std::optional request = bridge.receiveAppServer(); + expect(request && request->value("method", std::string{}) == "thread/list" && + request->at("params").value("limit", 0) == 17, + "thread refresh emits exactly the requested typed operation"); + if (request) + expect(bridge.reply(*request, + {{"data", nlohmann::json::array({listedThread()})}, + {"nextCursor", nullptr}}), + "thread refresh response is delivered"); + expect(!bridge.receiveAppServer(100ms), + "one refresh action does not duplicate thread/list"); + + RuntimeAction catalogs{RuntimeActionKind::RefreshCatalogs}; + catalogs.payload = {{"cwd", Value("/tmp/runtime-dispatch")}}; + expect(sendAction(runtime.channels(), std::move(catalogs)), + "catalog refresh enters the worker mailbox"); + std::unordered_map methods; + for (int count = 0; count != 2; ++count) { + request = bridge.receiveAppServer(); + if (!request) + break; + ++methods[request->value("method", std::string{})]; + expect(request->at("params").value("cwd", std::string{}) == + "/tmp/runtime-dispatch", + "catalog refresh preserves the newly authored payload"); + expect(bridge.reply(*request, {{"data", nlohmann::json::array()}}), + "catalog response is delivered"); + } + expect(methods == + std::unordered_map{ + {"model/list", 1}, {"permissionProfile/list", 1}}, + "catalog refresh emits one request for each concrete catalog"); + expect(!bridge.receiveAppServer(100ms), + "catalog operations are not duplicated"); + runtime.drainNotifications(); +} + +void failedWakeUsesBoundedWorkerRecovery(UnixBridge &bridge, + RunningRuntime &runtime) { + RuntimeAction refresh{RuntimeActionKind::RefreshThreads}; + refresh.payload = {{"limit", Value(std::uint64_t{9})}}; + runtime.channels().failNextQtToWorkerWakeForTest(); + const ChannelSendStatus status = + runtime.channels().sendRuntimeAction(refresh); + expect(status == ChannelSendStatus::AcceptedWakeFailed && + deliveryGuaranteed(status) && wakeFailed(status) && + runtime.channels().drainQtToWorkerWake().status == + EventFd::DrainStatus::Empty, + "a failed Qt wake leaves one non-retryable action for timeout " + "delivery"); + + std::optional request = bridge.receiveAppServer(1500ms); + expect(request && request->value("method", std::string{}) == "thread/list" && + request->at("params").value("limit", 0) == 9, + "the existing worker thread consumes an unwoken action within its " + "bounded recovery interval"); + if (request) + expect(bridge.reply(*request, + {{"data", nlohmann::json::array({listedThread()})}, + {"nextCursor", nullptr}}), + "the timeout-delivered action completes normally"); + expect(!bridge.receiveAppServer(150ms), + "wake recovery never retries the non-idempotent queue payload"); + runtime.drainNotifications(); +} + +void idleWorkerSleepsBetweenWakeRecoveryChecks(RunningRuntime &runtime) { + const auto before = runtime.workerCpuTime(); + std::this_thread::sleep_for(350ms); + const auto after = runtime.workerCpuTime(); + expect(before && after && *after >= *before && + *after - *before < 100ms, + "an idle worker rearms its mailbox timeout instead of zero-timeout " + "polling"); +} + +void reverseInteractionsRespondOnceWithAuthoredData(UnixBridge &bridge, + RunningRuntime &runtime) { + expect(bridge.appServerRequest("approval-runtime", + "item/commandExecution/requestApproval", + {{"threadId", "runtime-thread"}, + {"turnId", "runtime-turn"}, + {"itemId", "runtime-command"}, + {"command", "printf runtime"}, + {"cwd", "/tmp"}}), + "approval request reaches CodexBridge"); + const NodeRef approval = findNode( + runtime.graph(), {NodeKind::Interaction, + ProtocolRequestId("approval-runtime").canonical()}); + expect(static_cast(approval), + "reverse approval is represented by its stable interaction node"); + if (approval) { + NodeAction resolve{approval, NodeActionKind::ResolveInteraction}; + resolve.payload = {{"decision", Value("accept")}}; + expect(sendAction(runtime.channels(), std::move(resolve)), + "approval response action enters the worker mailbox"); + const std::optional response = bridge.receiveAppServer(); + expect(response && !response->contains("method") && + response->value("id", std::string{}) == "approval-runtime" && + response->at("result").value("decision", std::string{}) == + "accept", + "approval action emits one typed response with the original id"); + expect(!bridge.receiveAppServer(100ms), + "approval response is never dual-sent"); + expect(waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + return read && + !read->find( + {NodeKind::Interaction, + ProtocolRequestId("approval-runtime").canonical()}); + }), + "accepted response retires the exact interaction"); + } + + expect(bridge.appServerRequest("input-runtime", "item/tool/requestUserInput", + {{"threadId", "runtime-thread"}, + {"turnId", "runtime-turn"}, + {"itemId", "runtime-question"}, + {"questions", nlohmann::json::array()}}), + "user-input request reaches CodexBridge"); + const NodeRef input = findNode( + runtime.graph(), + {NodeKind::Interaction, ProtocolRequestId("input-runtime").canonical()}); + expect(static_cast(input), + "reverse user input is represented by its interaction node"); + if (input) { + NodeAction answer{input, NodeActionKind::ResolveInteraction}; + answer.payload = { + {"answers", Value(Value::Object{ + {"question-1", Value(Value::Array{Value("yes")})}})}}; + expect(sendAction(runtime.channels(), std::move(answer)), + "user-input response action enters the worker mailbox"); + const std::optional response = bridge.receiveAppServer(); + expect(response && + response->value("id", std::string{}) == "input-runtime" && + response->at("result").at("answers").at("question-1") == + nlohmann::json::array({"yes"}), + "user-input response moves only newly authored answers to the wire"); + expect(!bridge.receiveAppServer(100ms), + "user-input response is never dual-sent"); + expect( + waitUntil([&] { return interactionRetired(runtime.graph(), input); }), + "user-input response removes the exact interaction once"); + } + runtime.drainNotifications(); +} + +void workerRevalidatesCurrentAuthorityAndRetainsResponses( + UnixBridge &bridge, RunningRuntime &runtime) { + const auto graphRoleIs = [&](std::string_view expected) { + std::optional read = runtime.graph().tryRead(); + if (!read) + return false; + const NodeRef connection = read->find({NodeKind::Connection, "connection"}); + if (!connection) + return false; + const auto state = read->state(connection); + const auto role = state->fields.find("role"); + return role != state->fields.end() && role->second.asString() && + *role->second.asString() == expected; + }; + + expect(bridge.setRole("observer", "another-connection") && + waitUntil([&] { return graphRoleIs("observer"); }), + "runtime enters observer role before receiving a reverse request"); + expect(bridge.appServerRequest("observer-input", "item/tool/requestUserInput", + {{"threadId", "runtime-thread"}, + {"turnId", "observer-turn"}, + {"itemId", "observer-question"}, + {"questions", nlohmann::json::array()}}, + "observer"), + "observer-delivered reverse request reaches the graph"); + const NodeRef observerInput = findNode( + runtime.graph(), + {NodeKind::Interaction, ProtocolRequestId("observer-input").canonical()}); + expect(static_cast(observerInput), + "observer-delivered request has one stable interaction"); + + expect(bridge.setRole("controller", "runtime-test") && + waitUntil([&] { return graphRoleIs("controller"); }), + "the same connection can subsequently claim controller"); + if (observerInput) { + NodeAction response{observerInput, NodeActionKind::ResolveInteraction}; + response.payload = { + {"answers", Value(Value::Object{{"question", Value("claimed")}})}}; + expect(sendAction(runtime.channels(), std::move(response)), + "new controller admits the earlier observer request response"); + const std::optional wire = bridge.receiveAppServer(); + expect(wire && wire->value("id", std::string{}) == "observer-input" && + wire->at("result").at("answers").at("question") == "claimed", + "current controller role, not receipt-time role, authorizes the " + "exact response"); + } + + expect(bridge.appServerRequest("lost-control-input", + "item/tool/requestUserInput", + {{"threadId", "runtime-thread"}, + {"turnId", "lost-control-turn"}, + {"itemId", "lost-control-question"}, + {"questions", nlohmann::json::array()}}), + "second reverse request arrives while controlled"); + const NodeRef lostControl = findNode( + runtime.graph(), {NodeKind::Interaction, + ProtocolRequestId("lost-control-input").canonical()}); + expect(bridge.setRole("observer", "another-connection") && + waitUntil([&] { return graphRoleIs("observer"); }), + "controller loss is visible before queued action consumption"); + if (lostControl) { + NodeAction rejected{lostControl, NodeActionKind::ResolveInteraction}; + rejected.payload = { + {"answers", Value(Value::Object{{"question", Value("preserved")}})}}; + expect(sendAction(runtime.channels(), std::move(rejected)), + "stale Qt response still enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker revalidation prevents a response after controller loss"); + expect(waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + if (!read) + return false; + const NodeRef current = read->find(lostControl->id()); + if (current != lostControl || + read->state(current)->status != NodeStatus::Failed) + return false; + const auto state = read->state(current); + const auto retained = + state->fields.find("retainedResponsePayload"); + return retained != state->fields.end() && + retained->second.asObject() && + retained->second.asObject()->contains("answers"); + }), + "worker rejection keeps the authored answer on the actionable " + "interaction for manual recovery"); + } + + const NodeRef thread = + findNode(runtime.graph(), {NodeKind::Thread, "runtime-thread"}); + if (thread) { + NodeAction rename{thread, NodeActionKind::Rename}; + rename.payload = {{"name", Value("must not leave observer")}}; + expect(sendAction(runtime.channels(), std::move(rename)), + "observer thread mutation enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker current-role validation blocks provider mutations"); + } + expect(bridge.setRole("controller", "runtime-test") && + waitUntil([&] { return graphRoleIs("controller"); }), + "runtime restores controller for remaining coverage"); + if (lostControl) { + NodeAction retry{lostControl, NodeActionKind::ResolveInteraction}; + retry.payload = { + {"answers", Value(Value::Object{{"question", Value("preserved")}})}}; + expect(sendAction(runtime.channels(), std::move(retry)), + "the user can explicitly resubmit the preserved response"); + const std::optional wire = bridge.receiveAppServer(); + expect(wire && wire->value("id", std::string{}) == "lost-control-input" && + wire->at("result").at("answers").at("question") == "preserved" && + waitUntil([&] { + return interactionRetired(runtime.graph(), lostControl); + }), + "manual recovery sends exactly once after control is restored"); + } + + expect(bridge.appServerNotification( + "turn/started", + {{"threadId", "runtime-thread"}, + {"turn", + {{"id", "already-finished-turn"}, {"status", "inProgress"}}}}), + "active turn fixture reaches the worker"); + const NodeRef finishedTurn = + findNode(runtime.graph(), + scopedTurnNodeId("runtime-thread", "already-finished-turn")); + expect(bridge.appServerNotification( + "turn/completed", + {{"threadId", "runtime-thread"}, + {"turn", + {{"id", "already-finished-turn"}, {"status", "completed"}}}}) && + waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + return read && finishedTurn && + read->state(finishedTurn)->status == + NodeStatus::Completed; + }), + "turn fixture is terminal before the stale stop action"); + if (finishedTurn) { + NodeAction stop{finishedTurn, NodeActionKind::InterruptTurn}; + expect(sendAction(runtime.channels(), std::move(stop)), + "stale stop enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker revalidation blocks interrupt for a non-active turn"); + } + + const auto threadArchived = [&](bool expected) { + std::optional read = runtime.graph().tryRead(); + if (!read || !thread) + return false; + const NodeRef current = read->find(thread->id()); + if (current != thread) + return false; + const auto state = read->state(current); + const auto archived = state->fields.find("archived"); + return archived != state->fields.end() && archived->second.asBool() && + *archived->second.asBool() == expected; + }; + expect(thread && + bridge.appServerNotification("thread/archived", + {{"threadId", "runtime-thread"}}) && + waitUntil([&] { return threadArchived(true); }), + "the worker observes an authoritative archive before a queued " + "duplicate action"); + if (thread) { + NodeAction duplicateArchive{thread, NodeActionKind::Archive}; + expect(sendAction(runtime.channels(), std::move(duplicateArchive)), + "a now-stale archive action still enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker lifecycle revalidation blocks duplicate archive"); + } + + expect(bridge.appServerNotification( + "turn/started", + {{"threadId", "runtime-thread"}, + {"turn", + {{"id", "archived-active-turn"}, {"status", "inProgress"}}}}), + "an out-of-order active turn can coexist with archived thread state"); + const NodeRef archivedActiveTurn = + findNode(runtime.graph(), + scopedTurnNodeId("runtime-thread", "archived-active-turn")); + if (archivedActiveTurn) { + NodeAction stop{archivedActiveTurn, NodeActionKind::InterruptTurn}; + stop.payload = {{"turnId", Value("stale-payload-turn")}}; + expect(sendAction(runtime.channels(), std::move(stop)), + "an archived-thread stop enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker revalidation prevents archived active state from reaching " + "CodexBridge"); + } + + expect(bridge.appServerNotification("thread/unarchived", + {{"threadId", "runtime-thread"}}) && + waitUntil([&] { return threadArchived(false); }), + "the authoritative thread returns to unarchived state"); + if (thread) { + NodeAction duplicateUnarchive{thread, NodeActionKind::Unarchive}; + expect(sendAction(runtime.channels(), std::move(duplicateUnarchive)), + "a now-stale unarchive action still enters the typed mailbox"); + expect(!bridge.receiveAppServer(200ms), + "worker lifecycle revalidation blocks duplicate unarchive"); + } + if (archivedActiveTurn) { + NodeAction stop{archivedActiveTurn, NodeActionKind::InterruptTurn}; + stop.payload = {{"threadId", Value("stale-payload-thread")}, + {"turnId", Value("stale-payload-turn")}}; + expect(sendAction(runtime.channels(), std::move(stop)), + "the current unarchived active turn admits a stop action"); + const std::optional request = bridge.receiveAppServer(); + expect(request && + request->value("method", std::string{}) == "turn/interrupt" && + request->at("params").value("threadId", std::string{}) == + "runtime-thread" && + request->at("params").value("turnId", std::string{}) == + "archived-active-turn", + "worker addressing replaces stale payload ids with the current " + "canonical active-turn address"); + if (request) + expect(bridge.reply(*request, nlohmann::json::object()), + "the revalidated interrupt response reaches the worker"); + } + + expect(bridge.appServerNotification("thread/started", + {{"thread", + {{"id", "removed-action-thread"}, + {"name", "Removed before dispatch"}, + {"cwd", "/tmp"}}}}), + "stale thread-action fixture reaches the graph"); + const NodeRef removedActionThread = + findNode(runtime.graph(), {NodeKind::Thread, "removed-action-thread"}); + expect(removedActionThread && + bridge.appServerNotification( + "thread/deleted", {{"threadId", "removed-action-thread"}}) && + waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + return read && !read->find(removedActionThread->id()); + }), + "the worker retires the exact action target before dispatch"); + if (removedActionThread) { + NodeAction staleRename{removedActionThread, NodeActionKind::Rename}; + staleRename.payload = {{"name", Value("must not reach provider")}}; + expect(sendAction(runtime.channels(), std::move(staleRename)), + "a removed target remains safely pinnable in the typed queue"); + expect(!bridge.receiveAppServer(200ms), + "worker membership revalidation blocks a removed thread target"); + } + + expect(bridge.appServerRequest("generation-input", + "item/tool/requestUserInput", + {{"threadId", "runtime-thread"}, + {"turnId", "generation-turn"}, + {"itemId", "generation-question"}, + {"questions", nlohmann::json::array()}}), + "generation-change request reaches the current graph"); + const NodeRef generationInput = findNode( + runtime.graph(), {NodeKind::Interaction, + ProtocolRequestId("generation-input").canonical()}); + expect(bridge.setProviderGeneration(2), + "a new provider generation reaches the runtime"); + std::size_t refreshes = 0; + while (refreshes != 3) { + const std::optional refresh = bridge.receiveAppServer(); + if (!refresh) + break; + nlohmann::json result{{"data", nlohmann::json::array()}}; + if (refresh->value("method", std::string{}) == "thread/list") + result["nextCursor"] = nullptr; + if (bridge.reply(*refresh, std::move(result))) + ++refreshes; + } + expect(refreshes == 3 && generationInput && waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + if (!read) + return false; + const NodeRef current = read->find(generationInput->id()); + if (current != generationInput || + read->state(current)->status != NodeStatus::Failed) + return false; + const auto state = read->state(current); + const auto recovery = state->fields.find("recoveryOnly"); + return recovery != state->fields.end() && + recovery->second.asBool() && *recovery->second.asBool(); + }), + "provider replacement keeps the expired interaction as explicit " + "response-recovery state"); + if (generationInput) { + NodeAction response{generationInput, NodeActionKind::ResolveInteraction}; + response.payload = { + {"answers", Value(Value::Object{{"question", Value("generation")}})}}; + expect(sendAction(runtime.channels(), std::move(response)), + "authored response can reach the worker after generation reset"); + expect(!bridge.receiveAppServer(200ms), + "expired-generation response is never sent to the replacement " + "provider"); + expect(waitUntil([&] { + std::optional read = + runtime.graph().tryRead(); + if (!read) + return false; + const NodeRef current = read->find(generationInput->id()); + if (current != generationInput) + return false; + const auto state = read->state(current); + const auto retained = + state->fields.find("retainedResponsePayload"); + return retained != state->fields.end() && + retained->second.asObject() && + retained->second.asObject()->contains("answers"); + }), + "generation rejection preserves newly authored response data in " + "the shared graph"); + } + runtime.drainNotifications(); +} + +void remainingReverseRequestFamiliesRoundTripExactlyOnce( + UnixBridge &bridge, RunningRuntime &runtime) { + const auto roundTrip = + [&](std::string id, std::string method, nlohmann::json requestPayload, + Value::Object responsePayload) -> std::optional { + expect(bridge.appServerRequest(id, method, std::move(requestPayload)), + method + " reaches CodexBridge"); + const NodeRef interaction = + findNode(runtime.graph(), + {NodeKind::Interaction, ProtocolRequestId(id).canonical()}); + expect(static_cast(interaction), + method + " creates its stable interaction node"); + if (!interaction) + return std::nullopt; + + NodeAction resolve{interaction, NodeActionKind::ResolveInteraction}; + resolve.payload = std::move(responsePayload); + expect(sendAction(runtime.channels(), std::move(resolve)), + method + " typed response enters the worker mailbox"); + std::optional response = bridge.receiveAppServer(); + expect(response && response->value("id", std::string{}) == id, + method + " response preserves the original JSON-RPC id"); + expect(!bridge.receiveAppServer(100ms), + method + " response is never dual-sent"); + expect(waitUntil([&] { + return interactionRetired(runtime.graph(), interaction); + }), + method + " response removes the exact interaction once"); + return response; + }; + + const nlohmann::json address{{"threadId", "runtime-thread"}, + {"turnId", "reverse-turn"}}; + + nlohmann::json fileChangeRequest = address; + fileChangeRequest["itemId"] = "reverse-file-change"; + fileChangeRequest["reason"] = "write the focused test"; + std::optional response = + roundTrip("file-change-runtime", "item/fileChange/requestApproval", + std::move(fileChangeRequest), {{"decision", Value("accept")}}); + expect(response && response->contains("result") && + response->at("result") == nlohmann::json{{"decision", "accept"}}, + "file-change approval encodes the authored decision exactly"); + + nlohmann::json elicitationRequest = address; + elicitationRequest["serverName"] = "test-mcp"; + elicitationRequest["message"] = "Choose a value"; + elicitationRequest["requestedSchema"] = nlohmann::json{{"type", "object"}}; + response = + roundTrip("mcp-runtime", "mcpServer/elicitation/request", + std::move(elicitationRequest), + {{"decision", Value("accept")}, + {"content", Value(Value::Object{{"choice", Value("safe")}})}, + {"_meta", Value(Value::Object{{"source", Value("qt")}})}}); + expect(response && response->contains("result") && + response->at("result") == + nlohmann::json{{"action", "accept"}, + {"content", {{"choice", "safe"}}}, + {"_meta", {{"source", "qt"}}}}, + "MCP elicitation encodes action, content, and metadata exactly"); + + const nlohmann::json requestedPermissions{ + {"fileSystem", {{"read", nlohmann::json::array({"/workspace"})}}}, + {"network", {{"enabled", false}}}}; + nlohmann::json permissionsRequest = address; + permissionsRequest["itemId"] = "reverse-permissions"; + permissionsRequest["permissions"] = requestedPermissions; + response = + roundTrip("permissions-runtime", "item/permissions/requestApproval", + std::move(permissionsRequest), + {{"decision", Value("accept")}, {"scope", Value("turn")}}); + expect(response && response->contains("result") && + response->at("result") == + nlohmann::json{{"permissions", requestedPermissions}, + {"scope", "turn"}}, + "permission approval returns the exact requested permission object " + "and authored scope"); + + nlohmann::json dynamicToolRequest = address; + dynamicToolRequest["callId"] = "reverse-dynamic-tool"; + dynamicToolRequest["tool"] = "unsupported_tool"; + dynamicToolRequest["arguments"] = nlohmann::json{{"value", 7}}; + response = roundTrip( + "dynamic-tool-runtime", "item/tool/call", std::move(dynamicToolRequest), + {{"contentItems", Value(Value::Array{Value(Value::Object{ + {"type", Value("inputText")}, + {"text", Value("Request declined by user")}})})}, + {"success", Value(false)}}); + expect(response && response->contains("result") && + response->at("result") == + nlohmann::json{{"contentItems", + nlohmann::json::array( + {{{"type", "inputText"}, + {"text", "Request declined by user"}}})}, + {"success", false}}, + "dynamic-tool response encodes one explicit failure content item"); + + response = + roundTrip("auth-refresh-runtime", "account/chatgptAuthTokens/refresh", + {{"reason", "expired"}}, {}); + expect(response && response->contains("error") && + response->at("error").value("code", 0) == -32601 && + response->at("error").value("message", std::string{}) == + "CodexUI does not support authentication token refresh", + "authentication refresh emits the explicit unsupported error"); + + response = roundTrip("attestation-runtime", "attestation/generate", + {{"challenge", "challenge-value"}}, {}); + expect(response && response->contains("error") && + response->at("error").value("code", 0) == -32601 && + response->at("error").value("message", std::string{}) == + "CodexUI does not support attestation generation", + "attestation generation emits the explicit unsupported error"); + + const Value::Object deniedDecision{ + {"denied", + Value(Value::Object{{"rejection", Value("Denied by focused test")}})}}; + response = roundTrip("apply-patch-runtime", "applyPatchApproval", + {{"conversationId", "runtime-thread"}, + {"callId", "reverse-apply-patch"}, + {"fileChanges", nlohmann::json::object()}}, + {{"decision", Value(deniedDecision)}}); + expect(response && response->contains("result") && + response->at("result") == + nlohmann::json{ + {"decision", + {{"denied", {{"rejection", "Denied by focused test"}}}}}}, + "apply-patch approval preserves an authored structured decision"); + + response = roundTrip("exec-command-runtime", "execCommandApproval", + {{"conversationId", "runtime-thread"}, + {"callId", "reverse-exec"}, + {"command", nlohmann::json::array({"printf", "test"})}, + {"cwd", "/tmp"}}, + {{"decision", Value("acceptForSession")}}); + expect(response && response->contains("result") && + response->at("result") == + nlohmann::json{{"decision", "approved_for_session"}}, + "exec-command approval maps the authored session decision exactly"); + + runtime.drainNotifications(); +} + +void unknownInboundIsRetainedWithoutCorruptingKnownState( + UnixBridge &bridge, RunningRuntime &runtime) { + constexpr std::string_view Method = "future/runtime-event"; + expect(bridge.appServerNotification( + std::string(Method), {{"threadId", "runtime-thread"}, + {"name", "must-not-overwrite-known-thread"}, + {"futureValue", 17}}), + "unknown inbound notification reaches CodexBridge's raw hook"); + + const std::string unknownId = std::to_string(static_cast( + ProtocolDirection::ServerNotification)) + + ":" + std::string(Method); + const NodeRef unknown = + findNode(runtime.graph(), {NodeKind::UnknownProtocol, unknownId}); + expect(static_cast(unknown), + "unknown inbound notification is retained in the shared graph"); + + std::optional read = runtime.graph().tryRead(); + const NodeRef thread = + read ? read->find({NodeKind::Thread, "runtime-thread"}) : NodeRef{}; + const auto unknownState = read && unknown ? read->state(unknown) : nullptr; + const auto threadState = read && thread ? read->state(thread) : nullptr; + const auto unknownMethod = unknownState ? unknownState->fields.find("method") + : Value::Object::const_iterator{}; + const auto knownName = threadState ? threadState->fields.find("name") + : Value::Object::const_iterator{}; + expect(unknownState && unknownMethod != unknownState->fields.end() && + unknownMethod->second.asString() && + *unknownMethod->second.asString() == Method, + "unknown graph state identifies the unrecognized method"); + expect(threadState && knownName != threadState->fields.end() && + knownName->second.asString() && + *knownName->second.asString() == "Runtime dispatch", + "unknown addressed fields cannot mutate a known thread"); + runtime.drainNotifications(); +} + +} // namespace +} // namespace codexui::codex + +int main(int argc, char **argv) { + auto *configuration = + utils::Config::configRoot.newSubCommand(); + core::SNodeC::init(argc, argv); + + codexui::codex::UnixBridge bridge; + codexui::codex::expect(bridge.valid(), + "test bridge creates a private Unix listener"); + if (!bridge.valid()) + return EXIT_FAILURE; + + codexui::codex::RunningRuntime runtime(*configuration); + runtime.start(); + const bool ready = codexui::codex::establishProvider(bridge, runtime); + codexui::codex::expect( + ready, "runtime connects and performs one initial provider hydration"); + if (ready) { + codexui::codex::idleWorkerSleepsBetweenWakeRecoveryChecks(runtime); + codexui::codex::protocolDiagnosticsPreserveMetadataWithoutPayloads(bridge, + runtime); + codexui::codex::directNodeActionsUseOneCorrelatedRequest(bridge, runtime); + codexui::codex::remainingUiCommandFamiliesUseExactWirePaths(bridge, + runtime); + codexui::codex::runtimeRefreshActionsHaveExactRequestCardinality(bridge, + runtime); + codexui::codex::failedWakeUsesBoundedWorkerRecovery(bridge, runtime); + codexui::codex::reverseInteractionsRespondOnceWithAuthoredData(bridge, + runtime); + codexui::codex::remainingReverseRequestFamiliesRoundTripExactlyOnce( + bridge, runtime); + codexui::codex::unknownInboundIsRetainedWithoutCorruptingKnownState( + bridge, runtime); + codexui::codex::workerRevalidatesCurrentAuthorityAndRetainsResponses( + bridge, runtime); + } + runtime.channels().failNextQtToWorkerWakeForTest(); + const auto shutdownStarted = std::chrono::steady_clock::now(); + runtime.stop(); + codexui::codex::expect( + std::chrono::steady_clock::now() - shutdownStarted < + std::chrono::seconds(2), + "an unwoken ShutdownRequest is consumed without hanging worker join"); + + if (codexui::codex::failures != 0) { + std::cerr << codexui::codex::failures + << " client-runtime dispatch assertion(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "ClientRuntime typed dispatch integration test passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/codex/ConversationCardsTest.cpp b/tests/codex/ConversationCardsTest.cpp index befee16..a7241b0 100644 --- a/tests/codex/ConversationCardsTest.cpp +++ b/tests/codex/ConversationCardsTest.cpp @@ -2,6 +2,8 @@ #include "codex/middle/ConversationCards.h" #include "codex/middle/ConversationView.h" +#include "codex/nodegraph/NodeGraph.h" +#include "codex/ui/QtNodeAttachment.h" #include "codex/ui/UiStyle.h" #include @@ -35,6 +37,8 @@ #include #include #include +#include +#include #include #include #include @@ -82,6 +86,14 @@ class LayoutRequestProbe final : public QObject { }; void spin(int milliseconds = 0) { + if (milliseconds == 0) { + // One selected/load-more page is admitted in eight-card slices. Drain a + // bounded page worth of zero-delay continuations without turning every + // test settle into an arbitrary wall-clock delay. + for (int pass = 0; pass < 16; ++pass) + QCoreApplication::processEvents(QEventLoop::AllEvents, 2); + return; + } QElapsedTimer timer; timer.start(); do { @@ -91,6 +103,34 @@ void spin(int milliseconds = 0) { } while (timer.elapsed() < milliseconds); } +template +bool spinUntil(Predicate &&predicate, int maximumPasses = 64) { + for (int pass = 0; pass < maximumPasses; ++pass) { + if (predicate()) + return true; + // Zero-delay continuations normally drain in one event dispatch, while + // contention recovery deliberately uses a small nonzero timer. Give both + // paths a real event-loop tick without assuming synchronous completion. + spin(1); + } + return predicate(); +} + +template +bool dispatchUntil(Predicate &&predicate, int maximumPasses = 512) { + for (int pass = 0; pass < maximumPasses; ++pass) { + if (predicate()) + return true; + QCoreApplication::processEvents(QEventLoop::AllEvents); + } + return predicate(); +} + +void dispatchPasses(int count) { + for (int pass = 0; pass < count; ++pass) + QCoreApplication::processEvents(QEventLoop::AllEvents); +} + VisibleCardData agentCard(const std::string &threadId, const std::string &turnId, int index, QString text = {}) { @@ -168,11 +208,282 @@ VisibleCardData cardForAppearanceAudit(const std::string &threadId, return {std::move(key), kind, threadId, "turn-2", itemId, std::move(payload)}; } -ConversationSnapshot conversation(const std::string &threadId, int count) { - ConversationSnapshot result; +// ConversationView is graph-only. These concise fixture records keep the +// presentation-oriented test cases readable while applyConversation writes +// their current facts into the same NodeGraph shape used by the application. +struct TurnGraphSpec { + std::string key; + std::string turnId; + std::vector cards; + std::optional rootCardKey; +}; + +struct FixtureGraph final { + nodegraph::NodeGraph graph; +}; + +struct ConversationGraphSpec { + std::string threadId; + std::vector sections; + std::size_t hiddenAuthoritativeItemCount = 0; + bool hasMore = false; + std::optional activeTurnId; + mutable std::shared_ptr storage = + std::make_shared(); +}; + +nodegraph::Value graphValue(const nlohmann::json &value) { + if (value.is_null()) + return nullptr; + if (value.is_boolean()) + return value.get(); + if (value.is_number_unsigned()) + return value.get(); + if (value.is_number_integer()) + return value.get(); + if (value.is_number_float()) + return value.get(); + if (value.is_string()) + return value.get(); + if (value.is_array()) { + nodegraph::Value::Array result; + result.reserve(value.size()); + for (const nlohmann::json &entry : value) + result.push_back(graphValue(entry)); + return result; + } + nodegraph::Value::Object result; + for (const auto &[key, entry] : value.items()) + result.emplace(key, graphValue(entry)); + return result; +} + +nodegraph::NodeStatus graphStatus(std::string_view status) { + if (status == "pending" || status == "inProgress" || status == "running") + return nodegraph::NodeStatus::Running; + if (status == "completed") + return nodegraph::NodeStatus::Completed; + if (status == "failed") + return nodegraph::NodeStatus::Failed; + if (status == "interrupted") + return nodegraph::NodeStatus::Interrupted; + return nodegraph::NodeStatus::Unknown; +} + +nodegraph::Value stringArray(const std::vector &values) { + nodegraph::Value::Array result; + result.reserve(values.size()); + for (const std::string &value : values) + result.emplace_back(value); + return result; +} + +std::string fixtureNodeId(const VisibleCardData &card) { + if (const auto *authoritative = std::get_if(&card.key)) + return authoritative->itemId; + if (const auto *prompt = std::get_if(&card.key)) + return "fixture-local-prompt:" + std::to_string(prompt->submissionId); + return stableKey(card.key); +} + +nodegraph::NodeState fixtureNodeState(const VisibleCardData &card) { + nodegraph::NodeState state; + auto &fields = state.fields; + switch (card.kind) { + case CardKind::UserMessage: { + const auto &data = std::get(card.payload); + fields.emplace("type", "userMessage"); + fields.emplace("text", data.text); + nodegraph::Value::Array content; + for (const std::string &path : data.imagePaths) { + nodegraph::Value::Object image; + image.emplace("type", "localImage"); + image.emplace("path", path); + content.emplace_back(std::move(image)); + } + fields.emplace("content", std::move(content)); + state.status = nodegraph::NodeStatus::Completed; + break; + } + case CardKind::AgentMessage: { + const auto &data = std::get(card.payload); + fields.emplace("type", "agentMessage"); + fields.emplace("text", data.text); + fields.emplace("phase", data.finalAnswer ? "final_answer" : "commentary"); + state.status = nodegraph::NodeStatus::Completed; + break; + } + case CardKind::CommandExecution: { + const auto &data = std::get(card.payload); + fields.emplace("type", "commandExecution"); + fields.emplace("command", data.command); + fields.emplace("aggregatedOutput", data.output); + fields.emplace("status", data.status); + fields.emplace("cwd", data.cwd); + if (data.exitCode) + fields.emplace("exitCode", *data.exitCode); + if (data.durationMilliseconds) + fields.emplace("durationMs", *data.durationMilliseconds); + state.status = graphStatus(data.status); + break; + } + case CardKind::AgentActivity: { + const auto &data = std::get(card.payload); + fields.emplace("type", "collabAgentToolCall"); + fields.emplace("tool", data.tool); + fields.emplace("status", data.status); + fields.emplace("kind", data.kind); + fields.emplace("prompt", data.prompt); + fields.emplace("resultText", data.resultText); + fields.emplace("receiverThreadIds", stringArray(data.receivers)); + fields.emplace("model", data.model); + fields.emplace("reasoningEffort", data.reasoningEffort); + fields.emplace("agentThreadId", data.childThreadId); + fields.emplace("agentPath", data.agentPath); + fields.emplace("senderThreadId", data.senderThreadId); + state.status = graphStatus(data.status); + break; + } + case CardKind::Reasoning: { + fields.emplace("type", "reasoning"); + fields.emplace("summary", std::get(card.payload).summary); + state.status = nodegraph::NodeStatus::Completed; + break; + } + case CardKind::FileChanges: { + const auto &data = std::get(card.payload); + fields.emplace("type", "fileChange"); + fields.emplace("status", data.status); + nodegraph::Value::Array changes; + for (const FileChangeData &change : data.changes) { + nodegraph::Value::Object entry; + entry.emplace("path", change.path); + entry.emplace("kind", change.kind); + std::string diff; + for (int index = 0; index < change.additions.value_or(0); ++index) + diff += "+added\n"; + for (int index = 0; index < change.deletions.value_or(0); ++index) + diff += "-removed\n"; + entry.emplace("diff", std::move(diff)); + changes.emplace_back(std::move(entry)); + } + fields.emplace("changes", std::move(changes)); + state.status = graphStatus(data.status); + break; + } + case CardKind::ImageGeneration: { + const auto &data = std::get(card.payload); + fields.emplace("type", "imageGeneration"); + fields.emplace("path", data.path); + fields.emplace("status", data.status); + fields.emplace("revisedPrompt", data.revisedPrompt); + state.status = graphStatus(data.status); + break; + } + case CardKind::Plan: { + const auto &data = std::get(card.payload); + fields.emplace("type", "plan"); + fields.emplace("text", data.legacyText); + fields.emplace("planExplanation", data.explanation); + nodegraph::Value::Array steps; + for (const PlanStepData &step : data.steps) { + nodegraph::Value::Object entry; + entry.emplace("step", step.text); + entry.emplace("status", step.status); + steps.emplace_back(std::move(entry)); + } + fields.emplace("plan", std::move(steps)); + state.status = nodegraph::NodeStatus::Running; + break; + } + case CardKind::GenericActivity: { + const auto &data = std::get(card.payload); + const nodegraph::Value raw = graphValue(data.raw); + if (const auto *object = raw.asObject()) + fields = *object; + fields.insert_or_assign("type", data.type); + fields.insert_or_assign("status", data.status); + if (!data.displayDetail.empty()) + fields.insert_or_assign("detail", data.displayDetail); + state.status = graphStatus(data.status); + break; + } + case CardKind::LocalPrompt: { + const auto &data = std::get(card.payload); + fields.emplace("type", "localPrompt"); + fields.emplace("submissionId", data.submissionId); + fields.emplace("text", data.prompt); + fields.emplace("error", data.error); + const char *dispatch = "queued"; + switch (data.state) { + case PromptState::Queued: + break; + case PromptState::InFlight: + dispatch = "inFlight"; + break; + case PromptState::Accepted: + dispatch = "awaitingMaterialization"; + break; + case PromptState::Failed: + dispatch = "failed"; + break; + } + fields.emplace("dispatchState", dispatch); + fields.emplace("showPendingAnimation", data.showPendingAnimation); + if (data.admittedAtMs) + fields.emplace("admittedAtMs", *data.admittedAtMs); + nodegraph::Value::Array attachments; + for (const std::string &path : data.imagePaths) { + nodegraph::Value::Object attachment; + attachment.emplace("path", path); + attachment.emplace("mimeType", "image/test"); + attachments.emplace_back(std::move(attachment)); + } + fields.emplace("attachments", std::move(attachments)); + state.status = data.state == PromptState::Failed + ? nodegraph::NodeStatus::Failed + : nodegraph::NodeStatus::Running; + break; + } + } + return state; +} + +struct BoundFixture final { + std::shared_ptr storage; + nodegraph::NodeRef thread; +}; + +std::unordered_map &fixtureBindings() { + static std::unordered_map bindings; + return bindings; +} + +ConversationSnapshot +projectConversation(const ConversationGraphSpec &snapshot) { + ConversationSnapshot projected; + projected.threadId = snapshot.threadId; + projected.hiddenAuthoritativeItemCount = + snapshot.hiddenAuthoritativeItemCount; + projected.hasMore = snapshot.hasMore; + projected.activeTurnId = snapshot.activeTurnId; + projected.sections.reserve(snapshot.sections.size()); + for (const TurnGraphSpec §ion : snapshot.sections) + projected.sections.push_back( + {section.key, section.turnId, section.cards, section.rootCardKey}); + return projected; +} + +bool applyConversation(ConversationView &view, + const ConversationGraphSpec &snapshot) { + return view.reconcile(projectConversation(snapshot)); +} + +ConversationGraphSpec conversation(const std::string &threadId, int count) { + ConversationGraphSpec result; result.threadId = threadId; - TurnSection first{"turn:" + threadId + ":1", "turn-1", {}}; - TurnSection second{"turn:" + threadId + ":2", "turn-2", {}}; + TurnGraphSpec first{"turn:" + threadId + ":1", "turn-1", {}}; + TurnGraphSpec second{"turn:" + threadId + ":2", "turn-2", {}}; for (int index = 0; index < count; ++index) (index < count / 2 ? first : second) .cards.push_back(agentCard( @@ -182,6 +493,78 @@ ConversationSnapshot conversation(const std::string &threadId, int count) { return result; } +struct GraphConversationFixture final { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef reasoning; + std::vector messages; + + GraphConversationFixture() { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{65}); + threadState.fields.emplace("hydrationState", "ready"); + thread = write.upsert({nodegraph::NodeKind::Thread, "graph-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "graph-turn"}); + write.setParent(thread, turn); + + nodegraph::NodeState reasoningState; + reasoningState.status = nodegraph::NodeStatus::Completed; + reasoningState.fields.emplace("type", "reasoning"); + reasoningState.fields.emplace("summary", "latest hidden reasoning"); + reasoning = write.upsert({nodegraph::NodeKind::Item, "reasoning-item"}, + std::move(reasoningState)); + write.setParent(turn, reasoning); + + messages.reserve(64); + for (int index = 0; index < 64; ++index) { + nodegraph::NodeState state; + state.status = nodegraph::NodeStatus::Completed; + state.fields.emplace("type", "agentMessage"); + state.fields.emplace("phase", "final_answer"); + state.fields.emplace("text", "Graph message " + std::to_string(index)); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "graph-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + messages.push_back(std::move(item)); + } + static_cast(write.finish()); + } +}; + +ui::QtNodeAttachment *graphAttachment(const nodegraph::NodeRef &node) { + return node ? static_cast(node->uiAttachment()) + : nullptr; +} + +nodegraph::NodeState graphMessageState(std::string type, std::string text) { + nodegraph::NodeState state; + state.status = nodegraph::NodeStatus::Completed; + state.fields.emplace("type", std::move(type)); + state.fields.emplace("text", std::move(text)); + return state; +} + +QPushButton *historyButton(ConversationView &view) { + const auto buttons = view.findChildren(); + const auto found = std::ranges::find_if(buttons, [](QPushButton *button) { + return button->property("kind").toString() == QStringLiteral("history"); + }); + return found == buttons.end() ? nullptr : *found; +} + +QLabel *conversationEmptyLabel(ConversationView &view) { + const auto labels = view.findChildren(); + const auto found = std::ranges::find_if(labels, [](QLabel *label) { + return label->text() == + QStringLiteral("Conversation activity appears here."); + }); + return found == labels.end() ? nullptr : *found; +} + bool testMessageIdentityPalette() { const QString originalStyleSheet = qApp->styleSheet(); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); @@ -217,18 +600,23 @@ bool testMessageIdentityPalette() { titleColor(user) == QColor(QString::fromLatin1(codexui::UiStyle::blueHover)) && surfaceColor(user) == QColor(QStringLiteral("#eaf2ff")) && - surfaceColor(update) == - QColor(QString::fromLatin1(codexui::UiStyle::panel)) && + titleColor(update) == + QColor(QString::fromLatin1(codexui::UiStyle::yellowText)) && + surfaceColor(update) == QColor( + QString::fromLatin1( + codexui::UiStyle::yellowSurface)) && titleColor(final) == QColor(QString::fromLatin1(codexui::UiStyle::purpleText)) && surfaceColor(final) == QColor(QString::fromLatin1(codexui::UiStyle::purpleSurface)), - "You is blue, interim Codex is neutral, and final Codex is violet"); + "You is blue, interim Codex is yellow, and final Codex is violet"); qApp->setStyleSheet(originalStyleSheet); return result; } bool testActiveWorkBordersFollowStatus() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); VisibleCardData command{ AuthoritativeItemKey{"active-border", "turn", "command"}, CardKind::CommandExecution, @@ -237,17 +625,26 @@ bool testActiveWorkBordersFollowStatus() { "command", CommandExecutionData{"sleep 1", {}, "inProgress", {}, {}, {}}}; ConversationCard commandCard(command); + commandCard.resize(560, commandCard.sizeHint().height()); + commandCard.show(); + spin(); auto *commandStatus = commandCard.findChild(QStringLiteral("commandStatus")); + const auto emphasizedAtMidpoint = [](ConversationCard &card) { + const QImage frame = card.grab().toImage(); + return frame.pixelColor(1, frame.height() / 2).red() < 180; + }; bool result = expect( commandCard.property("activeWork").toBool() && commandStatus && + emphasizedAtMidpoint(commandCard) && commandStatus->property("tone").toString() == QStringLiteral("active"), "a running command uses the emphasized card border and active header " "status"); std::get(command.payload).status = "completed"; result &= expect(commandCard.apply(command) && - !commandCard.property("activeWork").toBool(), + !commandCard.property("activeWork").toBool() && + !emphasizedAtMidpoint(commandCard), "a completed command returns to the normal card border"); VisibleCardData image{AuthoritativeItemKey{"active-border", "turn", "image"}, @@ -273,6 +670,7 @@ bool testActiveWorkBordersFollowStatus() { imageStatus->property("tone").toString() == QStringLiteral("success"), "a loaded figure returns to the normal card border and success header " "status"); + qApp->setStyleSheet(originalStyleSheet); return result; } @@ -328,6 +726,74 @@ std::vector visualCardKeys(ConversationView &view) { return keys; } +bool hasConversationItem(ConversationView &view, const std::string &key) { + return std::ranges::any_of( + view.findChildren(), [&key](QWidget *widget) { + return widget->property("conversationAnchorKey").toString() == + QString::fromStdString(key); + }); +} + +struct LiveConversationWidgetCounts final { + int cards = 0; + int itemPlaceholders = 0; + int turnSections = 0; + + [[nodiscard]] int itemRepresentations() const noexcept { + return cards + itemPlaceholders; + } +}; + +LiveConversationWidgetCounts +liveConversationWidgetCounts(ConversationView &view) { + LiveConversationWidgetCounts result; + for (QWidget *widget : view.findChildren()) { + if (dynamic_cast(widget)) + ++result.cards; + if (widget->objectName() == QStringLiteral("conversationCardPlaceholder")) + ++result.itemPlaceholders; + if (widget->property("turnSectionKey").isValid()) + ++result.turnSections; + } + return result; +} + +bool graphPassBudgetsWereRespected(const ConversationView &view) { + const QVariant structure = view.property("graphMaxStructureReadsPerPass"); + const QVariant geometry = view.property("graphMaxGeometryRecordsPerPass"); + const QVariant cards = view.property("graphMaxCardOperationsPerPass"); + return structure.isValid() && geometry.isValid() && cards.isValid() && + structure.toULongLong() > 0 && structure.toULongLong() <= 64 && + geometry.toULongLong() > 0 && geometry.toULongLong() <= 32 && + cards.toULongLong() > 0 && cards.toULongLong() <= 8; +} + +bool graphRefreshWasConstantBounded(const ConversationView &view) { + const QVariant reads = view.property("graphLastRefreshStructureReads"); + return reads.isValid() && reads.toULongLong() <= 64; +} + +std::size_t graphLiveRecordBound(const ConversationView &view) { + constexpr int EstimatedItemExtent = 66; + const std::size_t viewportItems = static_cast( + std::max(1, view.viewport()->height()) / EstimatedItemExtent + 1); + // The visible viewport plus one bounded viewport of overscan on each side. + // A co-visible explicit root is part of this same record budget. + return std::max(8, viewportItems * 3); +} + +bool graphViewportWidgetsAreBounded(ConversationView &view) { + const LiveConversationWidgetCounts widgets = + liveConversationWidgetCounts(view); + const QVariant live = view.property("graphLiveRecordCount"); + const QVariant sections = view.property("graphLiveSectionCount"); + const std::size_t bound = graphLiveRecordBound(view); + return live.isValid() && sections.isValid() && live.toULongLong() <= bound && + sections.toULongLong() <= bound && + static_cast(widgets.itemRepresentations()) <= bound && + static_cast(widgets.turnSections) <= bound; +} + QToolButton *disclosure(ConversationCard *card) { return card ? card->findChild( QStringLiteral("cardDisclosureButton")) @@ -366,12 +832,13 @@ bool setFolded(ConversationCard *card, bool collapsed) { return false; if (card->isCollapsed() == collapsed) return true; + QPointer guard(card); QToolButton *button = disclosure(card); if (!button) return false; button->click(); spin(); - return card->isCollapsed() == collapsed; + return guard && guard->isCollapsed() == collapsed; } std::pair firstVisible(ConversationView &view) { @@ -404,12 +871,21 @@ class PaintAnchorProbe final : public QObject { void start(QWidget *tracked = nullptr) { anchors.clear(); trackedGeometries.clear(); + representationCounts.clear(); tracked_ = tracked; active = true; } + void trackOwnership(QWidget *owner, QWidget *child) { + owner_ = owner; + child_ = child; + ownership.clear(); + } + std::vector> anchors; std::vector trackedGeometries; + std::vector representationCounts; + std::vector ownership; bool active = false; protected: @@ -417,16 +893,27 @@ class PaintAnchorProbe final : public QObject { if (active && watched == view_.viewport() && event->type() == QEvent::Paint) { anchors.push_back(firstVisible(view_)); + representationCounts.push_back(static_cast(std::ranges::count_if( + view_.findChildren(), [](QWidget *widget) { + return dynamic_cast(widget) || + widget->objectName() == + QStringLiteral("conversationCardPlaceholder"); + }))); if (tracked_) trackedGeometries.emplace_back( tracked_->mapTo(view_.viewport(), QPoint{}), tracked_->size()); + if (owner_ && child_) + ownership.push_back(owner_->property("turnContainer").toBool() && + owner_->isAncestorOf(child_)); } return false; } private: ConversationView &view_; - QWidget *tracked_ = nullptr; + QPointer tracked_; + QPointer owner_; + QPointer child_; }; void wheel(ConversationView &view, int pixelDelta) { @@ -451,32 +938,34 @@ bool testStructuralOrderAndIdentity() { ConversationView view; view.resize(620, 420); view.show(); - ConversationSnapshot snapshot = conversation("structural-order", 8); - view.reconcile(snapshot); + ConversationGraphSpec snapshot = conversation("structural-order", 8); + applyConversation(view, snapshot); spin(); std::unordered_map identities; - for (const TurnSection §ion : snapshot.sections) + for (const TurnGraphSpec §ion : snapshot.sections) for (const VisibleCardData &value : section.cards) identities.emplace(stableKey(value.key), card(view, stableKey(value.key))); - for (TurnSection §ion : snapshot.sections) + for (TurnGraphSpec §ion : snapshot.sections) std::ranges::reverse(section.cards); std::ranges::reverse(snapshot.sections); std::vector expectedKeys; - for (const TurnSection §ion : snapshot.sections) + for (const TurnGraphSpec §ion : snapshot.sections) for (const VisibleCardData &value : section.cards) expectedKeys.push_back(stableKey(value.key)); - bool result = - expect(view.reconcile(snapshot), "structural order changes reconcile"); + bool result = expect(applyConversation(view, snapshot), + "structural order changes reconcile"); spin(); result &= expect(visualCardKeys(view) == expectedKeys, "section and card order follows the projection exactly"); bool retainedIdentity = true; - for (const auto &[key, identity] : identities) - retainedIdentity = retainedIdentity && card(view, key) == identity; + for (const auto &[key, identity] : identities) { + ConversationCard *current = card(view, key); + retainedIdentity = retainedIdentity && current == identity; + } result &= expect(retainedIdentity, "structural moves preserve same-kind card identity"); @@ -489,7 +978,7 @@ bool testStructuralOrderAndIdentity() { "later-user", UserMessageData{"Later prompt", {}}}; VisibleCardData activity = agentCard(pagingThread, "turn", 50); - ConversationSnapshot paged{ + ConversationGraphSpec paged{ pagingThread, {{"turn:paged", "turn", {laterPrompt, activity}, laterPrompt.key}}, 0, @@ -497,7 +986,7 @@ bool testStructuralOrderAndIdentity() { ConversationView pagedView; pagedView.resize(620, 420); pagedView.show(); - pagedView.reconcile(paged); + applyConversation(pagedView, paged); spin(); ConversationCard *laterRoot = card(pagedView, stableKey(laterPrompt.key)); ConversationCard *activityCard = card(pagedView, stableKey(activity.key)); @@ -511,7 +1000,7 @@ bool testStructuralOrderAndIdentity() { paged.sections.front().cards.insert(paged.sections.front().cards.begin(), earlierPrompt); paged.sections.front().rootCardKey = earlierPrompt.key; - result &= expect(pagedView.reconcile(paged), + result &= expect(applyConversation(pagedView, paged), "older history can introduce the real turn prompt"); spin(); ConversationCard *earlierRoot = card(pagedView, stableKey(earlierPrompt.key)); @@ -525,7 +1014,7 @@ bool testStructuralOrderAndIdentity() { "history paging replaces and flattens the visible turn root"); paged.sections.front().cards.erase(paged.sections.front().cards.begin()); - result &= expect(pagedView.reconcile(paged), + result &= expect(applyConversation(pagedView, paged), "a transient projection can omit the declared root"); spin(); result &= @@ -537,8 +1026,8 @@ bool testStructuralOrderAndIdentity() { paged.sections.front().cards.insert(paged.sections.front().cards.begin(), earlierPrompt); - result &= - expect(pagedView.reconcile(paged), "the declared turn root can return"); + result &= expect(applyConversation(pagedView, paged), + "the declared turn root can return"); spin(); ConversationCard *restoredRoot = card(pagedView, stableKey(earlierPrompt.key)); @@ -557,8 +1046,9 @@ bool testFollowPauseAndStableAnchor() { ConversationView view; view.resize(620, 340); view.show(); - ConversationSnapshot snapshot = conversation("thread-a", 34); - bool result = expect(view.reconcile(snapshot), "initial projection renders"); + ConversationGraphSpec snapshot = conversation("thread-a", 34); + bool result = + expect(applyConversation(view, snapshot), "initial projection renders"); spin(); QScrollArea nativeReference; nativeReference.setWidgetResizable(true); @@ -578,7 +1068,8 @@ bool testFollowPauseAndStableAnchor() { const int oldValue = view.verticalScrollBar()->value(); snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 34)); - result &= expect(view.reconcile(snapshot), "a new card materializes"); + result &= + expect(applyConversation(view, snapshot), "a new card materializes"); int previous = view.verticalScrollBar()->value(); bool monotonic = previous >= oldValue; QElapsedTimer animation; @@ -621,13 +1112,13 @@ bool testFollowPauseAndStableAnchor() { } snapshot.sections.back().cards.push_back(agentCard("thread-a", "turn-2", 35)); LayoutRequestProbe layoutRequests(&view); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "paused incoming changes still materialize"); layoutRequests.start(); spin(); - result &= expect(layoutRequests.count <= 12, + result &= expect(layoutRequests.count <= 24, "a paused append leaves only bounded ancestor/new-card " - "layout settlement, not per-card deferred work"); + "layout settlement across sliced graph rendering"); const auto after = firstVisible(view); result &= expect(after.first == anchor.first && std::abs(after.second - anchor.second) <= 1, @@ -638,7 +1129,7 @@ bool testFollowPauseAndStableAnchor() { const int unchangedValue = view.verticalScrollBar()->value(); const auto unchangedAnchor = firstVisible(view); - result &= expect(!view.reconcile(snapshot), + result &= expect(!applyConversation(view, snapshot), "an identical visible projection is a true no-op"); spin(); result &= expect(view.verticalScrollBar()->value() == unchangedValue && @@ -656,7 +1147,7 @@ bool testFollowPauseAndStableAnchor() { auto &upstream = std::get( snapshot.sections.front().cards.front().payload); upstream.text += "\nTrack-action upstream reflow.\nSecond line.\nThird line."; - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "page-action coverage applies an upstream reflow"); spin(); const auto afterPageStepReflow = firstVisible(view); @@ -671,7 +1162,9 @@ bool testPausedExpandedCommandStaysPainted() { const QString originalStyleSheet = qApp->styleSheet(); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); const std::string thread = "paused-expanded-command"; - ConversationSnapshot snapshot = conversation(thread, 24); + ConversationGraphSpec snapshot = conversation( + thread, qEnvironmentVariableIsSet("CODEXUI_CONVERSATION_TIMINGS") ? 80 + : 24); QString output; for (int line = 0; line < 48; ++line) output += QStringLiteral("completed command output %1\n").arg(line); @@ -686,22 +1179,25 @@ bool testPausedExpandedCommandStaysPainted() { snapshot.sections.back().cards.insert( snapshot.sections.back().cards.begin(), cardForAppearanceAudit(thread, CardKind::UserMessage, 99)); + snapshot.sections.back().rootCardKey = + snapshot.sections.back().cards.front().key; snapshot.sections.back().cards.push_back(completedCommand); ConversationView view; view.resize(620, 420); view.show(); - bool result = expect(view.reconcile(snapshot), + bool result = expect(applyConversation(view, snapshot), "expanded-command audit renders its conversation"); spin(); - ConversationCard *const commandCard = + QPointer commandCard = card(view, stableKey(completedCommand.key)); result &= expect(setFolded(commandCard, false), "completed command is expanded before incoming cards"); - auto *outputView = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; + QPointer outputView = + commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; if (outputView && outputView->verticalScrollBar()->maximum() > 0) { outputView->verticalScrollBar()->setValue( outputView->verticalScrollBar()->maximum() / 2); @@ -723,25 +1219,71 @@ bool testPausedExpandedCommandStaysPainted() { return candidate.first == reference.first && std::abs(candidate.second - reference.second) <= 1; }; + bool allIncomingCardsMaterialized = true; for (std::size_t index = 0; index < incomingKinds.size(); ++index) { + if (!commandCard) { + result &= expect(false, "incoming activity retains the visible expanded " + "command QWidget"); + break; + } const auto anchorBefore = firstVisible(view); const QRect commandBefore(commandCard->mapTo(view.viewport(), QPoint{}), commandCard->size()); const auto outputStateBefore = commandCard->commandOutputScrollState(); + const qulonglong fullGeometryBefore = + view.property("conversationGeometryPasses").toULongLong(); + const qulonglong localGeometryBefore = + view.property("conversationLocalGeometryPasses").toULongLong(); + const qulonglong cachedAppendBefore = + view.property("conversationCachedAppendGeometryPasses").toULongLong(); + const qulonglong structuralCommitsBefore = + view.property("incrementalStructuralCommits").toULongLong(); + const qulonglong stageCommitsBefore = + view.property("structuralStageCommits").toULongLong(); snapshot.sections.back().cards.push_back(cardForAppearanceAudit( thread, incomingKinds[index], 100 + static_cast(index))); + const std::string incomingKey = + stableKey(snapshot.sections.back().cards.back().key); paintProbe.start(commandCard); - const bool changed = view.reconcile(snapshot); + QElapsedTimer insertionTimer; + insertionTimer.start(); + view.reconcileStaged(projectConversation(snapshot)); + const auto stagedAnchor = firstVisible(view); + const bool hiddenUntilCommit = card(view, incomingKey) == nullptr; + const bool changed = spinUntil([&] { + return view.property("structuralStageCommits").toULongLong() == + stageCommitsBefore + 1; + }); + if (qEnvironmentVariableIsSet("CODEXUI_CONVERSATION_TIMINGS")) + std::cerr << "single insertion kind=" + << static_cast(incomingKinds[index]) << " us=" + << insertionTimer.nsecsElapsed() / 1000 << " construct=" + << view.property("lastStructuralStageCardConstructionMicros") + .toLongLong() + << " validation=" + << view.property("lastIncrementalValidationMicros") + .toLongLong() + << " geometry=" + << view.property("lastIncrementalGeometryMicros").toLongLong() + << " structural=" + << view.property("lastIncrementalStructuralMicros") + .toLongLong() + << '\n'; const auto immediateAnchor = firstVisible(view); const QRect immediateCommand(commandCard->mapTo(view.viewport(), QPoint{}), commandCard->size()); - const std::string incomingKey = - stableKey(snapshot.sections.back().cards.back().key); - ConversationCard *const incomingCard = card(view, incomingKey); + QPointer incomingCard = card(view, incomingKey); const int immediateIncomingHeight = incomingCard ? incomingCard->height() : -1; + if (!incomingCard) + allIncomingCardsMaterialized = false; spin(80); paintProbe.active = false; + if (!commandCard) { + result &= expect(false, "incoming activity retains the visible expanded " + "command QWidget through settlement"); + break; + } const auto settledAnchor = firstVisible(view); const QRect settledCommand(commandCard->mapTo(view.viewport(), QPoint{}), commandCard->size()); @@ -755,43 +1297,387 @@ bool testPausedExpandedCommandStaysPainted() { paintProbe.trackedGeometries, [&commandBefore](const QRect &geometry) { return geometry == commandBefore; }); + const bool incomingWidgetStable = + immediateIncomingHeight < 0 + ? !incomingCard + : incomingCard && immediateIncomingHeight == settledIncomingHeight; + const bool auditPass = + changed && hiddenUntilCommit && + stableAgainst(anchorBefore, stagedAnchor) && + card(view, stableKey(completedCommand.key)) == commandCard && + view.mode() == ConversationView::Mode::Paused && + stableAgainst(anchorBefore, immediateAnchor) && + stableAgainst(anchorBefore, settledAnchor) && + immediateCommand == commandBefore && settledCommand == commandBefore && + paintedAnchorStable && paintedStable && incomingWidgetStable && + commandCard->commandOutputScrollState() == outputStateBefore && + view.property("conversationGeometryPasses").toULongLong() == + fullGeometryBefore && + view.property("conversationLocalGeometryPasses").toULongLong() == + localGeometryBefore && + view.property("conversationCachedAppendGeometryPasses") + .toULongLong() == + cachedAppendBefore + 1 && + view.property("incrementalStructuralCommits").toULongLong() == + structuralCommitsBefore + 1; + if (!auditPass) + std::cerr << "incoming audit kind=" + << static_cast(incomingKinds[index]) + << " anchorImmediate=" + << stableAgainst(anchorBefore, immediateAnchor) + << " anchorSettled=" + << stableAgainst(anchorBefore, settledAnchor) + << " commandImmediate=" << (immediateCommand == commandBefore) + << " commandSettled=" << (settledCommand == commandBefore) + << " paintAnchor=" << paintedAnchorStable + << " paintGeometry=" << paintedStable + << " widget=" << incomingWidgetStable << " local=" + << view.property("conversationLocalGeometryPasses") + .toULongLong() + << '/' << localGeometryBefore << " cached=" + << view.property("conversationCachedAppendGeometryPasses") + .toULongLong() + << '/' << cachedAppendBefore << " outputState=" + << (commandCard->commandOutputScrollState() == + outputStateBefore) + << " identity=" + << (card(view, stableKey(completedCommand.key)) == commandCard) + << " mode=" + << (view.mode() == ConversationView::Mode::Paused) + << " full=" + << view.property("conversationGeometryPasses").toULongLong() + << '/' << fullGeometryBefore << " structural=" + << view.property("incrementalStructuralCommits").toULongLong() + << '/' << structuralCommitsBefore << '\n'; result &= expect( - changed && card(view, stableKey(completedCommand.key)) == commandCard && - view.mode() == ConversationView::Mode::Paused && - stableAgainst(anchorBefore, immediateAnchor) && - stableAgainst(anchorBefore, settledAnchor) && - immediateCommand == commandBefore && - settledCommand == commandBefore && paintedAnchorStable && - paintedStable && incomingCard && - immediateIncomingHeight == settledIncomingHeight && - commandCard->commandOutputScrollState() == outputStateBefore, - "incoming card preserves a visible expanded command in every paint"); + auditPass, + "incoming card preserves a visible expanded command in every paint " + "and settles only its affected Turn"); + } + result &= expect(allIncomingCardsMaterialized, + "selected-thread incoming cards materialize immediately"); + + auto appendedCommand = std::ranges::find_if( + snapshot.sections.back().cards, [](const VisibleCardData &candidate) { + return candidate.kind == CardKind::CommandExecution && + candidate.itemId == "appearance-102"; + }); + ConversationCard *appendedCommandCard = + appendedCommand == snapshot.sections.back().cards.end() + ? nullptr + : card(view, stableKey(appendedCommand->key)); + const auto completionAnchorBefore = firstVisible(view); + const int appendedCommandHeightBefore = + appendedCommandCard ? appendedCommandCard->height() : -1; + const int completionRangeBefore = view.verticalScrollBar()->maximum(); + const qulonglong completionFullGeometryBefore = + view.property("conversationGeometryPasses").toULongLong(); + const qulonglong completionLocalGeometryBefore = + view.property("conversationLocalGeometryPasses").toULongLong(); + if (appendedCommand != snapshot.sections.back().cards.end()) { + auto &data = std::get(appendedCommand->payload); + data.status = "completed"; + data.exitCode = 0; + data.durationMilliseconds = 20; + appendedCommand->activeWork = false; } + const bool appendedCommandCompleted = applyConversation(view, snapshot); + spin(); + auto *appendedCommandStatus = + appendedCommandCard + ? appendedCommandCard->findChild( + QStringLiteral("commandStatus")) + : nullptr; + result &= expect( + appendedCommandCompleted && appendedCommandCard && + appendedCommandStatus && + appendedCommandStatus->text() == QStringLiteral("completed") && + !appendedCommandCard->property("activeWork").toBool() && + appendedCommandCard->height() == appendedCommandHeightBefore && + view.verticalScrollBar()->maximum() == completionRangeBefore && + stableAgainst(completionAnchorBefore, firstVisible(view)) && + view.property("conversationGeometryPasses").toULongLong() == + completionFullGeometryBefore && + view.property("conversationLocalGeometryPasses").toULongLong() == + completionLocalGeometryBefore, + "a cached-appended running command completes locally without a retained " + "history traversal or paused-viewport movement"); + + ConversationCard *turnRoot = card( + view, stableKey(*snapshot.sections.back().rootCardKey)); + QWidget *nestedSurface = + turnRoot ? turnRoot->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) + : nullptr; + QWidget *conversationContent = + view.findChild(QStringLiteral("conversationContent")); + view.resize(view.width() - 24, view.height()); + spin(); + result &= expect( + turnRoot && nestedSurface && nestedSurface->layout() && + nestedSurface->layout()->isEnabled() && conversationContent && + conversationContent->layout() && + conversationContent->layout()->isEnabled() && + std::ranges::all_of( + snapshot.sections.back().cards, + [&](const VisibleCardData &data) { + ConversationCard *retained = card(view, stableKey(data.key)); + return retained && + (retained == turnRoot || + turnRoot->isAncestorOf(retained)); + }), + "a later viewport resize re-enables normal Qt layout and preserves " + "every retained card under its Turn/You parent"); + + const auto sectionAnchorBefore = firstVisible(view); + const qulonglong fullBeforeNewTurn = + view.property("conversationGeometryPasses").toULongLong(); + const qulonglong localBeforeNewTurn = + view.property("conversationLocalGeometryPasses").toULongLong(); + const qulonglong cachedSectionBefore = + view.property("conversationCachedSectionAppendGeometryPasses") + .toULongLong(); + const qulonglong sectionStageCommitsBefore = + view.property("structuralStageCommits").toULongLong(); + VisibleCardData newTurnPrompt{ + LocalPromptKey{12'345}, CardKind::LocalPrompt, thread, "turn-3", {}, + LocalPromptData{12'345, "A newly admitted turn", PromptState::InFlight, + true, {}, {}, QDateTime::currentMSecsSinceEpoch(), false}}; + snapshot.sections.push_back( + {"turn:" + thread + ":3", "turn-3", {newTurnPrompt}, + newTurnPrompt.key}); + snapshot.activeTurnId = "turn-3"; + QElapsedTimer newTurnTimer; + newTurnTimer.start(); + view.reconcileStaged(projectConversation(snapshot)); + const bool newTurnHiddenUntilCommit = + card(view, stableKey(newTurnPrompt.key)) == nullptr && + stableAgainst(sectionAnchorBefore, firstVisible(view)); + const bool newTurnChanged = spinUntil([&] { + return view.property("structuralStageCommits").toULongLong() == + sectionStageCommitsBefore + 1; + }); + if (qEnvironmentVariableIsSet("CODEXUI_CONVERSATION_TIMINGS")) + std::cerr << "new turn insertion us=" + << newTurnTimer.nsecsElapsed() / 1000 << '\n'; + const auto sectionAnchorAfter = firstVisible(view); + result &= expect( + newTurnChanged && newTurnHiddenUntilCommit && + card(view, stableKey(newTurnPrompt.key)) && + stableAgainst(sectionAnchorBefore, sectionAnchorAfter) && + view.property("conversationGeometryPasses").toULongLong() == + fullBeforeNewTurn && + view.property("conversationLocalGeometryPasses").toULongLong() == + localBeforeNewTurn && + view.property("conversationCachedSectionAppendGeometryPasses") + .toULongLong() == + cachedSectionBefore + 1, + "a new Turn/You card appends from cached geometry without traversing " + "the retained history or moving a paused viewport"); qApp->setStyleSheet(originalStyleSheet); spin(); return result; } +bool testCommandCompletionWithoutGeometryWork() { + const std::string thread = "command-completion-paint-only"; + VisibleCardData prompt{ + AuthoritativeItemKey{thread, "turn", "prompt"}, CardKind::UserMessage, + thread, "turn", "prompt", UserMessageData{"Run the command", {}}}; + QString output; + for (int line = 0; line < 80; ++line) + output += QStringLiteral("streamed output line %1\n").arg(line); + VisibleCardData command{ + AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{"run long command", utf8(output), "inProgress", + "/workspace", {}, {}}, + true}; + ConversationSnapshot snapshot; + snapshot.threadId = thread; + snapshot.activeTurnId = "turn"; + snapshot.sections.push_back( + {"turn-section", "turn", {prompt, command}, prompt.key}); + + ConversationView view; + view.resize(760, 420); + view.show(); + bool result = expect(view.reconcile(snapshot), + "running command completion audit renders"); + spin(); + ConversationCard *commandCard = card(view, stableKey(command.key)); + if (!commandCard) + return expect(false, "running command completion audit owns its card"); + const int heightBefore = commandCard->height(); + const int rangeBefore = view.verticalScrollBar()->maximum(); + const qulonglong fullGeometryBefore = + view.property("conversationGeometryPasses").toULongLong(); + const qulonglong localGeometryBefore = + view.property("conversationLocalGeometryPasses").toULongLong(); + + auto &completed = std::get(command.payload); + completed.status = "completed"; + completed.exitCode = 0; + completed.durationMilliseconds = 12'000; + command.activeWork = false; + const std::optional impact = + view.applyCardPresentation(command); + spin(); + + auto *status = commandCard->findChild( + QStringLiteral("commandStatus")); + const bool completionStayedLocal = + impact == PresentationImpact::PaintOnly && status && + status->text() == QStringLiteral("completed") && + !commandCard->property("activeWork").toBool() && + commandCard->height() == heightBefore && + view.verticalScrollBar()->maximum() == rangeBefore && + view.property("conversationGeometryPasses").toULongLong() == + fullGeometryBefore && + view.property("conversationLocalGeometryPasses").toULongLong() == + localGeometryBefore; + if (!completionStayedLocal) + std::cerr << "completion impact=" + << (impact ? static_cast(*impact) : -1) + << " height=" << heightBefore << "->" << commandCard->height() + << " range=" << rangeBefore << "->" + << view.verticalScrollBar()->maximum() << " full=" + << fullGeometryBefore << "->" + << view.property("conversationGeometryPasses").toULongLong() + << " local=" << localGeometryBefore << "->" + << view.property("conversationLocalGeometryPasses") + .toULongLong() + << '\n'; + result &= expect( + completionStayedLocal, + "running-to-completed patches lifecycle paint without conversation " + "geometry or scroll-range work"); + return result; +} + +bool testStreamingAgentBecomesVisibleWithoutReselection() { + const std::string thread = "streaming-final-visibility"; + VisibleCardData prompt{ + AuthoritativeItemKey{thread, "turn", "prompt"}, CardKind::UserMessage, + thread, "turn", "prompt", UserMessageData{"Prompt", {}}}; + VisibleCardData response{ + AuthoritativeItemKey{thread, "turn", "streaming-response"}, + CardKind::AgentMessage, + thread, + "turn", + "streaming-response", + AgentMessageData{"The completed response must appear immediately.", + false}}; + ConversationSnapshot snapshot; + snapshot.threadId = thread; + snapshot.sections.push_back( + {"turn-section", "turn", {prompt, response}, prompt.key}); + + ConversationView view; + view.setPresentationOptions({true, false, false, false}); + view.resize(620, 420); + view.show(); + bool result = expect(view.reconcile(snapshot), + "a filtered streaming response is retained"); + spin(); + QPointer responseCard = + card(view, stableKey(response.key)); + ConversationCard *rootCard = + card(view, stableKey(*snapshot.sections.front().rootCardKey)); + result &= expect(responseCard && responseCard->isHidden() && rootCard && + rootCard->isAncestorOf(responseCard), + "the streaming response performs no visible work while " + "updates are filtered"); + + std::get(snapshot.sections.front().cards.back().payload) + .finalAnswer = true; + result &= expect(view.reconcile(snapshot), + "completion makes the retained response visible"); + spin(); + responseCard = card(view, stableKey(response.key)); + rootCard = card(view, stableKey(*snapshot.sections.front().rootCardKey)); + result &= expect(responseCard && !responseCard->isHidden() && rootCard && + rootCard->isAncestorOf(responseCard) && + responseCard->height() > 0 && + rootCard->contentsRect().contains( + responseCard->mapTo(rootCard, QPoint{})) && + responseCard + ->mapTo(rootCard, + QPoint(0, responseCard->height())) + .y() <= rootCard->contentsRect().bottom() + 1, + "the final response and its settled owner appear without " + "thread reselection"); + + ConversationView optimisticView; + optimisticView.setPresentationOptions({true, false, false, false}); + optimisticView.resize(620, 420); + optimisticView.show(); + ConversationSnapshot liveSnapshot; + liveSnapshot.threadId = "optimistic-live-final"; + static_cast(optimisticView.reconcile(liveSnapshot)); + VisibleCardData localPrompt{ + LocalPromptKey{1}, CardKind::LocalPrompt, liveSnapshot.threadId, + "live-turn", {}, + LocalPromptData{1, "Live prompt", PromptState::InFlight, 0, {}, {}}}; + liveSnapshot.sections.push_back( + {"live-turn-section", "live-turn", {localPrompt}, localPrompt.key}); + result &= expect(optimisticView.reconcile(liveSnapshot), + "an optimistic Turn/You owner inserts immediately"); + liveSnapshot.sections.front().cards.front().kind = CardKind::UserMessage; + liveSnapshot.sections.front().cards.front().payload = + UserMessageData{"Live prompt", {}}; + result &= expect(optimisticView.reconcile(liveSnapshot), + "the optimistic Turn/You owner acknowledges in place"); + VisibleCardData liveResponse{ + AuthoritativeItemKey{liveSnapshot.threadId, "live-turn", "live-answer"}, + CardKind::AgentMessage, + liveSnapshot.threadId, + "live-turn", + "live-answer", + AgentMessageData{"Live final answer", true}}; + liveSnapshot.sections.front().cards.push_back(liveResponse); + result &= expect(optimisticView.reconcile(liveSnapshot), + "the final response inserts into the acknowledged Turn"); + spin(); + ConversationCard *liveRoot = + card(optimisticView, stableKey(localPrompt.key)); + ConversationCard *liveAnswer = + card(optimisticView, stableKey(liveResponse.key)); + result &= expect(liveRoot && liveAnswer && !liveAnswer->isHidden() && + liveRoot->isAncestorOf(liveAnswer) && + liveAnswer + ->mapTo(liveRoot, + QPoint(0, liveAnswer->height())) + .y() <= liveRoot->contentsRect().bottom() + 1, + "the optimistic live sequence exposes the final answer in " + "its settled Turn without reselection"); + return result; +} + bool testThreadLocalScrollAndComposerExtent() { ConversationView view; view.resize(620, 340); view.show(); - ConversationSnapshot first = conversation("thread-a", 30); - ConversationSnapshot second = conversation("thread-b", 26); - view.reconcile(first); + ConversationGraphSpec first = conversation("thread-a", 30); + ConversationGraphSpec second = conversation("thread-b", 26); + applyConversation(view, first); spin(); wheel(view, 220); const auto saved = firstVisible(view); bool result = expect(view.mode() == ConversationView::Mode::Paused, "first thread is paused before switching"); - view.reconcile(second); + applyConversation(view, second); spin(); result &= expect(view.mode() == ConversationView::Mode::Following && view.isAtBottom(), "a new thread does not inherit another thread's pause"); - view.reconcile(first); + applyConversation(view, first); spin(); const auto restored = firstVisible(view); result &= expect(view.mode() == ConversationView::Mode::Paused && @@ -825,8 +1711,8 @@ bool testPromptAdmissionFollowOwnership() { ConversationView view; view.resize(620, 340); view.show(); - ConversationSnapshot snapshot = conversation("prompt-follow", 30); - view.reconcile(snapshot); + ConversationGraphSpec snapshot = conversation("prompt-follow", 30); + applyConversation(view, snapshot); spin(); view.setTrailingSpaceHeight(120); @@ -844,19 +1730,19 @@ bool testPromptAdmissionFollowOwnership() { 0, {}}}; snapshot.sections.back().cards.push_back(pending); - view.reconcile(snapshot); + applyConversation(view, snapshot); view.setTrailingSpaceHeight(0); - QElapsedTimer follow; - follow.start(); - while (follow.elapsed() < 400 && !view.isAtBottom()) - spin(8); - ConversationCard *pendingCard = card(view, stableKey(pending.key)); + ConversationCard *pendingCard = nullptr; + const bool admittedPromptReady = spinUntil([&] { + pendingCard = card(view, stableKey(pending.key)); + return view.isAtBottom() && pendingCard && + pendingCard->mapTo(view.viewport(), QPoint{}).y() + + pendingCard->height() <= + view.viewport()->height(); + }, 512); result &= expect( - view.mode() == ConversationView::Mode::Following && view.isAtBottom() && - pendingCard && - pendingCard->mapTo(view.viewport(), QPoint{}).y() + - pendingCard->height() <= - view.viewport()->height(), + admittedPromptReady && + view.mode() == ConversationView::Mode::Following, "composer-owned pause resumes and reveals the complete admitted prompt"); wheel(view, 180); @@ -869,7 +1755,7 @@ bool testPromptAdmissionFollowOwnership() { std::get(later.payload).prompt = "must not displace a user-owned reading position"; snapshot.sections.back().cards.push_back(later); - view.reconcile(snapshot); + applyConversation(view, snapshot); view.setTrailingSpaceHeight(0); spin(40); const auto retainedAnchor = firstVisible(view); @@ -987,8 +1873,7 @@ bool testCardCopyControls() { result &= expect( morph && button->property("copyFeedbackActive").toBool() && button->property("copyIconState") == QStringLiteral("check") && - copyIcon != checkIcon && - QToolTip::isVisible() && + copyIcon != checkIcon && QToolTip::isVisible() && QToolTip::text() == QStringLiteral("Copied"), "Copy quickly morphs into a visible success check while showing " "the canonical transient Copied overlay"); @@ -1062,7 +1947,7 @@ bool testMutableCardsAndCommandOutput() { const QString originalStyleSheet = qApp->styleSheet(); qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); const std::string thread = "card-thread"; - TurnSection section{"turn:cards", "turn", {}}; + TurnGraphSpec section{"turn:cards", "turn", {}}; section.cards = { {AuthoritativeItemKey{thread, "turn", "user"}, CardKind::UserMessage, thread, "turn", "user", @@ -1104,12 +1989,26 @@ bool testMutableCardsAndCommandOutput() { {}}}, }; section.rootCardKey = section.cards.front().key; - ConversationSnapshot snapshot{thread, {section}, 0, false}; + ConversationGraphSpec snapshot{thread, {section}, 0, false}; ConversationView view; view.resize(650, 520); view.show(); - view.reconcile(snapshot); - spin(); + applyConversation(view, snapshot); + const bool allCoVisibleCardsReady = spinUntil([&] { + return std::ranges::all_of( + snapshot.sections.front().cards, [&view](const VisibleCardData &value) { + return card(view, stableKey(value.key)) != nullptr; + }); + }); + + bool result = + expect(allCoVisibleCardsReady, + "bounded render continuations materialize every co-visible card"); + if (!allCoVisibleCardsReady) { + qApp->setStyleSheet(originalStyleSheet); + spin(); + return false; + } std::unordered_map identities; for (const auto &value : snapshot.sections.front().cards) @@ -1138,7 +2037,7 @@ bool testMutableCardsAndCommandOutput() { commandCard->findChild(QStringLiteral("commandStatus")); auto *commandMeta = commandCard->findChild(QStringLiteral("commandMetadata")); - bool result = expect( + result &= expect( output && output->isHidden() && commandStatus && commandMeta && commandMeta->isHidden() && commandStatus->property("tone").toString() == @@ -1235,6 +2134,10 @@ bool testMutableCardsAndCommandOutput() { commandCard->setCollapsed(false); spin(); + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + commandCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); auto &cards = snapshot.sections.front().cards; std::get(cards[0].payload).text += " updated"; auto &agent = std::get(cards[1].payload); @@ -1261,26 +2164,47 @@ bool testMutableCardsAndCommandOutput() { generic.raw["detail"] = "updated"; std::get(cards[8].payload).state = PromptState::Failed; std::get(cards[8].payload).error = "error"; - result &= - expect(view.reconcile(snapshot), "all card types accept visible updates"); - const int immediateOuterRange = view.verticalScrollBar()->maximum(); + result &= expect(applyConversation(view, snapshot), + "all card types accept visible updates"); + result &= spinUntil([&] { + const auto *presented = + std::get_if(&commandCard->data().payload); + return presented && presented->output.ends_with("visible\n\n \t"); + }); const int immediateCommandHeight = commandCard->height(); const int immediatePreferredOutputHeight = output->sizeHint().height(); spin(); result &= - expect(view.verticalScrollBar()->maximum() == immediateOuterRange && - commandCard->height() == immediateCommandHeight && + expect(commandCard->height() == immediateCommandHeight && output->sizeHint().height() == immediatePreferredOutputHeight, - "command output has no delayed outer geometry settlement"); - result &= expect( + "command output has no delayed card geometry settlement while " + "other graph renders remain sliced"); + const bool longCommandStartsAtTop = commandText->verticalScrollBar()->maximum() > 0 && - commandText->verticalScrollBar()->value() == - commandText->verticalScrollBar()->minimum(), - "long executed-command text opens at its beginning"); + commandText->verticalScrollBar()->value() == + commandText->verticalScrollBar()->minimum(); + if (!longCommandStartsAtTop) + std::cerr << "long command scroll: value=" + << commandText->verticalScrollBar()->value() << " minimum=" + << commandText->verticalScrollBar()->minimum() << " maximum=" + << commandText->verticalScrollBar()->maximum() << " height=" + << commandText->height() << " hint=" + << commandText->sizeHint().height() << " cursor=" + << commandText->textCursor().position() << " focus=" + << commandText->hasFocus() << '\n'; + result &= expect(longCommandStartsAtTop, + "long executed-command text opens at its beginning"); for (const auto &value : cards) result &= expect(card(view, stableKey(value.key)) == identities[stableKey(value.key)], "same-key same-kind card updates in place"); + // The graph update above deliberately does no QWidget projection for this + // offscreen card. Bringing the already-materialized card into view applies + // its latest canonical state without reconstructing it. + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + agentCardWidget->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); result &= expect( titleText(agentCardWidget) == QStringLiteral("Codex") && agentPhase && agentPhase->text() == QStringLiteral("final answer") && @@ -1309,7 +2233,7 @@ bool testMutableCardsAndCommandOutput() { longOutput += QStringLiteral("line %1 with terminal output\n").arg(line); output->setOutput(longOutput); view.resize(650, 520); - spin(); + spin(40); result &= expect(output->verticalScrollBar()->maximum() > 0 && output->followsLatest(), "long command output exposes its own scrollbar and follows"); @@ -1335,15 +2259,19 @@ bool testMutableCardsAndCommandOutput() { result &= expect(!output->followsLatest() && output->verticalScrollBar()->value() == preserved, "paused command output preserves its inner scroll value"); - output->verticalScrollBar()->triggerAction( - QAbstractSlider::SliderToMaximum); + output->verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); spin(); result &= expect(output->followsLatest(), "inner output following resumes at its real bottom"); + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + commandCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); command.output = "\x1b]0;terminal title\x07\x1b[0m \n\t"; - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "non-presentable replacement updates the command card"); + result &= spinUntil([&] { return output->isHidden(); }); const int hiddenOuterRange = view.verticalScrollBar()->maximum(); const int hiddenCommandHeight = commandCard->height(); result &= expect(output->isHidden(), @@ -1443,7 +2371,7 @@ bool testCardFoldingGeometryAndRetention() { "turn", "empty-reasoning", ReasoningData{}}; - ConversationSnapshot snapshot{ + ConversationGraphSpec snapshot{ thread, {{"turn:folding", "turn", @@ -1458,35 +2386,49 @@ bool testCardFoldingGeometryAndRetention() { view.resize(700, 820); view.setTrailingSpaceHeight(500); view.show(); - ConversationSnapshot promptOnly = snapshot; + ConversationGraphSpec promptOnly = snapshot; promptOnly.sections.front().cards = {user}; - bool result = - expect(view.reconcile(promptOnly), "prompt-only folding fixture renders"); + bool result = expect(applyConversation(view, promptOnly), + "prompt-only folding fixture renders"); spin(); ConversationCard *promptOnlyCard = card(view, stableKey(user.key)); QWidget *promptOnlyNestedCards = - promptOnlyCard - ? promptOnlyCard->findChild( - QStringLiteral("conversationNestedCards"), - Qt::FindDirectChildrenOnly) - : nullptr; + promptOnlyCard ? promptOnlyCard->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) + : nullptr; result &= expect(promptOnlyCard && promptOnlyNestedCards && promptOnlyNestedCards->isHidden(), "an initial turn prompt reserves no nested-card gap"); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "first nested activity extends the folding fixture"); - spin(); + const bool foldingCardsReady = spinUntil([&] { + return std::ranges::all_of( + snapshot.sections.front().cards, [&view](const VisibleCardData &value) { + return card(view, stableKey(value.key)) != nullptr; + }); + }); + result &= expect( + foldingCardsReady, + "bounded render continuations materialize every co-visible folding " + "card"); + if (!foldingCardsReady) { + qApp->setStyleSheet(originalStyleSheet); + spin(); + return false; + } - ConversationCard *userCard = card(view, stableKey(user.key)); - ConversationCard *agentCardWidget = card(view, stableKey(agent.key)); - ConversationCard *reasoningCard = card(view, stableKey(reasoning.key)); - ConversationCard *commandCard = card(view, stableKey(command.key)); - ConversationCard *filesCard = card(view, stableKey(files.key)); - const std::vector additionalActionCards{ + QPointer userCard = card(view, stableKey(user.key)); + QPointer agentCardWidget = card(view, stableKey(agent.key)); + QPointer reasoningCard = + card(view, stableKey(reasoning.key)); + QPointer commandCard = card(view, stableKey(command.key)); + QPointer filesCard = card(view, stableKey(files.key)); + const std::vector> additionalActionCards{ card(view, stableKey(activity.key)), card(view, stableKey(image.key)), card(view, stableKey(plan.key)), card(view, stableKey(generic.key))}; - ConversationCard *emptyReasoningCard = + QPointer emptyReasoningCard = card(view, stableKey(emptyReasoning.key)); result &= expect( userCard && agentCardWidget && reasoningCard && commandCard && @@ -1502,12 +2444,22 @@ bool testCardFoldingGeometryAndRetention() { result &= expect( userCard && userCard == promptOnlyCard && userCard->property("authoritativeTurnActive").toBool() && + agentCardWidget && !agentCardWidget->property("authoritativeTurnActive").toBool() && !userCard->findChild(QStringLiteral("activeTurnAnimation")), "the retained running outer You card receives a static emphasized " "border"); snapshot.activeTurnId.reset(); - result &= expect(view.reconcile(snapshot) && + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + userCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); + result &= expect(applyConversation(view, snapshot) && + spinUntil([&] { + return !userCard + ->property("authoritativeTurnActive") + .toBool(); + }) && userCard == card(view, stableKey(user.key)) && !userCard->property("authoritativeTurnActive").toBool(), "turn completion restores the same card's canonical border"); @@ -1519,7 +2471,7 @@ bool testCardFoldingGeometryAndRetention() { "collapsed disclosure paints only a right-inset left chevron"); result &= expect( std::ranges::all_of(additionalActionCards, - [](ConversationCard *value) { + [](const QPointer &value) { return value && value->isCollapsed() && disclosure(value); }), @@ -1563,7 +2515,7 @@ bool testCardFoldingGeometryAndRetention() { LocalPromptData{ 4343, "A steering prompt", PromptState::InFlight, true, {}, {}}}; snapshot.sections.front().cards.push_back(steering); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "a steering prompt joins the active turn"); spin(40); ConversationCard *steeringCard = card(view, stableKey(steeringKey)); @@ -1579,7 +2531,7 @@ bool testCardFoldingGeometryAndRetention() { steeringCard && userCard->isAncestorOf(steeringCard) && steeringCard->property("nestedConversationCard").toBool() && cardTitle(steeringCard) == QStringLiteral("You") && steeringPhase && - steeringPhase->text() == QStringLiteral("steering") && + steeringPhase->text() == QStringLiteral("steering · pending") && steeringPhase->font().weight() == QFont::Normal && steeringPhase->parentWidget()->layout()->indexOf(steeringPhase) < steeringPhase->parentWidget()->layout()->indexOf( @@ -1592,7 +2544,7 @@ bool testCardFoldingGeometryAndRetention() { steeringKey, CardKind::UserMessage, thread, "turn", "steering-user", UserMessageData{"A steering prompt", {}}}; - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "the steering prompt receives authoritative content"); spin(); ConversationCard *authoritativeSteering = card(view, stableKey(steeringKey)); @@ -1613,8 +2565,13 @@ bool testCardFoldingGeometryAndRetention() { }); std::get(retainedEmptyReasoning->payload).summary = "Public reasoning summary arrived"; - result &= expect(view.reconcile(snapshot), + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + emptyReasoningCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); + result &= expect(applyConversation(view, snapshot), "empty reasoning accepts later public content"); + result &= spinUntil([&] { return !disclosure(emptyReasoningCard)->isHidden(); }); result &= expect(!disclosure(emptyReasoningCard)->isHidden() && disclosure(emptyReasoningCard)->property("chevronDirection") == @@ -1650,10 +2607,21 @@ bool testCardFoldingGeometryAndRetention() { snapshot.sections.front().cards[3].payload); execution.output = "streamed line 1\nstreamed line 2\nstreamed line 3\nstreamed line 4"; - result &= expect(view.reconcile(snapshot), + const int scrollBeforeCommandUpdate = view.verticalScrollBar()->value(); + view.verticalScrollBar()->setValue( + scrollBeforeCommandUpdate + + commandCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(40); + result &= expect(applyConversation(view, snapshot), "folded command accepts a streamed content update"); auto *output = dynamic_cast( commandCard->findChild(QStringLiteral("commandOutputView"))); + result &= spinUntil([&] { + return output && + output->toPlainText().contains(QStringLiteral("streamed line 4")); + }); + view.verticalScrollBar()->setValue(scrollBeforeCommandUpdate); + spin(20); result &= expect( commandCard->isCollapsed() && commandCard->height() == commandHeight && output && @@ -1663,24 +2631,34 @@ bool testCardFoldingGeometryAndRetention() { const int userHeight = userCard->height(); result &= expect(setFolded(userCard, true), "You can be folded from its expanded default"); - result &= expect( + const bool foldedTurnPass = userCard->mapTo(view.viewport(), QPoint{}).y() == userTop && - userCard->height() < userHeight && - !agentCardWidget->isVisibleTo(userCard) && - !reasoningCard->isCollapsed() && commandCard->isCollapsed(), - "folding You fixes its title, hides the turn, and retains nested folds"); + userCard->height() < userHeight && + (!agentCardWidget || !agentCardWidget->isVisibleTo(userCard)) && + (!reasoningCard || !reasoningCard->isVisibleTo(userCard)) && + (!commandCard || !commandCard->isVisibleTo(userCard)); + result &= expect( + foldedTurnPass, + "folding You fixes its title and hides or releases nested widgets"); - view.reconcile(conversation("folding-other-thread", 4)); - spin(); - view.reconcile(snapshot); + applyConversation(view, conversation("folding-other-thread", 4)); spin(); + applyConversation(view, snapshot); + spin(40); userCard = card(view, stableKey(user.key)); reasoningCard = card(view, stableKey(reasoning.key)); commandCard = card(view, stableKey(command.key)); - result &= expect( - userCard && reasoningCard && commandCard && userCard->isCollapsed() && - !reasoningCard->isCollapsed() && commandCard->isCollapsed(), - "user fold choices survive thread switching and updates"); + result &= expect(userCard && userCard->isCollapsed(), + "the root fold survives thread switching and updates"); + result &= expect(setFolded(userCard, false), + "the restored root can rematerialize its nested cards"); + spin(80); + reasoningCard = card(view, stableKey(reasoning.key)); + commandCard = card(view, stableKey(command.key)); + result &= + expect(reasoningCard && commandCard && !reasoningCard->isCollapsed() && + commandCard->isCollapsed(), + "rematerialized nested cards restore user-owned folds"); const std::string promptThread = "folding-prompt-replacement"; const LocalPromptKey promptKey{4242}; @@ -1693,15 +2671,15 @@ bool testCardFoldingGeometryAndRetention() { LocalPromptData{ 4242, "A temporary prompt", PromptState::InFlight, 0, {}, {}}}; VisibleCardData promptActivity = agentCard(promptThread, "turn", 77); - ConversationSnapshot promptSnapshot{ + ConversationGraphSpec promptSnapshot{ promptThread, {{"local:folding-prompt", {}, {localPrompt, promptActivity}, promptKey}}, 0, false}; - view.reconcile(promptSnapshot); + applyConversation(view, promptSnapshot); spin(); ConversationCard *promptCard = card(view, stableKey(promptKey)); - ConversationCard *const promptActivityCard = + QPointer promptActivityCard = card(view, stableKey(promptActivity.key)); result &= expect(promptCard && !promptCard->isCollapsed() && promptActivityCard && @@ -1725,30 +2703,41 @@ bool testCardFoldingGeometryAndRetention() { "user", UserMessageData{"A temporary prompt", {}}}; promptSnapshot.sections.front().key = "turn:folding-prompt"; promptSnapshot.sections.front().turnId = "turn"; - view.reconcile(promptSnapshot); + applyConversation(view, promptSnapshot); spin(); promptCard = card(view, stableKey(promptKey)); auto *promptAnimation = admittedPromptCard->findChild( QString{}, Qt::FindDirectChildrenOnly); - result &= expect( + const bool promptMorphPass = promptCard && promptCard == admittedPromptCard && - promptCard->cardKind() == CardKind::UserMessage && - promptCard->isCollapsed() && promptAnimation && - !promptAnimation->isActive() && - promptCard->property("messageRole") == QStringLiteral("user") && - promptCard->property("conversationCardKind").toInt() == - static_cast(CardKind::UserMessage) && - promptCard->objectName() == QStringLiteral("conversationCard") && - promptCard->styleSheet().isEmpty() && - card(view, stableKey(promptActivity.key)) == promptActivityCard && - promptCard->isAncestorOf(promptActivityCard) && - promptCard->size() == admittedPromptSize && admittedPromptHeader && - admittedPromptHeader->geometry() == admittedPromptHeaderGeometry && - promptCard->frameWidth() == admittedPromptFrameWidth, + promptCard->cardKind() == CardKind::UserMessage && + promptCard->isCollapsed() && promptAnimation && + !promptAnimation->isActive() && + promptCard->property("messageRole") == QStringLiteral("user") && + promptCard->property("conversationCardKind").toInt() == + static_cast(CardKind::UserMessage) && + promptCard->objectName() == QStringLiteral("conversationCard") && + promptCard->styleSheet().isEmpty() && + promptCard->size() == admittedPromptSize && admittedPromptHeader && + admittedPromptHeader->geometry() == admittedPromptHeaderGeometry && + promptCard->frameWidth() == admittedPromptFrameWidth; + result &= expect( + promptMorphPass, "acknowledgement morphs the retained You card without geometry drift"); + result &= expect(setFolded(promptCard, false), + "acknowledged prompt can reveal current turn activity"); + spin(40); + ConversationCard *rematerializedPromptActivity = + card(view, stableKey(promptActivity.key)); + result &= + expect(rematerializedPromptActivity && + promptCard->isAncestorOf(rematerializedPromptActivity) && + (!promptActivityCard || + rematerializedPromptActivity == promptActivityCard), + "prompt activity remains structurally nested across lazy release"); const std::string edgeThread = "folding-bottom-edge"; - ConversationSnapshot edge = conversation(edgeThread, 12); + ConversationGraphSpec edge = conversation(edgeThread, 12); QString longOutput; for (int line = 0; line < 70; ++line) longOutput += QStringLiteral("bottom-edge line %1\n").arg(line); @@ -1764,7 +2753,7 @@ bool testCardFoldingGeometryAndRetention() { ConversationView edgeView; edgeView.resize(650, 520); edgeView.show(); - edgeView.reconcile(edge); + applyConversation(edgeView, edge); spin(); ConversationCard *edgeCard = card(edgeView, stableKey(edgeCommand.key)); const int collapsedTop = @@ -1818,7 +2807,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { const AuthoritativeItemKey nestedUserKey{nestedThread, "turn", "user"}; const AuthoritativeItemKey nestedReasoningKey{nestedThread, "turn", "reasoning"}; - ConversationSnapshot nestedSnapshot; + ConversationGraphSpec nestedSnapshot; nestedSnapshot.threadId = nestedThread; nestedSnapshot.sections.push_back( {"turn:nested-presentation-options:turn", @@ -1830,25 +2819,26 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { nestedView.setPresentationOptions({false, true, true, true}); nestedView.resize(700, 700); nestedView.show(); - bool nestedResult = nestedView.reconcile(nestedSnapshot); + bool nestedResult = applyConversation(nestedView, nestedSnapshot); spin(); nestedSnapshot.sections.front().cards.push_back( {nestedReasoningKey, CardKind::Reasoning, nestedThread, "turn", "reasoning", ReasoningData{"Hidden reasoning"}}); - nestedResult &= nestedView.reconcile(nestedSnapshot); + nestedResult &= applyConversation(nestedView, nestedSnapshot); spin(); ConversationCard *nestedReasoning = card(nestedView, stableKey(nestedReasoningKey)); - if (!expect(nestedResult && nestedReasoning && nestedReasoning->isHidden(), - "new nested reasoning obeys the disabled visibility filter")) + if (!expect(nestedResult && nestedReasoning && + !nestedReasoning->isVisible(), + "filtered nested reasoning is retained without painting")) return false; } { const std::string followingThread = "following-nested-insertion"; - ConversationSnapshot followingSnapshot; + ConversationGraphSpec followingSnapshot; followingSnapshot.threadId = followingThread; - TurnSection followingSection{ + TurnGraphSpec followingSection{ "turn:following-nested-insertion:turn", "turn", {}}; followingSection.cards.push_back( {AuthoritativeItemKey{followingThread, "turn", "user"}, @@ -1869,7 +2859,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { followingView.setPresentationOptions({false, true, true, true}); followingView.resize(520, 320); followingView.show(); - bool followingResult = followingView.reconcile(followingSnapshot); + bool followingResult = applyConversation(followingView, followingSnapshot); spin(); const AuthoritativeItemKey incomingKey{followingThread, "turn", "incoming"}; followingSnapshot.sections.front().cards.push_back( @@ -1886,18 +2876,20 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { {}, {}, {}}}); - followingResult &= followingView.reconcile(followingSnapshot); + followingResult &= applyConversation(followingView, followingSnapshot); + followingResult &= spinUntil( + [&] { return card(followingView, stableKey(incomingKey)) != nullptr; }); ConversationCard *incoming = card(followingView, stableKey(incomingKey)); - const int immediateTop = + const int materializedTop = incoming ? incoming->mapTo(followingView.viewport(), QPoint{}).y() : -1; - const bool immediatelyAtBottom = followingView.isAtBottom(); + const bool materializedAtBottom = followingView.isAtBottom(); spin(320); const int settledTop = incoming ? incoming->mapTo(followingView.viewport(), QPoint{}).y() : -1; - if (!expect(followingResult && incoming && immediatelyAtBottom && - immediateTop == settledTop, - "new nested cards paint directly at their final followed " - "position")) + if (!expect(followingResult && incoming && materializedAtBottom && + materializedTop == settledTop, + "a new nested card reaches its final followed position in " + "the bounded materialization continuation")) return false; followingView.setPresentationOptions({true, true, true, true}); @@ -1920,7 +2912,7 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { const AuthoritativeItemKey reasoningKey{thread, "turn", "reasoning"}; const AuthoritativeItemKey firstCommandKey{thread, "turn", "command-1"}; const AuthoritativeItemKey firstImageKey{thread, "turn", "image-1"}; - ConversationSnapshot snapshot; + ConversationGraphSpec snapshot; snapshot.threadId = thread; snapshot.sections.push_back( {"turn:presentation-options:turn", @@ -1947,28 +2939,37 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { view.setPresentationOptions({false, true, true, true}); view.resize(700, 700); view.show(); - bool result = - expect(view.reconcile(snapshot), "presentation-options fixture renders"); - spin(); - ConversationCard *update = card(view, stableKey(updateKey)); - ConversationCard *final = card(view, stableKey(finalKey)); - ConversationCard *reasoning = card(view, stableKey(reasoningKey)); - ConversationCard *firstCommand = card(view, stableKey(firstCommandKey)); - ConversationCard *firstImage = card(view, stableKey(firstImageKey)); - result &= expect( - update && final && reasoning && firstCommand && firstImage && - !update->isHidden() && !final->isHidden() && reasoning->isHidden() && - !firstCommand->isCollapsed() && !firstImage->isCollapsed(), - "default presentation hides reasoning and opens commands and images"); - if (!update || !final || !reasoning || !firstCommand || !firstImage) + bool result = expect(applyConversation(view, snapshot), + "presentation-options fixture renders"); + result &= spinUntil([&] { + return card(view, stableKey(updateKey)) && + card(view, stableKey(finalKey)) && + card(view, stableKey(firstCommandKey)) && + card(view, stableKey(firstImageKey)); + }); + QPointer update = card(view, stableKey(updateKey)); + QPointer final = card(view, stableKey(finalKey)); + QPointer reasoning = card(view, stableKey(reasoningKey)); + QPointer firstCommand = + card(view, stableKey(firstCommandKey)); + QPointer firstImage = card(view, stableKey(firstImageKey)); + result &= expect(update && final && reasoning && firstCommand && firstImage && + !update->isHidden() && !final->isHidden() && + reasoning->isHidden() && + !firstCommand->isCollapsed() && + !firstImage->isCollapsed(), + "default presentation retains hidden reasoning and opens " + "commands and images"); + if (!update || !final || !firstCommand || !firstImage) return false; view.setPresentationOptions({false, false, false, false}); spin(); - result &= expect(update->isHidden() && reasoning->isHidden() && - !final->isHidden() && !firstCommand->isCollapsed(), - "filters hide reasoning and updates without changing final " - "answers or existing folds"); + result &= expect(update && update->isHidden() && reasoning && + reasoning->isHidden() && final && !final->isHidden() && + firstCommand && !firstCommand->isCollapsed(), + "filters hide retained reasoning and update widgets without " + "changing final answers or existing folds"); std::get(snapshot.sections.front().cards[0].payload).text = "Updated while hidden"; @@ -1984,31 +2985,41 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { {secondImageKey, CardKind::ImageGeneration, thread, "turn", "image-2", ImageGenerationData{"/missing/image-2.png", "completed", "Second image"}}); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "hidden cards and a new command accept updates"); - spin(); - ConversationCard *secondCommand = card(view, stableKey(secondCommandKey)); - ConversationCard *secondImage = card(view, stableKey(secondImageKey)); - result &= expect(card(view, stableKey(updateKey)) == update && - card(view, stableKey(reasoningKey)) == reasoning && - update->isHidden() && reasoning->isHidden() && - secondCommand && secondCommand->isCollapsed() && - secondImage && secondImage->isCollapsed(), - "hidden widgets retain identity and new commands and images " - "use their current initial preferences"); + result &= spinUntil([&] { + return card(view, stableKey(secondCommandKey)) && + card(view, stableKey(secondImageKey)); + }); + QPointer secondCommand = + card(view, stableKey(secondCommandKey)); + QPointer secondImage = + card(view, stableKey(secondImageKey)); + result &= expect(update && update->isHidden() && reasoning && + reasoning->isHidden() && secondCommand && + secondCommand->isCollapsed() && secondImage && + secondImage->isCollapsed(), + "filtered nodes remain hidden while new commands and images " + "use current initial preferences"); result &= expect(setFolded(firstCommand, true), "an existing command records a user-owned collapsed state"); view.setPresentationOptions({true, true, true, true}); - spin(); + result &= spinUntil([&] { + return card(view, stableKey(updateKey)) && + card(view, stableKey(reasoningKey)); + }); + update = card(view, stableKey(updateKey)); + reasoning = card(view, stableKey(reasoningKey)); result &= expect( - !update->isHidden() && !reasoning->isHidden() && + update && reasoning && !update->isHidden() && !reasoning->isHidden() && containsText(update, QStringLiteral("Updated while hidden")) && containsText(reasoning, QStringLiteral("Reasoning updated while hidden")) && - firstCommand->isCollapsed() && secondCommand && - secondCommand->isCollapsed() && !firstImage->isCollapsed() && - secondImage && secondImage->isCollapsed(), + firstCommand && firstCommand->isCollapsed() && secondCommand && + secondCommand->isCollapsed() && firstImage && + !firstImage->isCollapsed() && secondImage && + secondImage->isCollapsed(), "restoring visibility reveals latest content and preserves existing " "folds"); @@ -2016,13 +3027,16 @@ bool testPresentationOptionsRetainCardsAndInitialFolding() { snapshot.sections.front().cards.push_back( {thirdCommandKey, CardKind::CommandExecution, thread, "turn", "command-3", CommandExecutionData{"printf third", {}, "completed", {}, 0}}); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "a command arrives after restoring expanded-by-default"); - spin(); - ConversationCard *thirdCommand = card(view, stableKey(thirdCommandKey)); + result &= spinUntil( + [&] { return card(view, stableKey(thirdCommandKey)) != nullptr; }); + QPointer thirdCommand = + card(view, stableKey(thirdCommandKey)); result &= - expect(thirdCommand && !thirdCommand->isCollapsed() && - firstCommand->isCollapsed() && secondCommand->isCollapsed(), + expect(thirdCommand && !thirdCommand->isCollapsed() && firstCommand && + firstCommand->isCollapsed() && secondCommand && + secondCommand->isCollapsed(), "only newly appearing commands use the changed initial folding " "preference"); return result; @@ -2040,14 +3054,14 @@ bool testInitialCommandGeometrySettlement() { "turn", "command", CommandExecutionData{"printf output", utf8(output), "completed", {}, 0}}; - ConversationSnapshot snapshot{ + ConversationGraphSpec snapshot{ thread, {{"turn:initial-command", "turn", {command}}}, 0, false}; ConversationView view; view.resize(650, 520); view.show(); spin(); - bool result = expect(view.reconcile(snapshot), + bool result = expect(applyConversation(view, snapshot), "initial visible command output is inserted"); ConversationCard *commandCard = card(view, stableKey(command.key)); result &= expect(setFolded(commandCard, false), @@ -2079,7 +3093,7 @@ bool testInitialCommandGeometrySettlement() { auto &execution = std::get( snapshot.sections.front().cards.front().payload); execution.output = utf8(QString(charactersPerLine + 1, QLatin1Char('W'))); - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "single logical output line changes to two visual lines"); spin(); const QTextBlock wrappedBlock = outputView->document()->firstBlock(); @@ -2101,24 +3115,31 @@ bool testRootlessFinalAnswerGeometrySettlement() { thread, "turn", "activity", - AgentActivityData{"spawn_agent", "completed", "tool", "Child work", - {}, {}, {}, {}, {}, {}, {}}}; - VisibleCardData answer{ - AuthoritativeItemKey{thread, "turn", "answer"}, + AgentActivityData{"spawn_agent", + "completed", + "tool", + "Child work", + {}, + {}, + {}, + {}, + {}, + {}, + {}}}; + VisibleCardData answer{ + AuthoritativeItemKey{thread, "turn", "answer"}, CardKind::AgentMessage, thread, "turn", "answer", - AgentMessageData{"Implemented the requested child-thread change.", - true}}; - ConversationSnapshot snapshot{ - thread, {{"turn:rootless-child", "turn", {activity, answer}}}, 0, - false}; + AgentMessageData{"Implemented the requested child-thread change.", true}}; + ConversationGraphSpec snapshot{ + thread, {{"turn:rootless-child", "turn", {activity, answer}}}, 0, false}; ConversationView view; view.resize(700, 700); view.show(); - bool result = expect(view.reconcile(snapshot), + bool result = expect(applyConversation(view, snapshot), "rootless child activity and final answer appear"); spin(); ConversationCard *answerCard = card(view, stableKey(answer.key)); @@ -2126,10 +3147,9 @@ bool testRootlessFinalAnswerGeometrySettlement() { return false; answerCard->setMinimumHeight(600); answerCard->resize(answerCard->width(), 600); - std::get(answer.payload).text += - "\n\nValidation passed."; + std::get(answer.payload).text += "\n\nValidation passed."; snapshot.sections.front().cards.back() = answer; - result &= expect(view.reconcile(snapshot), + result &= expect(applyConversation(view, snapshot), "rootless final answer accepts an authoritative update"); spin(); QLabel *answerBody = nullptr; @@ -2148,228 +3168,3627 @@ bool testRootlessFinalAnswerGeometrySettlement() { return result; } -bool testRetainedNestedFinalAnswerGeometrySettlement() { - const QString originalStyleSheet = qApp->styleSheet(); - qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); - const std::string thread = "retained-nested-final-answer"; - const VisibleCardData prompt{ - AuthoritativeItemKey{thread, "turn", "prompt"}, - CardKind::UserMessage, - thread, - "turn", - "prompt", - UserMessageData{"Please provide the complete retained report.", {}}}; - QString markdown = QStringLiteral( - "The retained report contains enough Markdown to require its final " - "nested width before height calculation.\n\n" - "Its complete list must remain inside the final-answer border:\n\n"); - for (int index = 1; index <= 14; ++index) - markdown += QStringLiteral( - "- Retained result %1 with explanatory text, **emphasis**, " - "and enough detail to wrap naturally at the nested card " - "width.\n") - .arg(index); - const VisibleCardData answer{ - AuthoritativeItemKey{thread, "turn", "answer"}, - CardKind::AgentMessage, - thread, - "turn", - "answer", - AgentMessageData{"Retained final answer is materializing.", true}}; - TurnSection section{"turn:retained", "turn", {prompt}, prompt.key}; - for (int index = 0; index < 4; ++index) - section.cards.push_back( - agentCard(thread, "turn", index, - QStringLiteral("Retained update %1 preceding the final " - "answer with enough text to wrap.") - .arg(index))); - section.cards.push_back(answer); - ConversationSnapshot snapshot{thread, {std::move(section)}, 0, false}; +bool testRetainedNestedFinalAnswerGeometrySettlement() { + const QString originalStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); + const std::string thread = "retained-nested-final-answer"; + const VisibleCardData prompt{ + AuthoritativeItemKey{thread, "turn", "prompt"}, + CardKind::UserMessage, + thread, + "turn", + "prompt", + UserMessageData{"Please provide the complete retained report.", {}}}; + QString markdown = QStringLiteral( + "The retained report contains enough Markdown to require its final " + "nested width before height calculation.\n\n" + "Its complete list must remain inside the final-answer border:\n\n"); + for (int index = 1; index <= 48; ++index) + markdown += QStringLiteral( + "- Retained result %1 with explanatory text, **emphasis**, " + "and enough detail to wrap naturally at the nested card " + "width.\n") + .arg(index); + markdown += QStringLiteral( + "\nRenamed:\n\n" + "- `src/codex/PresentationStatus.h` → `src/codex/UiStatus.h`\n\n" + "\n\n" + "No remote operation was performed; the final line must remain fully " + "visible.\n"); + const VisibleCardData answer{ + AuthoritativeItemKey{thread, "turn", "answer"}, + CardKind::AgentMessage, + thread, + "turn", + "answer", + AgentMessageData{"Retained final answer is materializing.", true}}; + TurnGraphSpec section{"turn:retained", "turn", {prompt}, prompt.key}; + for (int index = 0; index < 4; ++index) + section.cards.push_back( + agentCard(thread, "turn", index, + QStringLiteral("Retained update %1 preceding the final " + "answer with enough text to wrap.") + .arg(index))); + section.cards.push_back(answer); + ConversationGraphSpec snapshot{thread, {std::move(section)}, 0, false}; + + ConversationView view; + view.resize(980, 420); + bool result = + expect(applyConversation(view, snapshot), + "retained prompt and partial final answer materialize initially"); + std::get(snapshot.sections.front().cards.back().payload) + .text = utf8(markdown); + result &= expect(applyConversation(view, snapshot), + "retained hydration completes before first exposure"); + view.resize(560, 420); + view.show(); + result &= spinUntil([&] { + return view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }); + spin(160); + ConversationCard *promptCard = card(view, stableKey(prompt.key)); + ConversationCard *answerCard = card(view, stableKey(answer.key)); + QLabel *answerBody = nullptr; + if (answerCard) + for (QLabel *label : answerCard->findChildren()) + if (label->property("markdownSource").toString() == markdown) { + answerBody = label; + break; + } + int documentHeight = 0; + if (answerBody) { + QTextDocument document; + document.setDefaultFont(answerBody->font()); + document.setDocumentMargin(0); + document.setHtml(answerBody->text()); + document.setTextWidth(answerBody->width()); + documentHeight = static_cast(std::ceil(document.size().height())); + } + if (!(promptCard && answerCard && answerBody && + promptCard->isAncestorOf(answerCard) && + answerBody->height() >= + documentHeight + answerBody->fontMetrics().descent() && + answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= + answerCard->contentsRect().bottom() + 1)) + std::cerr << "nested final settle: prompt=" << bool(promptCard) + << " answer=" << bool(answerCard) + << " body=" << bool(answerBody) << " bodyHeight=" + << (answerBody ? answerBody->height() : -1) + << " documentHeight=" << documentHeight << " cardBottom=" + << (answerCard ? answerCard->contentsRect().bottom() : -1) + << " bodyBottom=" + << (answerBody ? answerBody + ->mapTo(answerCard, + QPoint(0, answerBody->height())) + .y() + : -1) + << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << '\n'; + const int answerBottomInPrompt = + promptCard && answerCard + ? answerCard->mapTo(promptCard, QPoint(0, answerCard->height())).y() + : -1; + result &= expect( + promptCard && answerCard && answerBody && + promptCard->isAncestorOf(answerCard) && + answerBody->height() >= + documentHeight + answerBody->fontMetrics().descent() && + answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= + answerCard->contentsRect().bottom() + 1 && + answerBottomInPrompt <= promptCard->contentsRect().bottom() + 1, + "an initially retained nested final answer fully fits its rendered " + "document, inner card, and canonical Turn/You owner"); + + QPointer retainedPrompt = promptCard; + QPointer retainedAnswer = answerCard; + const VisibleCardData laterPrompt{ + AuthoritativeItemKey{thread, "later-turn", "later-prompt"}, + CardKind::UserMessage, + thread, + "later-turn", + "later-prompt", + UserMessageData{"A later prompt arrives after the long answer.", {}}}; + const VisibleCardData laterAnswer{ + AuthoritativeItemKey{thread, "later-turn", "later-answer"}, + CardKind::AgentMessage, + thread, + "later-turn", + "later-answer", + AgentMessageData{"The later result is complete.", true}}; + snapshot.sections.push_back( + {"turn:later", "later-turn", {laterPrompt, laterAnswer}, laterPrompt.key}); + result &= expect(applyConversation(view, snapshot), + "a later completed Turn is appended after the long answer"); + spin(160); + promptCard = card(view, stableKey(prompt.key)); + answerCard = card(view, stableKey(answer.key)); + ConversationCard *laterPromptCard = card(view, stableKey(laterPrompt.key)); + QWidget *retainedSection = promptCard ? promptCard->parentWidget() : nullptr; + while (retainedSection && + retainedSection->property("turnSectionKey").toString().isEmpty()) + retainedSection = retainedSection->parentWidget(); + const int retainedAnswerBottom = + promptCard && answerCard + ? answerCard->mapTo(promptCard, QPoint(0, answerCard->height())).y() + : -1; + const int promptBottomInSection = + retainedSection && promptCard + ? promptCard->mapTo(retainedSection, + QPoint(0, promptCard->height())) + .y() + : -1; + const int promptBottomInViewport = + promptCard + ? promptCard->mapTo(view.viewport(), + QPoint(0, promptCard->height())) + .y() + : -1; + const int laterTopInViewport = + laterPromptCard + ? laterPromptCard->mapTo(view.viewport(), QPoint()).y() + : -1; + result &= expect( + promptCard && answerCard && laterPromptCard && retainedSection && + retainedPrompt == promptCard && + retainedAnswer == answerCard && promptCard->isAncestorOf(answerCard) && + retainedAnswerBottom <= promptCard->contentsRect().bottom() + 1 && + promptBottomInSection <= retainedSection->contentsRect().bottom() + 1 && + laterTopInViewport >= promptBottomInViewport + 8, + "appending a later conversation card cannot clip the retained long " + "answer through its Turn/You owner or section boundary"); + spin(); + qApp->setStyleSheet(originalStyleSheet); + return result; +} + +bool testBottomAnchoredCommandOutputGrowth() { + const std::string thread = "bottom-anchored-output"; + ConversationGraphSpec snapshot = conversation(thread, 14); + VisibleCardData command{ + AuthoritativeItemKey{thread, "turn-2", "live-command"}, + CardKind::CommandExecution, + thread, + "turn-2", + "live-command", + CommandExecutionData{ + "run live command", {}, "inProgress", {}, std::nullopt}}; + snapshot.sections.back().cards.push_back(command); + + ConversationView view; + view.resize(620, 360); + view.show(); + applyConversation(view, snapshot); + spinUntil([&] { + return view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }); + ConversationCard *commandCard = card(view, stableKey(command.key)); + bool result = expect(setFolded(commandCard, false), + "live command expands from its compact default"); + wheel(view, -10000); + auto *metadata = + commandCard + ? commandCard->findChild(QStringLiteral("commandMetadata")) + : nullptr; + auto *status = + commandCard + ? commandCard->findChild(QStringLiteral("commandStatus")) + : nullptr; + auto *output = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= expect(commandCard && metadata && metadata->isHidden() && status && + output && output->isHidden() && view.isAtBottom() && + status->property("tone") == "active", + "live command starts with a hidden zero-line output"); + if (!commandCard || !metadata || !status || !output) + return false; + const int cardBottomBefore = + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); + + auto &live = std::get( + snapshot.sections.back().cards.back().payload); + live.output = + "first wrapped output line with enough words to use real width\n" + "second output line\nthird output line\n\n"; + result &= + expect(applyConversation(view, snapshot), "live output becomes visible"); + spinUntil([&] { + return !output->isHidden() && output->height() > 2 * 20 && + output->height() == output->sizeHint().height(); + }); + const int cardBottomAfter = + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); + result &= expect(!output->isHidden() && output->height() > 2 * 20 && + output->height() == output->sizeHint().height() && + cardBottomAfter == cardBottomBefore && view.isAtBottom(), + "multiline output takes its needed height and grows upward"); + + QString cappedOutput; + for (int line = 0; line < 80; ++line) + cappedOutput += QStringLiteral("scrollable line %1\n").arg(line); + live.output = utf8(cappedOutput); + result &= + expect(applyConversation(view, snapshot), "live output reaches its cap"); + spinUntil([&] { + return output->height() == 220 && + output->verticalScrollBar()->maximum() > 0; + }); + if (!(output->height() == 220 && + output->verticalScrollBar()->maximum() > 0 && + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) + .y() == cardBottomBefore)) + std::cerr << "capped output: height=" << output->height() + << " maximum=" << output->verticalScrollBar()->maximum() + << " presentedBytes=" + << std::get(commandCard->data().payload) + .output.size() + << " expectedBytes=" << utf8(cappedOutput).size() + << " bottom=" + << commandCard + ->mapTo(view.viewport(), QPoint(0, commandCard->height())) + .y() + << " expectedBottom=" << cardBottomBefore << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << " blocker=" + << view.property("bulkMaterializationBlocker") + .toString() + .toStdString() + << '\n'; + result &= expect( + output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) + .y() == cardBottomBefore, + "capped output keeps its scrollbar and fixed card bottom"); + + const qulonglong geometryBeforeAppend = + view.property("conversationGeometryPasses").toULongLong(); + QPointer retainedCommand = commandCard; + live.output += "one more append-only streaming line\n"; + result &= expect(applyConversation(view, snapshot), + "capped output accepts another streaming append"); + spin(); + result &= expect( + retainedCommand == commandCard && output->height() == 220 && + view.property("conversationGeometryPasses").toULongLong() == + geometryBeforeAppend && + commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) + .y() == cardBottomBefore, + "append-only capped output repaints its retained card without a " + "conversation geometry pass"); + return result; +} + +bool testCommandOutputStateAcrossNavigation() { + const std::string thread = "command-navigation-thread"; + QString output; + for (int line = 0; line < 80; ++line) + output += QStringLiteral("retained line %1\n").arg(line); + const VisibleCardData command{ + AuthoritativeItemKey{thread, "turn", "command"}, + CardKind::CommandExecution, + thread, + "turn", + "command", + CommandExecutionData{"produce output", utf8(output), "completed", {}, 0}}; + const ConversationGraphSpec commandThread{ + thread, {{"turn:command-navigation", "turn", {command}}}, 0, false}; + + ConversationView view; + view.resize(650, 520); + view.show(); + applyConversation(view, commandThread); + spin(); + ConversationCard *commandCard = card(view, stableKey(command.key)); + bool result = expect(setFolded(commandCard, false), + "navigation command expands from its compact default"); + auto *initialOutput = commandCard + ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= + expect(initialOutput && initialOutput->verticalScrollBar()->maximum() > 0, + "navigation test has independently scrollable output"); + if (!initialOutput) + return false; + applyConversation(view, conversation("other-thread", 8)); + spin(); + applyConversation(view, commandThread); + spin(); + commandCard = card(view, stableKey(command.key)); + initialOutput = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= expect(initialOutput && initialOutput->followsLatest() && + initialOutput->verticalScrollBar()->value() == + initialOutput->verticalScrollBar()->maximum(), + "framework geometry during navigation does not pause a " + "following command output"); + if (!initialOutput) + return false; + initialOutput->verticalScrollBar()->triggerAction( + QAbstractSlider::SliderSingleStepSub); + spin(); + const int pausedValue = initialOutput->verticalScrollBar()->value(); + result &= expect(!initialOutput->followsLatest(), + "command output is paused before thread navigation"); + + applyConversation(view, conversation("other-thread", 8)); + spin(); + applyConversation(view, commandThread); + spin(); + commandCard = card(view, stableKey(command.key)); + auto *restoredOutput = commandCard + ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + result &= + expect(restoredOutput && !restoredOutput->followsLatest() && + restoredOutput->verticalScrollBar()->value() == pausedValue, + "thread navigation restores paused command output state"); + return result; +} + +#if defined(CODEXUI_DIRECT_GRAPH_WIDGET_TESTS) +bool testLoadedWindowMaterializesOnce() { + const std::string thread = "viewport-lazy"; + ConversationGraphSpec snapshot = conversation(thread, 240); + ConversationView view; + view.resize(620, 360); + view.show(); + + bool result = expect(applyConversation(view, snapshot), + "a large conversation creates its lazy geometry"); + const int immediateCards = liveConversationWidgetCounts(view).cards; + result &= expect(immediateCards <= 8, + "the first loaded-window pass remains card-budgeted"); + const bool loadedWindowReady = spinUntil([&] { + const LiveConversationWidgetCounts widgets = + liveConversationWidgetCounts(view); + return widgets.cards == static_cast(AuthoritativeHistoryPageSize) && + widgets.itemPlaceholders == 0; + }, 512); + const LiveConversationWidgetCounts settled = + liveConversationWidgetCounts(view); + result &= expect( + loadedWindowReady && + settled.cards == static_cast(AuthoritativeHistoryPageSize) && + settled.turnSections <= 2 && view.isAtBottom() && + historyButton(view)->isVisible(), + "thread selection materializes exactly the loaded 80-card window in " + "bounded continuations"); + + const std::string firstKey = + stableKey(snapshot.sections.back().cards[40].key); + const std::string lastKey = + stableKey(snapshot.sections.back().cards.back().key); + const QVariant firstRetainedScroll = + view.property("graphFirstRetainedScrollValue"); + QPointer firstIdentity = card(view, firstKey); + QPointer lastIdentity = card(view, lastKey); + const qulonglong geometryBeforeScroll = + view.property("conversationGeometryPasses").toULongLong(); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + if (firstRetainedScroll.isValid()) + view.verticalScrollBar()->setValue(firstRetainedScroll.toInt()); + spin(100); + result &= expect(view.mode() == ConversationView::Mode::Paused, + "scrolling a large conversation pauses following"); + result &= expect(card(view, firstKey) == firstIdentity && + card(view, lastKey) == lastIdentity, + "scrolling retains both ends of the loaded card window"); + const LiveConversationWidgetCounts scrolled = + liveConversationWidgetCounts(view); + result &= expect( + firstRetainedScroll.isValid() && + scrolled.cards == settled.cards && scrolled.turnSections <= 2 && + view.property("conversationGeometryPasses").toULongLong() == + geometryBeforeScroll && + graphPassBudgetsWereRespected(view), + "viewport movement performs no card construction, destruction, or " + "geometry pass while initial materialization remains pass-budgeted"); + return result; +} + +// Retained only as a record of the discarded direct-graph QWidget scanner. +// The production widget API intentionally has no bindGraph/graphChanged seam; +// canonical graph-to-snapshot coverage lives in NodeGraphConversationUiTest. +bool testGraphBackedLazyRenderingAndLifetime() { + GraphConversationFixture fixture; + ConversationView view; + view.resize(620, 360); + view.show(); + ConversationView::PresentationOptions options = view.presentationOptions(); + options.showReasoning = false; + view.setPresentationOptions(options); + view.bindGraph(fixture.graph, fixture.thread); + + const auto materializedCount = [&view] { + return static_cast(std::ranges::count_if( + view.findChildren(), [](QWidget *widget) { + return dynamic_cast(widget) != nullptr; + })); + }; + bool result = expect( + materializedCount() <= 8 && graphAttachment(fixture.reasoning) == nullptr, + "graph binding performs at most eight immediate renders and leaves a " + "filtered item unmaterialized"); + const bool loadedWindowReady = spinUntil([&] { + return view.property("graphStructureScanComplete").toBool() && + view.property("graphLiveRecordCount").toULongLong() == + fixture.messages.size() && + std::ranges::all_of( + fixture.messages, [](const nodegraph::NodeRef &message) { + const auto *attachment = graphAttachment(message); + return attachment && attachment->widget; + }) && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 1024); + spin(); + + nodegraph::NodeRef offscreen = fixture.messages.front(); + nodegraph::NodeRef visible = fixture.messages.back(); + ui::QtNodeAttachment *offscreenInitialAttachment = + graphAttachment(offscreen); + const std::uint64_t offscreenInitialRevision = + offscreenInitialAttachment + ? offscreenInitialAttachment->renderedRevision + : 0; + result &= expect(loadedWindowReady && offscreenInitialAttachment, + "every card in the selected loaded window materializes " + "once, including its initially off-screen cards"); + ui::QtNodeAttachment *visibleAttachment = graphAttachment(visible); + QPointer visibleIdentity = + visibleAttachment ? visibleAttachment->widget : nullptr; + result &= expect(visibleAttachment && visibleIdentity, + "a viewport graph node owns its Qt attachment"); + + nodegraph::GraphChange visibleChange; + { + auto graphWrite = fixture.graph.write(); + graphWrite.setField(visible, "text", "Visible graph revision"); + visibleChange = graphWrite.finish(); + } + view.graphChanged(visibleChange.removed); + const bool visibleRevisionRendered = spinUntil([&] { + const auto *attachment = graphAttachment(visible); + return attachment && + attachment->renderedRevision == visibleChange.revision; + }); + visibleAttachment = graphAttachment(visible); + auto *visibleCard = + visibleAttachment + ? qobject_cast(visibleAttachment->widget.data()) + : nullptr; + const auto *visibleMessage = + visibleCard ? std::get_if(&visibleCard->data().payload) + : nullptr; + if (!(visibleAttachment && visibleAttachment->widget == visibleIdentity && + visibleAttachment->renderedRevision == visibleChange.revision && + visibleMessage && visibleMessage->text == "Visible graph revision")) + std::cerr << "visible revision: attachment=" << bool(visibleAttachment) + << " viewport=" + << (visibleAttachment ? visibleAttachment->viewportVisible : 0) + << " rendered=" + << (visibleAttachment ? visibleAttachment->renderedRevision : 0) + << " expected=" << visibleChange.revision << " text=" + << (visibleMessage ? visibleMessage->text : "") + << " scroll=" << view.verticalScrollBar()->value() << '/' + << view.verticalScrollBar()->maximum() << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << '\n'; + result &= expect( + visibleRevisionRendered && visibleAttachment && + visibleAttachment->widget == visibleIdentity && + visibleAttachment->renderedRevision == visibleChange.revision && + visibleMessage && visibleMessage->text == "Visible graph revision", + "a visible node revision updates the existing attached card"); + + nodegraph::GraphChange deferredChange; + { + auto graphWrite = fixture.graph.write(); + graphWrite.setField(offscreen, "text", "Deferred off-screen revision"); + graphWrite.setField(fixture.reasoning, "summary", + "Deferred filtered reasoning revision"); + deferredChange = graphWrite.finish(); + } + view.graphChanged(deferredChange.removed); + spin(40); + result &= expect( + graphAttachment(offscreen) == offscreenInitialAttachment && + graphAttachment(offscreen)->renderedRevision == + offscreenInitialRevision && + graphAttachment(fixture.reasoning) == nullptr, + "off-screen loaded and filtered node updates perform no QWidget " + "projection while retaining existing card identity"); + + options.showReasoning = true; + view.setPresentationOptions(options); + spin(20); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + const bool newlyVisibleRendered = spinUntil([&] { + const auto *offscreenCurrent = graphAttachment(offscreen); + const auto *reasoningCurrent = graphAttachment(fixture.reasoning); + return offscreenCurrent && reasoningCurrent && + offscreenCurrent->renderedRevision == deferredChange.revision; + }); + ui::QtNodeAttachment *offscreenAttachment = graphAttachment(offscreen); + auto *offscreenCard = + offscreenAttachment + ? qobject_cast(offscreenAttachment->widget.data()) + : nullptr; + const auto *offscreenMessage = + offscreenCard + ? std::get_if(&offscreenCard->data().payload) + : nullptr; + ui::QtNodeAttachment *reasoningAttachment = + graphAttachment(fixture.reasoning); + auto *reasoningCard = + reasoningAttachment + ? qobject_cast(reasoningAttachment->widget.data()) + : nullptr; + const auto *reasoningData = + reasoningCard ? std::get_if(&reasoningCard->data().payload) + : nullptr; + result &= expect( + newlyVisibleRendered && offscreenAttachment && + offscreenAttachment->renderedRevision == deferredChange.revision && + offscreenMessage && + offscreenMessage->text == "Deferred off-screen revision" && + reasoningAttachment && reasoningData && + reasoningData->summary == "Deferred filtered reasoning revision", + "newly visible nodes render once from their latest graph state"); + + const std::uint64_t renderedBeforeContention = + offscreenAttachment ? offscreenAttachment->renderedRevision : 0; + const qulonglong retriesBeforeContention = + view.property("graphContentionRetryCount").toULongLong(); + auto contendedWrite = fixture.graph.write(); + contendedWrite.setField(offscreen, "text", "Rendered after lock retry"); + view.graphChanged(); + QCoreApplication::processEvents(QEventLoop::AllEvents, 5); + result &= expect( + view.property("graphContentionRetryDelayMs").toInt() > 0 && + view.property("graphContentionRetryDelayMs").toInt() <= 16 && + view.property("graphContentionRetryCount").toULongLong() > + retriesBeforeContention && + graphAttachment(offscreen) && + graphAttachment(offscreen)->renderedRevision == + renderedBeforeContention, + "a contended graph read returns to Qt and schedules a nonzero bounded " + "retry without rendering stale state"); + const nodegraph::GraphChange contentionChange = contendedWrite.finish(); + spin(60); + offscreenAttachment = graphAttachment(offscreen); + offscreenCard = + offscreenAttachment + ? qobject_cast(offscreenAttachment->widget.data()) + : nullptr; + offscreenMessage = + offscreenCard + ? std::get_if(&offscreenCard->data().payload) + : nullptr; + result &= expect( + offscreenAttachment && + offscreenAttachment->renderedRevision == contentionChange.revision && + offscreenMessage && + offscreenMessage->text == "Rendered after lock retry", + "the already-scheduled retry renders the current revision after " + "contention clears without another notification"); + + QPointer removedWidget = + offscreenAttachment ? offscreenAttachment->widget : nullptr; + auto removalWrite = fixture.graph.write(); + removalWrite.remove(offscreen); + const nodegraph::GraphChange removal = removalWrite.finish(); + view.graphChanged(removal.removed); + result &= + expect(offscreen->uiAttachment() == nullptr && removedWidget.isNull(), + "removal synchronously clears the node attachment and " + "deletes its widget"); + spin(20); + return result; +} + +bool testGraphStructureScanSurvivesUnrelatedRevisionChurn() { + constexpr std::size_t ItemCount = 400; + constexpr std::size_t AdditionalPages = 3; + constexpr std::size_t ExpectedScanTarget = + (AdditionalPages + 1) * AuthoritativeHistoryPageSize; + constexpr int ChurnRevisions = 96; + constexpr int EventDispatchLimit = 512; + + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef unrelated; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", ItemCount); + thread = write.upsert({nodegraph::NodeKind::Thread, "scan-thread"}, + std::move(threadState)); + nodegraph::NodeRef turn = + write.upsert({nodegraph::NodeKind::Turn, "scan-turn"}); + write.setParent(thread, turn); + for (std::size_t index = 0; index < ItemCount; ++index) { + nodegraph::NodeState state = graphMessageState( + "agentMessage", "Scanned history " + std::to_string(index)); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "scan-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + } + + nodegraph::NodeRef unrelatedThread = + write.upsert({nodegraph::NodeKind::Thread, "unrelated-scan-thread"}); + nodegraph::NodeRef unrelatedTurn = + write.upsert({nodegraph::NodeKind::Turn, "unrelated-scan-turn"}); + unrelated = + write.upsert({nodegraph::NodeKind::Item, "unrelated-scan-item"}, + graphMessageState("agentMessage", "Unrelated activity")); + write.setParent(unrelatedThread, unrelatedTurn); + write.setParent(unrelatedTurn, unrelated); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + view.show(); + view.bindGraph(graph, thread); + + QPushButton *loadMore = historyButton(view); + const qulonglong retainedAtFirstYield = + view.property("graphRetainedGeometryRecordCount").toULongLong(); + bool result = expect( + loadMore && retainedAtFirstYield > 32 && + retainedAtFirstYield < AuthoritativeHistoryPageSize && + !view.property("graphStructureScanComplete").toBool(), + "the fixture yields during an actually incomplete selected-history " + "structure scan after more than thirty-two records"); + if (!loadMore) + return false; + for (std::size_t page = 0; page < AdditionalPages; ++page) + loadMore->click(); + + int churnCount = 0; + int completedAtChurn = -1; + std::function churn; + churn = [&] { + if (completedAtChurn < 0 && + view.property("graphStructureScanComplete").toBool() && + view.property("graphStructureScanTarget").toULongLong() >= + ExpectedScanTarget) + completedAtChurn = churnCount; + if (churnCount >= ChurnRevisions) + return; + + nodegraph::GraphChange change; + { + auto write = graph.write(); + write.setField(unrelated, "unrelatedRevision", + static_cast(churnCount + 1)); + change = write.finish(); + } + ++churnCount; + view.graphChangedDeferred(change.affected, change.removed); + if (churnCount < ChurnRevisions) + QTimer::singleShot(0, &view, churn); + }; + QTimer::singleShot(0, &view, churn); + + int dispatches = 0; + while (churnCount < ChurnRevisions && dispatches < EventDispatchLimit) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 1); + ++dispatches; + } + + result &= expect( + churnCount == ChurnRevisions && completedAtChurn >= 0 && + completedAtChurn < ChurnRevisions && + view.property("graphStructureScanComplete").toBool() && + view.property("graphStructureScanTarget").toULongLong() >= + ExpectedScanTarget && + view.property("graphRetainedGeometryRecordCount").toULongLong() >= + ExpectedScanTarget && + graphPassBudgetsWereRespected(view), + "continuous unrelated Item field revisions cannot restart or starve the " + "bounded selected-history structure scan"); + return result; +} + +bool testGraphGeometryScanSurvivesVisibleHeightChurn() { + constexpr int FilteredItems = 78; + constexpr int ChurnRevisions = 48; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef streaming; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", FilteredItems + 2); + thread = + write.upsert({nodegraph::NodeKind::Thread, "geometry-churn-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "geometry-churn-turn"}); + write.setParent(thread, turn); + + nodegraph::NodeState sentinel = + graphMessageState("agentMessage", "Visible sentinel"); + sentinel.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef sentinelNode = + write.upsert({nodegraph::NodeKind::Item, "geometry-churn-sentinel"}, + std::move(sentinel)); + write.setParent(turn, sentinelNode); + for (int index = 0; index < FilteredItems; ++index) { + nodegraph::NodeState hidden; + hidden.status = nodegraph::NodeStatus::Completed; + hidden.fields.emplace("type", "reasoning"); + hidden.fields.emplace("summary", + "Filtered geometry " + std::to_string(index)); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "geometry-churn-hidden-" + std::to_string(index)}, + std::move(hidden)); + write.setParent(turn, item); + } + nodegraph::NodeState initial = + graphMessageState("agentMessage", "Initial visible stream"); + initial.fields.emplace("phase", "final_answer"); + streaming = + write.upsert({nodegraph::NodeKind::Item, "geometry-churn-stream"}, + std::move(initial)); + write.setParent(turn, streaming); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + ConversationView::PresentationOptions options = view.presentationOptions(); + options.showReasoning = false; + view.setPresentationOptions(options); + view.show(); + view.bindGraph(graph, thread); + const bool initiallyVisible = dispatchUntil([&] { + ui::QtNodeAttachment *attachment = graphAttachment(streaming); + return attachment && attachment->widget && attachment->viewportVisible && + view.property("graphStructureScanComplete").toBool() && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }); + std::uint64_t latestRevision = 0; + std::string latestText; + int churnCount = 0; + int renderedDuringChurn = -1; + view.resize(430, 360); + const qulonglong fullGeometryPassesBeforeChurn = + view.property("conversationFullGeometryPasses").toULongLong(); + std::function churn; + churn = [&] { + if (latestRevision != 0) { + ui::QtNodeAttachment *attachment = graphAttachment(streaming); + if (attachment && attachment->renderedRevision >= latestRevision && + renderedDuringChurn < 0) + renderedDuringChurn = churnCount; + } + if (churnCount >= ChurnRevisions) + return; + + ++churnCount; + latestText = "Visible stream revision " + std::to_string(churnCount); + const int lines = churnCount % 2 == 0 ? 14 : 2; + for (int line = 0; line < lines; ++line) + latestText += "\nheight-changing selected text " + std::to_string(line); + nodegraph::GraphChange change; + { + auto write = graph.write(); + write.setField(streaming, "text", latestText); + change = write.finish(); + } + latestRevision = change.revision; + view.graphChangedDeferred(change.affected, change.removed); + if (churnCount < ChurnRevisions) + QTimer::singleShot(0, &view, churn); + }; + churn(); + + const bool churnCompleted = dispatchUntil( + [&] { return churnCount == ChurnRevisions; }, ChurnRevisions * 8); + const bool finalRevisionRendered = dispatchUntil([&] { + ui::QtNodeAttachment *attachment = graphAttachment(streaming); + auto *widget = + attachment ? qobject_cast(attachment->widget.data()) + : nullptr; + const auto *message = + widget ? std::get_if(&widget->data().payload) + : nullptr; + return attachment && attachment->renderedRevision >= latestRevision && + message && message->text == latestText; + }); + + return expect( + initiallyVisible && churnCompleted && renderedDuringChurn >= 0 && + renderedDuringChurn < ChurnRevisions && finalRevisionRendered && + view.property("conversationFullGeometryPasses").toULongLong() == + fullGeometryPassesBeforeChurn && + view.property("graphMaxGeometryRecordsPerPass").toULongLong() > 0 && + view.property("graphMaxGeometryRecordsPerPass").toULongLong() <= 32 && + view.property("graphMaxCardOperationsPerPass").toULongLong() <= 8, + "continuous visible height revisions cannot restart and starve the " + "bounded geometry frontier"); +} + +bool testFocusedGraphCardSurvivesViewportReconciliation() { + constexpr int HistoryItems = 240; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef command; + std::vector messages; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", HistoryItems + 1); + thread = write.upsert({nodegraph::NodeKind::Thread, "focused-card-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "focused-card-turn"}); + write.setParent(thread, turn); + messages.reserve(HistoryItems); + for (int index = 0; index < HistoryItems; ++index) { + nodegraph::NodeState state = graphMessageState( + "agentMessage", "Focus history " + std::to_string(index)); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "focused-card-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + messages.push_back(std::move(item)); + } + nodegraph::NodeState commandState; + commandState.status = nodegraph::NodeStatus::Completed; + commandState.fields.emplace("type", "commandExecution"); + commandState.fields.emplace("command", "retain focused output"); + std::string output; + for (int line = 0; line < 60; ++line) + output += "focused output " + std::to_string(line) + "\n"; + commandState.fields.emplace("output", std::move(output)); + commandState.fields.emplace("status", "completed"); + command = write.upsert({nodegraph::NodeKind::Item, "focused-card-command"}, + std::move(commandState)); + write.setParent(turn, command); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + view.show(); + view.bindGraph(graph, thread); + const bool commandReady = dispatchUntil([&] { + ui::QtNodeAttachment *attachment = graphAttachment(command); + return attachment && attachment->widget && attachment->viewportVisible; + }); + QPointer commandCard; + if (ui::QtNodeAttachment *attachment = graphAttachment(command)) + commandCard = qobject_cast(attachment->widget.data()); + bool result = expect(commandReady && setFolded(commandCard, false), + "the focus-pinning fixture exposes its command output"); + QPointer output = + commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + if (!commandCard || !output) + return false; + view.raise(); + view.activateWindow(); + output->setFocus(Qt::OtherFocusReason); + dispatchPasses(2); + const bool focusEstablished = + QApplication::focusWidget() == output || + commandCard->isAncestorOf(QApplication::focusWidget()); + + const std::size_t retainedStart = + messages.size() - (AuthoritativeHistoryPageSize - 1); + nodegraph::NodeRef earliestRetained = messages[retainedStart]; + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + view.verticalScrollBar()->setValue( + view.property("graphFirstRetainedScrollValue").toInt()); + const bool distantViewportReady = dispatchUntil( + [&] { return graphAttachment(earliestRetained) != nullptr; }); + ui::QtNodeAttachment *pinnedAttachment = graphAttachment(command); + const bool retainedWhileFocused = + commandCard && pinnedAttachment && + pinnedAttachment->widget == commandCard && + (QApplication::focusWidget() == output || + commandCard->isAncestorOf(QApplication::focusWidget())); + if (!(focusEstablished && distantViewportReady && retainedWhileFocused)) { + std::cerr << "focus pin: established=" << focusEstablished + << " distant=" << distantViewportReady + << " retained=" << retainedWhileFocused + << " command=" << static_cast(commandCard) + << " attachment=" << static_cast(pinnedAttachment) + << " same=" + << (pinnedAttachment && pinnedAttachment->widget == commandCard) + << " focus=" + << (QApplication::focusWidget() + ? QApplication::focusWidget()->metaObject()->className() + : "null") + << '\n'; + } + result &= expect( + focusEstablished && distantViewportReady && retainedWhileFocused, + "viewport reconciliation pins a focused card while materializing a " + "distant viewport"); + + output->clearFocus(); + view.graphChangedDeferred(); + dispatchPasses(16); + result &= expect(graphAttachment(command) && + graphAttachment(command)->widget == commandCard, + "a loaded card retains its QWidget and local output state " + "after focus leaves and it becomes off-screen"); + return result; +} + +bool testGraphRootFoldSuppressesAndRestoresChildExtent() { + const std::string thread = "root-fold-extent"; + TurnGraphSpec preceding{"turn:root-fold-preceding", "preceding", {}}; + for (int index = 0; index < 12; ++index) + preceding.cards.push_back(agentCard(thread, "preceding", index)); + + const VisibleCardData root{ + AuthoritativeItemKey{thread, "fold-turn", "root"}, + CardKind::UserMessage, + thread, + "fold-turn", + "root", + UserMessageData{"Fold this complete turn without losing child state.", + {}}}; + TurnGraphSpec folded{"turn:root-fold-target", "fold-turn", {root}, root.key}; + for (int index = 0; index < 5; ++index) + folded.cards.push_back(agentCard(thread, "fold-turn", 100 + index)); + QString outputText; + for (int line = 0; line < 70; ++line) + outputText += QStringLiteral("retained child output %1\n").arg(line); + const VisibleCardData command{ + AuthoritativeItemKey{thread, "fold-turn", "command"}, + CardKind::CommandExecution, + thread, + "fold-turn", + "command", + CommandExecutionData{"preserve child interaction", utf8(outputText), + "completed", "/workspace", 0}}; + folded.cards.push_back(command); + ConversationGraphSpec snapshot{ + thread, {std::move(preceding), std::move(folded)}, 0, false}; + + ConversationView view; + view.resize(650, 520); + view.show(); + const bool applied = applyConversation(view, snapshot); + const bool cardsReady = dispatchUntil([&] { + ui::QtNodeAttachment *commandAttachment = nullptr; + if (snapshot.storage) { + auto read = snapshot.storage->graph.tryRead(); + if (read) { + nodegraph::NodeRef commandNode = + read->find({nodegraph::NodeKind::Item, fixtureNodeId(command)}); + commandAttachment = graphAttachment(commandNode); + } + } + return card(view, stableKey(root.key)) && + card(view, stableKey(command.key)) && commandAttachment && + commandAttachment->viewportVisible; + }); + // Measure both ends of the retained window before comparing the fold + // round-trip. Otherwise previously unseen cards in the preceding turn can + // legitimately replace their estimated heights while the target turn is + // folded, obscuring the target turn's exact effective-extent invariant. + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + const bool precedingMeasured = dispatchUntil([&] { + return card(view, stableKey(snapshot.sections.front().cards.front().key)); + }); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); + const bool targetRestored = dispatchUntil([&] { + return card(view, stableKey(root.key)) && + card(view, stableKey(command.key)); + }); + QPointer rootCard = card(view, stableKey(root.key)); + QPointer commandCard = card(view, stableKey(command.key)); + QPointer commandDisclosure = disclosure(commandCard); + if (commandDisclosure) { + commandDisclosure->setFocus(Qt::OtherFocusReason); + dispatchPasses(1); + } + bool result = + expect(applied && cardsReady && precedingMeasured && targetRestored && + rootCard && commandCard && setFolded(commandCard, false), + "the root-fold fixture materializes an expanded child"); + QPointer output = + commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + if (!rootCard || !commandCard || !output) + return false; + if (commandDisclosure) + commandDisclosure->clearFocus(); + if (output->verticalScrollBar()->maximum() > 0) + output->verticalScrollBar()->setValue( + output->verticalScrollBar()->maximum() / 2); + dispatchPasses(64); + const auto childStateBefore = commandCard->commandOutputScrollState(); + const int expandedExtent = view.verticalScrollBar()->maximum(); + result &= expect(setFolded(rootCard, true), + "the authoritative root folds its child activity"); + dispatchPasses(64); + const int foldedExtent = view.verticalScrollBar()->maximum(); + const bool childSuppressed = + !commandCard || !commandCard->isVisibleTo(view.viewport()); + result &= expect( + foldedExtent < expandedExtent && childSuppressed, + "a folded root removes child geometry from the effective scroll extent"); + + result &= expect(setFolded(rootCard, false), + "the authoritative root expands after suppression"); + const bool childRestored = dispatchUntil([&] { + return card(view, stableKey(command.key)) != nullptr && + view.verticalScrollBar()->maximum() == expandedExtent; + }); + commandCard = card(view, stableKey(command.key)); + output = commandCard ? dynamic_cast( + commandCard->findChild( + QStringLiteral("commandOutputView"))) + : nullptr; + const auto childStateAfter = + commandCard ? commandCard->commandOutputScrollState() : std::nullopt; + result &= expect( + childRestored && commandCard && output && !commandCard->isCollapsed() && + childStateBefore && childStateAfter && + childStateAfter->value == childStateBefore->value && + childStateAfter->followsLatest == childStateBefore->followsLatest && + view.verticalScrollBar()->maximum() == expandedExtent, + "expanding a root restores the exact extent and child interaction state"); + return result; +} + +bool testLargeGraphResetUsesBoundedRetiredCleanup() { + constexpr std::size_t ItemCount = 640; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", ItemCount); + thread = + write.upsert({nodegraph::NodeKind::Thread, "retired-cleanup-thread"}, + std::move(threadState)); + nodegraph::NodeRef turn = + write.upsert({nodegraph::NodeKind::Turn, "retired-cleanup-turn"}); + write.setParent(thread, turn); + for (std::size_t index = 0; index < ItemCount; ++index) { + nodegraph::NodeState state = graphMessageState( + "agentMessage", "Retired geometry " + std::to_string(index)); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "retired-cleanup-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + } + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + view.show(); + view.bindGraph(graph, thread); + QPushButton *loadMore = historyButton(view); + bool result = expect(loadMore && loadMore->isVisible(), + "the cleanup fixture exposes retained history paging"); + if (!loadMore) + return false; + for (std::size_t page = AuthoritativeHistoryPageSize; page < ItemCount; + page += AuthoritativeHistoryPageSize) + loadMore->click(); + const bool retained = dispatchUntil([&] { + return view.property("graphStructureScanComplete").toBool() && + view.property("graphRetainedGeometryRecordCount").toULongLong() >= + ItemCount; + }); + result &= expect(retained, "the cleanup fixture retains its large geometry"); + + nodegraph::GraphChange removal; + { + auto write = graph.write(); + write.remove(thread); + removal = write.finish(); + } + view.graphChanged(removal.removed); + const QVariant retiredCount = + view.property("graphRetiredGeometryRecordCount"); + const QVariant lastCleanup = + view.property("graphLastRetiredCleanupOperations"); + const QVariant maxCleanup = view.property("graphMaxRetiredCleanupOperations"); + result &= expect( + retiredCount.isValid() && lastCleanup.isValid() && maxCleanup.isValid() && + retiredCount.toULongLong() >= ItemCount && + lastCleanup.toULongLong() <= 64 && maxCleanup.toULongLong() <= 64 && + view.property("graphRetainedGeometryRecordCount").toULongLong() == 0, + "large selected-thread removal retires geometry immediately without an " + "unbounded cleanup pass"); + + const bool cleanupFinished = dispatchUntil( + [&] { + return view.property("graphRetiredGeometryRecordCount").toULongLong() == + 0; + }, + 128); + result &= expect( + cleanupFinished && + view.property("graphLastRetiredCleanupOperations").toULongLong() <= + 64 && + view.property("graphMaxRetiredCleanupOperations").toULongLong() <= 64, + "retired geometry drains completely in fixed-size Qt cleanup slices"); + return result; +} + +bool testDeferredRefreshSurvivesRetirementAcknowledgement() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", 1); + thread = write.upsert( + {nodegraph::NodeKind::Thread, "deferred-retirement-thread"}, + std::move(threadState)); + nodegraph::NodeRef turn = write.upsert( + {nodegraph::NodeKind::Turn, "deferred-retirement-turn"}); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "deferred-retirement-item"}, + graphMessageState("agentMessage", "Visible before retirement")); + write.setParent(thread, turn); + write.setParent(turn, item); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + view.show(); + view.bindGraph(graph, thread); + bool result = expect( + dispatchUntil([&] { + return card(view, + stableKey(AuthoritativeItemKey{ + "deferred-retirement-thread", + "deferred-retirement-turn", + "deferred-retirement-item"})) != nullptr; + }), + "the deferred-retirement fixture materializes its selected thread"); + + nodegraph::GraphChange removal; + { + auto write = graph.write(); + write.remove(thread); + removal = write.finish(); + } + // FrontendSession acknowledges detached UI nodes immediately after the + // GraphChanged callback, before this view's deferred refresh executes. + view.graphChangedDeferred(removal.affected, removal.removed); + { + auto write = graph.write(); + write.releaseRetired(removal.removed); + static_cast(write.finish()); + } + dispatchPasses(8); + result &= expect( + card(view, + stableKey(AuthoritativeItemKey{"deferred-retirement-thread", + "deferred-retirement-turn", + "deferred-retirement-item"})) == + nullptr, + "a deferred Qt pass safely discards purged NodeRefs after detachment"); + return result; +} + +bool testAffectedNodeRequeuedAfterCursorConsumption() { + constexpr int ItemCount = 64; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef target; + std::vector fillers; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", ItemCount); + thread = + write.upsert({nodegraph::NodeKind::Thread, "affected-cursor-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "affected-cursor-turn"}); + write.setParent(thread, turn); + fillers.reserve(ItemCount - 1); + for (int index = 0; index < ItemCount - 1; ++index) { + nodegraph::NodeState state = graphMessageState( + "agentMessage", "Affected filler " + std::to_string(index)); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "affected-cursor-filler-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + fillers.push_back(std::move(item)); + } + nodegraph::NodeState hidden; + hidden.status = nodegraph::NodeStatus::Completed; + hidden.fields.emplace("type", "reasoning"); + hidden.fields.emplace("summary", "Initially filtered target"); + target = write.upsert({nodegraph::NodeKind::Item, "affected-cursor-target"}, + std::move(hidden)); + write.setParent(turn, target); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 360); + ConversationView::PresentationOptions options = view.presentationOptions(); + options.showReasoning = false; + view.setPresentationOptions(options); + view.show(); + view.bindGraph(graph, thread); + const bool initialScanComplete = dispatchUntil([&] { + return view.property("graphStructureScanComplete").toBool() && + view.property("graphRetainedGeometryRecordCount").toULongLong() >= + ItemCount; + }); + + std::vector affected; + affected.reserve(ItemCount); + affected.push_back(target); + affected.insert(affected.end(), fillers.begin(), fillers.end()); + view.graphChangedDeferred(affected, {}); + bool lateChangeSent = false; + std::uint64_t lateRevision = 0; + QTimer::singleShot(0, &view, [&] { + nodegraph::GraphChange change; + { + auto write = graph.write(); + write.setField(target, "type", "agentMessage"); + write.setField(target, "phase", "final_answer"); + write.setField(target, "text", "Late change after cursor consumption"); + change = write.finish(); + } + lateRevision = change.revision; + lateChangeSent = true; + view.graphChangedDeferred(change.affected, change.removed); + }); + + const bool lateProjectionRendered = dispatchUntil([&] { + if (!lateChangeSent) + return false; + ui::QtNodeAttachment *attachment = graphAttachment(target); + auto *widget = + attachment ? qobject_cast(attachment->widget.data()) + : nullptr; + const auto *message = + widget ? std::get_if(&widget->data().payload) + : nullptr; + return attachment && attachment->renderedRevision >= lateRevision && + message && message->text == "Late change after cursor consumption"; + }); + return expect( + initialScanComplete && graphAttachment(target) && lateChangeSent && + lateProjectionRendered && graphPassBudgetsWereRespected(view), + "an affected node changed after cursor consumption is requeued and " + "rendered exactly from its latest graph state"); +} + +bool testGraphStreamTruncationNotices() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef agent; + nodegraph::NodeRef command; + nodegraph::NodeRef reasoning; + nodegraph::NodeRef plan; + const auto addRetention = [](nodegraph::NodeState &state, std::string field, + std::uint64_t omitted, std::uint64_t retained) { + nodegraph::Value::Object entry{ + {"discardedBytes", nodegraph::Value(omitted)}, + {"retainedBytes", nodegraph::Value(retained)}}; + nodegraph::Value::Object retention; + retention.emplace(std::move(field), nodegraph::Value(std::move(entry))); + state.fields.emplace("textRetention", + nodegraph::Value(std::move(retention))); + }; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{4}); + thread = write.upsert({nodegraph::NodeKind::Thread, "truncation-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "truncation-turn"}); + write.setParent(thread, turn); + + nodegraph::NodeState agentState = + graphMessageState("agentMessage", "retained response"); + agentState.fields.emplace("phase", "final_answer"); + addRetention(agentState, "text", 123, 17); + agent = write.upsert({nodegraph::NodeKind::Item, "truncation-agent"}, + std::move(agentState)); + + nodegraph::NodeState commandState; + commandState.status = nodegraph::NodeStatus::Completed; + commandState.fields = {{"type", "commandExecution"}, + {"command", "printf retained"}, + {"aggregatedOutput", "retained output"}}; + addRetention(commandState, "aggregatedOutput", 456, 15); + command = write.upsert({nodegraph::NodeKind::Item, "truncation-command"}, + std::move(commandState)); + + nodegraph::NodeState reasoningState; + reasoningState.status = nodegraph::NodeStatus::Completed; + reasoningState.fields = { + {"type", "reasoning"}, + {"summary", + nodegraph::Value::Array{nodegraph::Value("retained reasoning")}}}; + addRetention(reasoningState, "summary", 789, 18); + reasoning = + write.upsert({nodegraph::NodeKind::Item, "truncation-reasoning"}, + std::move(reasoningState)); + + nodegraph::NodeState planState = + graphMessageState("plan", "retained plan text"); + addRetention(planState, "text", 42, 18); + plan = write.upsert({nodegraph::NodeKind::Item, "truncation-plan"}, + std::move(planState)); + + for (const nodegraph::NodeRef &item : {agent, command, reasoning, plan}) + write.setParent(turn, item); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(720, 1000); + view.show(); + view.bindGraph(graph, thread); + spin(160); + + const auto attachedCard = [](const nodegraph::NodeRef &node) { + ui::QtNodeAttachment *attachment = graphAttachment(node); + return attachment + ? qobject_cast(attachment->widget.data()) + : nullptr; + }; + ConversationCard *agentCard = attachedCard(agent); + ConversationCard *commandCard = attachedCard(command); + ConversationCard *reasoningCard = attachedCard(reasoning); + ConversationCard *planCard = attachedCard(plan); + const auto *agentData = + agentCard ? std::get_if(&agentCard->data().payload) + : nullptr; + const auto *commandData = + commandCard + ? std::get_if(&commandCard->data().payload) + : nullptr; + const auto *reasoningData = + reasoningCard ? std::get_if(&reasoningCard->data().payload) + : nullptr; + const auto *planData = + planCard ? std::get_if(&planCard->data().payload) : nullptr; + return expect( + agentData && + agentData->text.starts_with( + "> Earlier Codex response was truncated (123 bytes omitted).") && + commandData && + commandData->output.starts_with( + "[Earlier command output was truncated (456 " + "bytes omitted).]") && + reasoningData && + reasoningData->summary.starts_with( + "> Earlier reasoning was truncated (789 bytes " + "omitted).") && + planData && + planData->legacyText.starts_with( + "> Earlier plan text was truncated (42 bytes " + "omitted)."), + "visible graph cards disclose every bounded protocol text tail"); +} + +bool testGraphBoundedHistoryAndExplicitRoot() { + constexpr std::size_t ItemCount = 5000; + constexpr std::size_t RevealedPages = 2; + constexpr std::size_t FirstRevealedIndex = + ItemCount - (RevealedPages + 1) * AuthoritativeHistoryPageSize; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef root; + nodegraph::NodeRef firstRevealed; + nodegraph::NodeRef distant; + nodegraph::NodeRef steering; + nodegraph::NodeRef tail; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", ItemCount); + threadState.fields.emplace("historyHasMore", true); + threadState.fields.emplace("historyNextCursor", "older-page"); + thread = write.upsert({nodegraph::NodeKind::Thread, "bounded-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "bounded-turn"}); + write.setParent(thread, turn); + for (std::size_t index = 0; index < ItemCount; ++index) { + const bool isRoot = index == 0; + const bool isSteering = index == ItemCount - 2; + nodegraph::NodeState state = graphMessageState( + isRoot || isSteering ? "userMessage" : "agentMessage", + isRoot ? "Original prompt" + : (isSteering ? "Later steering prompt" + : "History " + std::to_string(index))); + if (!isRoot && !isSteering) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "bounded-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + if (isRoot) + root = item; + if (index == FirstRevealedIndex) + firstRevealed = item; + if (index == 3100) + distant = item; + if (isSteering) + steering = item; + if (index + 1 == ItemCount) + tail = item; + } + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + // History suffix replacement may unparent the original prompt while its + // explicit protocol relation remains authoritative. + write.clearParent(root); + static_cast(write.finish()); + } + + ConversationView view; + int providerPageRequests = 0; + view.setLoadMoreAction([&providerPageRequests] { ++providerPageRequests; }); + view.resize(620, 420); + view.show(); + PaintAnchorProbe initialPaints(view); + initialPaints.start(); + view.bindGraph(graph, thread); + const bool selectionMaterializationFrozen = + view.property("bulkMaterializationUpdatesSuppressed").toBool() && + !view.viewport()->updatesEnabled(); + const bool initialLoadedWindowReady = spinUntil([&] { + const LiveConversationWidgetCounts widgets = + liveConversationWidgetCounts(view); + return view.property("graphLiveRecordCount").toULongLong() == + AuthoritativeHistoryPageSize + 1 && + widgets.cards == + static_cast(AuthoritativeHistoryPageSize + 1) && + widgets.itemPlaceholders == 0 && + graphAttachment(root) && graphAttachment(steering) && + graphAttachment(tail) && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 2048); + initialPaints.active = false; + const bool initializationPaintedOnlyCompleteWindow = + std::ranges::all_of(initialPaints.representationCounts, [](int count) { + return count == + static_cast(AuthoritativeHistoryPageSize + 1); + }); + + const auto representationCount = [&view] { + return static_cast(std::ranges::count_if( + view.findChildren(), [](QWidget *widget) { + return dynamic_cast(widget) || + widget->objectName() == + QStringLiteral("conversationCardPlaceholder"); + })); + }; + const auto hiddenItemCount = [&view] { + qulonglong count = 0; + for (QWidget *widget : view.findChildren()) + if (widget->objectName() == + QStringLiteral("conversationHistoryPlaceholder")) + count += widget->property("hiddenItemCount").toULongLong(); + return count; + }; + QPushButton *loadMore = historyButton(view); + const QVariant retainedGeometry = + view.property("graphRetainedGeometryRecordCount"); + bool result = expect( + initialLoadedWindowReady && selectionMaterializationFrozen && + initializationPaintedOnlyCompleteWindow && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool() && + representationCount() == + static_cast(AuthoritativeHistoryPageSize + 1) && + retainedGeometry.isValid() && loadMore && + loadMore->isVisible() && + loadMore->text() == QStringLiteral("Load 80 more activities") && + hiddenItemCount() == ItemCount - AuthoritativeHistoryPageSize - 1, + "five thousand graph items retain compact graph geometry while thread " + "selection materializes the loaded 80-card window plus its root once"); + + int heartbeatCount = 0; + qint64 longestHeartbeatGap = 0; + QElapsedTimer heartbeatGap; + heartbeatGap.start(); + QTimer heartbeat; + heartbeat.setInterval(1); + QObject::connect(&heartbeat, &QTimer::timeout, &view, [&] { + longestHeartbeatGap = std::max(longestHeartbeatGap, heartbeatGap.restart()); + ++heartbeatCount; + }); + heartbeat.start(); + spin(80); + heartbeat.stop(); + result &= expect(heartbeatCount >= 5 && longestHeartbeatGap < 50, + "visibility continuations for five thousand graph items " + "preserve the Qt heartbeat"); + ui::QtNodeAttachment *steeringAttachment = graphAttachment(steering); + auto *steeringCard = + steeringAttachment + ? qobject_cast(steeringAttachment->widget.data()) + : nullptr; + result &= + expect(steeringCard && !steeringCard->property("turnContainer").toBool() && + qobject_cast( + graphAttachment(root)->widget.data()) + ->isAncestorOf(steeringCard), + "a loaded steering message remains a child of the canonical " + "Turn/You card and is never inferred to be the root"); + + const qulonglong hiddenBeforePaging = hiddenItemCount(); + bool everyPageMaterializedAtomically = true; + for (std::size_t page = 0; page < RevealedPages; ++page) { + PaintAnchorProbe pagePaints(view); + pagePaints.start(); + loadMore->click(); + everyPageMaterializedAtomically = + everyPageMaterializedAtomically && + view.property("bulkMaterializationUpdatesSuppressed").toBool() && + !view.viewport()->updatesEnabled(); + spinUntil([&] { + const std::size_t expected = + (page + 2) * AuthoritativeHistoryPageSize + 1; + const LiveConversationWidgetCounts widgets = + liveConversationWidgetCounts(view); + return view.property("graphLiveRecordCount").toULongLong() == expected && + widgets.cards == static_cast(expected) && + widgets.itemPlaceholders == 0 && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 2048); + pagePaints.active = false; + const int expectedRepresentations = static_cast( + (page + 2) * AuthoritativeHistoryPageSize + 1); + const bool pagePaintedOnlyCompleteWindow = + std::ranges::all_of(pagePaints.representationCounts, + [expectedRepresentations](int count) { + return count == expectedRepresentations; + }); + everyPageMaterializedAtomically = + everyPageMaterializedAtomically && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool() && + pagePaintedOnlyCompleteWindow; + if (!pagePaintedOnlyCompleteWindow) { + std::cerr << "load-more page " << page << " expected " + << expectedRepresentations << " representations; paints:"; + for (const int count : pagePaints.representationCounts) + std::cerr << ' ' << count; + std::cerr << '\n'; + } + } + const QVariant retainedAfterPaging = + view.property("graphRetainedGeometryRecordCount"); + result &= expect( + providerPageRequests == 0 && everyPageMaterializedAtomically && + hiddenItemCount() + RevealedPages * AuthoritativeHistoryPageSize == + hiddenBeforePaging && + retainedAfterPaging.isValid() && + retainedAfterPaging.toULongLong() >= + RevealedPages * AuthoritativeHistoryPageSize && + view.property("graphLiveRecordCount").toULongLong() == + (RevealedPages + 1) * AuthoritativeHistoryPageSize + 1 && + graphPassBudgetsWereRespected(view), + "each Load 80 more action materializes that admitted batch exactly once " + "while every construction pass remains budgeted"); + + ui::QtNodeAttachment *tailAttachment = graphAttachment(tail); + QPointer tailIdentity = + tailAttachment ? tailAttachment->widget : nullptr; + const LiveConversationWidgetCounts beforeVisibleDelta = + liveConversationWidgetCounts(view); + const qulonglong fullGeometryBeforeVisibleDelta = + view.property("conversationFullGeometryPasses").toULongLong(); + nodegraph::GraphChange visibleDelta; + { + auto write = graph.write(); + write.setField(tail, "text", "Targeted visible revision after many pages"); + visibleDelta = write.finish(); + } + view.graphChangedDeferred(visibleDelta.affected, visibleDelta.removed); + spin(80); + tailAttachment = graphAttachment(tail); + auto *tailCard = + tailAttachment + ? qobject_cast(tailAttachment->widget.data()) + : nullptr; + const auto *tailMessage = + tailCard ? std::get_if(&tailCard->data().payload) + : nullptr; + result &= expect( + tailIdentity && tailAttachment && + tailAttachment->widget == tailIdentity && + tailAttachment->renderedRevision == visibleDelta.revision && + tailMessage && + tailMessage->text == "Targeted visible revision after many pages" && + liveConversationWidgetCounts(view).itemRepresentations() == + beforeVisibleDelta.itemRepresentations() && + view.property("conversationFullGeometryPasses").toULongLong() == + fullGeometryBeforeVisibleDelta && + view.property("conversationSectionsLaidOutLastPass").toULongLong() == + 1 && + graphRefreshWasConstantBounded(view) && + graphPassBudgetsWereRespected(view), + "a visible targeted delta after many revealed pages updates the stable " + "card inside only its TurnSection with constant bounded graph and " + "QWidget work"); + + const LiveConversationWidgetCounts beforeOffscreenDelta = + liveConversationWidgetCounts(view); + result &= expect(graphAttachment(distant) == nullptr, + "an item outside the loaded history window has no QWidget " + "attachment"); + nodegraph::GraphChange offscreenDelta; + { + auto write = graph.write(); + write.setField(distant, "text", "Off-screen revision stays in NodeGraph"); + offscreenDelta = write.finish(); + } + view.graphChangedDeferred(offscreenDelta.affected, offscreenDelta.removed); + spin(80); + const LiveConversationWidgetCounts afterOffscreenDelta = + liveConversationWidgetCounts(view); + result &= expect( + graphAttachment(distant) == nullptr && + afterOffscreenDelta.cards == beforeOffscreenDelta.cards && + afterOffscreenDelta.itemPlaceholders == + beforeOffscreenDelta.itemPlaceholders && + afterOffscreenDelta.turnSections == + beforeOffscreenDelta.turnSections && + graphRefreshWasConstantBounded(view) && + graphPassBudgetsWereRespected(view), + "a delta outside the loaded history window creates no QWidget and keeps " + "all work bounded"); + + ui::QtNodeAttachment *firstAttachment = graphAttachment(firstRevealed); + auto *firstCard = + firstAttachment + ? qobject_cast(firstAttachment->widget.data()) + : nullptr; + ui::QtNodeAttachment *rootAttachment = graphAttachment(root); + auto *rootCard = + rootAttachment + ? qobject_cast(rootAttachment->widget.data()) + : nullptr; + result &= expect( + rootCard && firstCard && + rootCard->property("turnContainer").toBool() && + rootCard->isAncestorOf(firstCard) && + firstCard->property("nestedConversationCard").toBool(), + "every loaded child is materialized once under its actual canonical " + "Turn/You card even when both are initially offscreen"); + + view.verticalScrollBar()->setValue( + view.verticalScrollBar()->value() + + firstCard->mapTo(view.viewport(), QPoint{}).y() - 8); + spin(80); + const int firstScrollValue = view.verticalScrollBar()->value(); + + std::string tallText; + for (int line = 0; line < 18; ++line) + tallText += "Measured retained line " + std::to_string(line) + "\n"; + nodegraph::GraphChange tallChange; + { + auto write = graph.write(); + write.setField(firstRevealed, "text", tallText); + tallChange = write.finish(); + } + view.graphChangedDeferred(tallChange.affected, tallChange.removed); + spin(100); + firstAttachment = graphAttachment(firstRevealed); + firstCard = + firstAttachment + ? qobject_cast(firstAttachment->widget.data()) + : nullptr; + QPointer retainedFirst = firstCard; + const int retainedHeight = firstCard ? firstCard->height() : 0; + const int retainedViewportTop = + firstCard ? firstCard->mapTo(view.viewport(), QPoint{}).y() : 0; + const int nestedViewportX = + firstCard ? firstCard->mapTo(view.viewport(), QPoint{}).x() : -1; + const int maximumWithMeasuredHeight = view.verticalScrollBar()->maximum(); + result &= expect( + firstCard && retainedHeight > 66 && + firstAttachment->renderedRevision == tallChange.revision, + "a visible tall record publishes and retains its measured final height"); + + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); + spin(160); + rootAttachment = graphAttachment(root); + rootCard = + rootAttachment + ? qobject_cast(rootAttachment->widget.data()) + : nullptr; + result &= expect( + rootCard && graphAttachment(firstRevealed) == firstAttachment && + retainedFirst == firstCard && rootCard->isAncestorOf(firstCard) && + std::abs(view.verticalScrollBar()->maximum() - + maximumWithMeasuredHeight) <= 2, + "scrolling retains the loaded child, its canonical owner, and its " + "measured scroll extent without reconstruction"); + steeringAttachment = graphAttachment(steering); + steeringCard = + steeringAttachment + ? qobject_cast(steeringAttachment->widget.data()) + : nullptr; + result &= expect( + rootCard && steeringCard && rootCard->isAncestorOf(steeringCard) && + steeringCard->property("nestedConversationCard").toBool() && + steeringCard->mapTo(view.viewport(), QPoint{}).x() == + nestedViewportX && retainedFirst == firstCard, + "a retained child remains owned by its canonical Turn/You card and " + "identically indented after scrolling"); + + view.verticalScrollBar()->setValue(firstScrollValue); + spin(160); + firstAttachment = graphAttachment(firstRevealed); + firstCard = + firstAttachment + ? qobject_cast(firstAttachment->widget.data()) + : nullptr; + rootAttachment = graphAttachment(root); + rootCard = + rootAttachment + ? qobject_cast(rootAttachment->widget.data()) + : nullptr; + result &= expect( + rootCard && firstCard && retainedFirst == firstCard && + rootCard->isAncestorOf(firstCard) && + firstCard->property("nestedConversationCard").toBool() && + firstCard->height() == retainedHeight && + std::abs(firstCard->mapTo(view.viewport(), QPoint{}).y() - + retainedViewportTop) <= 2 && + liveConversationWidgetCounts(view).cards == + static_cast((RevealedPages + 1) * + AuthoritativeHistoryPageSize + + 1) && + graphPassBudgetsWereRespected(view), + "returning to a retained child preserves identity, canonical ownership, " + "nested presentation, measured height, and exact scroll anchor"); + return result; +} + +bool testLoadedCardsMaterializeOnceWithoutScrollChurn() { + constexpr std::size_t LoadedCount = 80; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + std::vector items; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", LoadedCount); + thread = write.upsert({nodegraph::NodeKind::Thread, "once-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "once-turn"}); + write.setParent(thread, turn); + items.reserve(LoadedCount); + for (std::size_t index = 0; index < LoadedCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Retained card " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "once-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + items.push_back(item); + } + write.relate(turn, nodegraph::RelationKind::TurnRootItem, items.front()); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + view.bindGraph(graph, thread); + const bool allMaterialized = spinUntil( + [&items, &view] { + return std::ranges::all_of( + items, [](const nodegraph::NodeRef &item) { + ui::QtNodeAttachment *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, + 1024); + + std::vector> identities; + identities.reserve(items.size()); + for (const nodegraph::NodeRef &item : items) { + ui::QtNodeAttachment *attachment = graphAttachment(item); + identities.push_back(attachment ? attachment->widget : nullptr); + } + const qulonglong geometryBefore = + view.property("conversationGeometryPasses").toULongLong(); + const int maximumBefore = view.verticalScrollBar()->maximum(); + for (int pass = 0; pass < 4; ++pass) { + view.verticalScrollBar()->setValue(0); + spin(10); + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum()); + spin(10); + } + + bool sameCards = allMaterialized; + for (std::size_t index = 0; index < items.size(); ++index) { + ui::QtNodeAttachment *attachment = graphAttachment(items[index]); + sameCards = sameCards && attachment && attachment->widget == identities[index]; + } + ConversationCard *rootCard = + qobject_cast(identities.front().data()); + bool owned = rootCard && rootCard->property("turnContainer").toBool(); + for (std::size_t index = 1; owned && index < identities.size(); ++index) + owned = identities[index] && rootCard->isAncestorOf(identities[index]); + if (!(allMaterialized && sameCards && owned && + view.verticalScrollBar()->maximum() == maximumBefore && + view.property("conversationGeometryPasses").toULongLong() == + geometryBefore)) + std::cerr << "loaded-window churn: materialized=" << allMaterialized + << " same=" << sameCards << " owned=" << owned + << " maximum=" << maximumBefore << "->" + << view.verticalScrollBar()->maximum() << " geometry=" + << geometryBefore << "->" + << view.property("conversationGeometryPasses").toULongLong() + << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << " blocker=" + << view.property("bulkMaterializationBlocker") + .toString() + .toStdString() + << '\n'; + return expect( + allMaterialized && sameCards && owned && + view.verticalScrollBar()->maximum() == maximumBefore && + view.property("conversationGeometryPasses").toULongLong() == + geometryBefore, + "the selected 80-card loaded window materializes once, retains exact " + "Turn/You ownership, and performs no reconstruction or relayout while " + "scrolling"); +} + +bool testDelayedInitialHistoryMaterializesAtomically() { + constexpr std::size_t LoadedCount = AuthoritativeHistoryPageSize; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{0}); + threadState.fields.emplace("hydrationState", "loading"); + thread = write.upsert( + {nodegraph::NodeKind::Thread, "delayed-initial-history"}, + std::move(threadState)); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + spin(20); + PaintAnchorProbe paints(view); + paints.start(); + view.bindGraph(graph, thread); + const bool emptyBindingHeld = spinUntil([&] { + return !view.viewport()->updatesEnabled() && + view.property("bulkMaterializationUpdatesSuppressed").toBool() && + view.property("bulkMaterializationBlocker").toString() == + QStringLiteral("hydration"); + }); + auto *loadingCover = view.findChild( + QStringLiteral("conversationAtomicTransitionOverlay")); + const bool loadingCoverVisible = + loadingCover && loadingCover->isVisible() && + loadingCover->pixmap().isNull() && + loadingCover->text() == QStringLiteral("Loading conversation…"); + + std::vector items; + nodegraph::GraphChange hydrated; + { + auto write = graph.write(); + const nodegraph::NodeRef turn = + write.upsert({nodegraph::NodeKind::Turn, "delayed-initial-turn"}); + write.setParent(thread, turn); + items.reserve(LoadedCount); + for (std::size_t index = 0; index < LoadedCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Hydrated retained card " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "delayed-initial-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + items.push_back(item); + } + write.relate(turn, nodegraph::RelationKind::TurnRootItem, items.front()); + write.setField(thread, "historyLoadedItemCount", LoadedCount); + write.setField(thread, "hydrationState", "ready"); + hydrated = write.finish(); + } + view.graphChangedDeferred(hydrated.affected, hydrated.removed); + const bool hydratedWindowReady = spinUntil( + [&] { + return std::ranges::all_of( + items, [](const nodegraph::NodeRef &item) { + const auto *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed") + .toBool() && + loadingCover && !loadingCover->isVisible(); + }, + 1024); + paints.active = false; + const bool noPartialHistoryFrame = + std::ranges::all_of(paints.representationCounts, [](int count) { + return count == 0 || count == static_cast(LoadedCount); + }) && + std::ranges::find(paints.representationCounts, + static_cast(LoadedCount)) != + paints.representationCounts.end(); + + auto *rootCard = items.empty() || !graphAttachment(items.front()) + ? nullptr + : qobject_cast( + graphAttachment(items.front())->widget.data()); + QWidget *nested = rootCard + ? rootCard->findChild( + QStringLiteral("conversationNestedCards"), + Qt::FindDirectChildrenOnly) + : nullptr; + const int rootHeight = rootCard ? rootCard->height() : -1; + const int nestedHeight = nested ? nested->height() : -1; + const int scrollMaximum = view.verticalScrollBar()->maximum(); + const qulonglong geometryPasses = + view.property("conversationGeometryPasses").toULongLong(); + spin(80); + const bool finalLayoutStable = + rootCard && nested && rootCard->property("turnContainer").toBool() && + rootCard->height() == rootHeight && nested->height() == nestedHeight && + view.verticalScrollBar()->maximum() == scrollMaximum && + view.property("conversationGeometryPasses").toULongLong() == + geometryPasses; + + return expect( + emptyBindingHeld && loadingCoverVisible && hydratedWindowReady && + noPartialHistoryFrame && finalLayoutStable, + "history arriving after an empty selection remains invisible until all " + "retained cards have their stable final old-UI layout"); +} + +bool testPartialLiveTailWaitsForAuthoritativeInitialHistory() { + constexpr std::size_t LoadedCount = AuthoritativeHistoryPageSize; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + std::vector items; + { + auto write = graph.write(); + thread = write.upsert( + {nodegraph::NodeKind::Thread, "partial-live-tail-thread"}); + turn = + write.upsert({nodegraph::NodeKind::Turn, "partial-live-tail-turn"}); + write.setParent(thread, turn); + items.reserve(LoadedCount); + for (std::size_t index = 0; index < 2; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Partial live tail " + std::to_string(index)); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "partial-live-tail-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + items.push_back(item); + } + write.relate(turn, nodegraph::RelationKind::TurnRootItem, items.front()); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + spin(20); + PaintAnchorProbe paints(view); + paints.start(); + view.bindGraph(graph, thread); + const bool partialTailHeld = spinUntil([&] { + return !view.viewport()->updatesEnabled() && + view.property("bulkMaterializationUpdatesSuppressed").toBool() && + view.property("bulkMaterializationBlocker").toString() == + QStringLiteral("hydration"); + }); + + nodegraph::GraphChange loading; + { + auto write = graph.write(); + write.setField(thread, "hydrationState", "loading"); + loading = write.finish(); + } + view.graphChangedDeferred(loading.affected, loading.removed); + spin(20); + + nodegraph::GraphChange hydrated; + { + auto write = graph.write(); + for (std::size_t index = items.size(); index < LoadedCount; ++index) { + nodegraph::NodeState state = graphMessageState( + "agentMessage", "Hydrated retained card " + std::to_string(index)); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "partial-live-tail-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + items.push_back(item); + } + write.setField(thread, "historyLoadedItemCount", LoadedCount); + write.setField(thread, "hydrationState", "ready"); + hydrated = write.finish(); + } + view.graphChangedDeferred(hydrated.affected, hydrated.removed); + const bool hydratedWindowReady = spinUntil( + [&] { + return std::ranges::all_of( + items, [](const nodegraph::NodeRef &item) { + const auto *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed") + .toBool(); + }, + 1024); + paints.active = false; + + const bool noPartialTailFrame = + std::ranges::all_of(paints.representationCounts, [](int count) { + return count == 0 || count == static_cast(LoadedCount); + }) && + std::ranges::find(paints.representationCounts, + static_cast(LoadedCount)) != + paints.representationCounts.end(); + auto *rootCard = graphAttachment(items.front()) + ? qobject_cast( + graphAttachment(items.front())->widget.data()) + : nullptr; + bool allOwned = rootCard && rootCard->property("turnContainer").toBool(); + for (std::size_t index = 1; allOwned && index < items.size(); ++index) { + const auto *attachment = graphAttachment(items[index]); + allOwned = attachment && attachment->widget && + rootCard->isAncestorOf(attachment->widget); + } + + return expect( + partialTailHeld && hydratedWindowReady && noPartialTailFrame && allOwned, + "a thread-list live tail never paints before authoritative history and " + "the first exposed frame has the complete canonically owned window"); +} + +bool testThreadSwitchCoversOldFrameUntilAtomicCommit() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef sourceThread; + nodegraph::NodeRef sourceItem; + nodegraph::NodeRef targetThread; + { + auto write = graph.write(); + nodegraph::NodeState ready; + ready.fields.emplace("historyLoadedItemCount", std::uint64_t{1}); + ready.fields.emplace("hydrationState", "ready"); + sourceThread = write.upsert( + {nodegraph::NodeKind::Thread, "atomic-frame-source"}, + std::move(ready)); + const auto sourceTurn = + write.upsert({nodegraph::NodeKind::Turn, "atomic-frame-source-turn"}); + sourceItem = write.upsert( + {nodegraph::NodeKind::Item, "atomic-frame-source-item"}, + graphMessageState("userMessage", "Previous painted conversation")); + write.setParent(sourceThread, sourceTurn); + write.setParent(sourceTurn, sourceItem); + write.relate(sourceTurn, nodegraph::RelationKind::TurnRootItem, + sourceItem); + + nodegraph::NodeState loading; + loading.fields.emplace("historyLoadedItemCount", std::uint64_t{0}); + loading.fields.emplace("hydrationState", "loading"); + targetThread = write.upsert( + {nodegraph::NodeKind::Thread, "atomic-frame-target"}, + std::move(loading)); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + view.bindGraph(graph, sourceThread); + const bool sourceReady = spinUntil([&] { + const auto *attachment = graphAttachment(sourceItem); + return attachment && attachment->widget && view.viewport()->updatesEnabled(); + }); + view.bindGraph(graph, targetThread); + const bool targetHeld = spinUntil([&] { + return !view.viewport()->updatesEnabled() && + view.property("bulkMaterializationBlocker").toString() == + QStringLiteral("hydration"); + }); + auto *overlay = view.findChild( + QStringLiteral("conversationAtomicTransitionOverlay")); + const bool oldFrameCovered = + overlay && overlay->isVisible() && overlay->pixmap().isNull() && + overlay->text() == QStringLiteral("Loading conversation…") && + graphAttachment(sourceItem) == nullptr; + + nodegraph::NodeRef targetItem; + nodegraph::GraphChange hydrated; + { + auto write = graph.write(); + const auto targetTurn = + write.upsert({nodegraph::NodeKind::Turn, "atomic-frame-target-turn"}); + targetItem = write.upsert( + {nodegraph::NodeKind::Item, "atomic-frame-target-item"}, + graphMessageState("userMessage", "Complete incoming conversation")); + write.setParent(targetThread, targetTurn); + write.setParent(targetTurn, targetItem); + write.relate(targetTurn, nodegraph::RelationKind::TurnRootItem, + targetItem); + write.setField(targetThread, "historyLoadedItemCount", std::uint64_t{1}); + write.setField(targetThread, "hydrationState", "ready"); + hydrated = write.finish(); + } + view.graphChangedDeferred(hydrated.affected, hydrated.removed); + const bool targetReady = spinUntil([&] { + const auto *attachment = graphAttachment(targetItem); + return attachment && attachment->widget && view.viewport()->updatesEnabled() && + overlay && !overlay->isVisible(); + }); + + return expect(sourceReady && targetHeld && oldFrameCovered && targetReady, + "thread switching covers the outgoing conversation until the " + "incoming history can be exposed in one atomic commit"); +} + +bool testPausedIncomingCardMaterializesWithoutAnchorJump() { + constexpr std::size_t InitialCount = 40; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef root; + std::vector initial; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", InitialCount); + thread = write.upsert({nodegraph::NodeKind::Thread, "paused-append-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "paused-append-turn"}); + write.setParent(thread, turn); + for (std::size_t index = 0; index < InitialCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Initial retained card " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "paused-append-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + initial.push_back(item); + } + root = initial.front(); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + view.bindGraph(graph, thread); + const bool initialReady = spinUntil([&] { + return std::ranges::all_of(initial, [](const nodegraph::NodeRef &item) { + ui::QtNodeAttachment *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 512); + view.verticalScrollBar()->setValue(0); + spin(40); + const auto anchorBefore = firstVisible(view); + const int scrollBefore = view.verticalScrollBar()->value(); + std::vector> identities; + identities.reserve(initial.size()); + for (const nodegraph::NodeRef &item : initial) { + ui::QtNodeAttachment *attachment = graphAttachment(item); + identities.push_back(attachment ? attachment->widget : nullptr); + } + + nodegraph::NodeRef incoming; + nodegraph::GraphChange change; + const qulonglong atomicAttemptsBefore = + view.property("bulkMaterializationCommitAttempts").toULongLong(); + { + auto write = graph.write(); + nodegraph::NodeState state = + graphMessageState("agentMessage", "Incoming while reading above"); + state.fields.emplace("phase", "update"); + incoming = write.upsert( + {nodegraph::NodeKind::Item, "paused-append-incoming"}, + std::move(state)); + write.setParent(turn, incoming); + write.setField(thread, "historyLoadedItemCount", InitialCount + 1); + change = write.finish(); + } + view.graphChangedDeferred(change.affected, change.removed); + const bool incomingReady = spinUntil([&] { + ui::QtNodeAttachment *attachment = graphAttachment(incoming); + return attachment && attachment->widget && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 32); + const auto anchorAfter = firstVisible(view); + ui::QtNodeAttachment *rootAttachment = graphAttachment(root); + ui::QtNodeAttachment *incomingAttachment = graphAttachment(incoming); + auto *rootCard = rootAttachment + ? qobject_cast( + rootAttachment->widget.data()) + : nullptr; + QWidget *incomingCard = + incomingAttachment ? incomingAttachment->widget.data() : nullptr; + bool oldIdentitiesRetained = true; + for (std::size_t index = 0; index < initial.size(); ++index) + oldIdentitiesRetained = + oldIdentitiesRetained && graphAttachment(initial[index]) && + graphAttachment(initial[index])->widget == identities[index]; + + return expect( + initialReady && !anchorBefore.first.empty() && incomingReady && + view.property("bulkMaterializationCommitAttempts").toULongLong() > + atomicAttemptsBefore && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool() && + view.mode() == ConversationView::Mode::Paused && + view.verticalScrollBar()->value() == scrollBefore && + anchorAfter.first == anchorBefore.first && + std::abs(anchorAfter.second - anchorBefore.second) <= 1 && + oldIdentitiesRetained && rootCard && incomingCard && + rootCard->isAncestorOf(incomingCard) && + incomingCard->mapTo(view.viewport(), QPoint{}).y() >= + view.viewport()->height() && + graphPassBudgetsWereRespected(view), + "a selected thread materializes one new offscreen card promptly while " + "a paused viewport retains its exact anchor and existing card identity"); +} + +bool testPausedMixedCardBurstKeepsLeafAnchorAndParents() { + constexpr std::size_t InitialCount = 40; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef root; + std::vector initial; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", InitialCount); + thread = write.upsert( + {nodegraph::NodeKind::Thread, "mixed-card-anchor-thread"}, + std::move(threadState)); + turn = write.upsert( + {nodegraph::NodeKind::Turn, "mixed-card-anchor-turn"}); + write.setParent(thread, turn); + initial.reserve(InitialCount); + for (std::size_t index = 0; index < InitialCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Retained mixed-card history " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "mixed-card-anchor-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(turn, item); + initial.push_back(item); + } + root = initial.front(); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + view.bindGraph(graph, thread); + const bool initialReady = spinUntil([&] { + return std::ranges::all_of(initial, [](const nodegraph::NodeRef &item) { + const auto *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && view.viewport()->updatesEnabled(); + }, 512); + QWidget *anchorWidget = + initialReady ? graphAttachment(initial[12])->widget.data() : nullptr; + if (anchorWidget) + view.verticalScrollBar()->setValue(std::clamp( + anchorWidget->mapTo(view.viewport(), QPoint{}).y() + + view.verticalScrollBar()->value() - 24, + view.verticalScrollBar()->minimum(), + view.verticalScrollBar()->maximum())); + spin(24); + const int anchorTopBefore = + anchorWidget ? anchorWidget->mapTo(view.viewport(), QPoint{}).y() : 0; + const int scrollBefore = view.verticalScrollBar()->value(); + QPointer rootIdentity = + graphAttachment(root) ? graphAttachment(root)->widget : nullptr; + PaintAnchorProbe paintProbe(view); + paintProbe.start(anchorWidget); + + std::vector incoming; + nodegraph::NodeRef review; + nodegraph::GraphChange burst; + { + auto write = graph.write(); + std::vector states; + states.push_back(graphMessageState("userMessage", "Steering user card")); + nodegraph::NodeState agent = + graphMessageState("agentMessage", "Agent response card"); + agent.fields.emplace("phase", "commentary"); + states.push_back(std::move(agent)); + nodegraph::NodeState command; + command.status = nodegraph::NodeStatus::Running; + command.fields = {{"type", "commandExecution"}, + {"command", "printf mixed-card"}, + {"aggregatedOutput", "one line"}, + {"status", "inProgress"}}; + states.push_back(std::move(command)); + nodegraph::NodeState activity; + activity.status = nodegraph::NodeStatus::Running; + activity.fields = {{"type", "collabAgentToolCall"}, + {"tool", "spawn_agent"}, + {"status", "inProgress"}, + {"prompt", "mixed-card agent activity"}}; + states.push_back(std::move(activity)); + nodegraph::NodeState reasoning; + reasoning.status = nodegraph::NodeStatus::Running; + reasoning.fields = {{"type", "reasoning"}, + {"summary", "mixed-card reasoning"}}; + states.push_back(std::move(reasoning)); + nodegraph::NodeState fileChange; + fileChange.status = nodegraph::NodeStatus::Running; + fileChange.fields = { + {"type", "fileChange"}, + {"status", "inProgress"}, + {"changes", + nodegraph::Value::Array{nodegraph::Value(nodegraph::Value::Object{ + {"path", "src/mixed.cpp"}, {"kind", "update"}})}}}; + states.push_back(std::move(fileChange)); + nodegraph::NodeState image; + image.status = nodegraph::NodeStatus::Running; + image.fields = {{"type", "imageGeneration"}, + {"status", "inProgress"}, + {"revisedPrompt", "mixed-card image"}}; + states.push_back(std::move(image)); + nodegraph::NodeState plan; + plan.status = nodegraph::NodeStatus::Running; + plan.fields = {{"type", "plan"}, {"text", "mixed-card plan"}}; + states.push_back(std::move(plan)); + nodegraph::NodeState autoReview; + autoReview.status = nodegraph::NodeStatus::Running; + autoReview.fields = {{"type", "autoApprovalReview"}, + {"phase", "started"}, + {"detail", "mixed-card approval review"}}; + states.push_back(std::move(autoReview)); + nodegraph::NodeState generic; + generic.status = nodegraph::NodeStatus::Running; + generic.fields = {{"type", "contextCompaction"}, + {"detail", "mixed-card generic activity"}}; + states.push_back(std::move(generic)); + nodegraph::NodeState local; + local.status = nodegraph::NodeStatus::Running; + local.fields = {{"type", "localPrompt"}, + {"submissionId", std::uint64_t{4100}}, + {"text", "Optimistic steering card"}, + {"dispatchState", "inFlight"}, + {"showPendingAnimation", true}, + {"startsTurn", false}}; + states.push_back(std::move(local)); + + incoming.reserve(states.size()); + for (std::size_t index = 0; index < states.size(); ++index) { + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "mixed-card-incoming-" + std::to_string(index)}, + std::move(states[index])); + write.setParent(turn, item); + incoming.push_back(item); + } + review = incoming[8]; + write.setField(thread, "historyLoadedItemCount", + InitialCount + incoming.size()); + burst = write.finish(); + } + view.graphChangedDeferred(burst.affected, burst.removed); + const bool burstReady = spinUntil([&] { + return std::ranges::all_of(incoming, [](const nodegraph::NodeRef &item) { + const auto *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 256); + paintProbe.active = false; + + bool allNested = rootIdentity; + for (const nodegraph::NodeRef &item : incoming) + allNested = allNested && graphAttachment(item) && + rootIdentity->isAncestorOf(graphAttachment(item)->widget); + auto *runningCommandCard = + graphAttachment(incoming[2]) + ? qobject_cast( + graphAttachment(incoming[2])->widget.data()) + : nullptr; + const bool everyDelayedWorkCardEmphasized = std::ranges::all_of( + incoming.begin() + 2, incoming.begin() + 10, + [](const nodegraph::NodeRef &item) { + const auto *attachment = graphAttachment(item); + const auto *card = + attachment + ? qobject_cast(attachment->widget.data()) + : nullptr; + return card && card->property("activeWork").toBool(); + }); + const int anchorTopAfter = + anchorWidget ? anchorWidget->mapTo(view.viewport(), QPoint{}).y() : 0; + const bool paintedStable = std::ranges::all_of( + paintProbe.trackedGeometries, [anchorTopBefore](const QRect &geometry) { + return geometry.top() == anchorTopBefore; + }); + + // Completion of the same approval-review node is a state/geometry change, + // not another structural row. It must retain identity and the paused view. + QPointer reviewIdentity = + graphAttachment(review) ? graphAttachment(review)->widget : nullptr; + nodegraph::GraphChange completed; + { + auto write = graph.write(); + write.setField(review, "phase", "completed"); + write.setField(review, "detail", + std::string(1200, 'r') + " completed review"); + write.setStatus(review, nodegraph::NodeStatus::Completed); + completed = write.finish(); + } + view.graphChangedDeferred(completed.affected, completed.removed); + spin(64); + + const bool pausedViewportContract = + initialReady && anchorWidget && burstReady && allNested && + runningCommandCard && + runningCommandCard->property("activeWork").toBool() && + everyDelayedWorkCardEmphasized && + rootIdentity == graphAttachment(root)->widget && + reviewIdentity && graphAttachment(review) && + graphAttachment(review)->widget == reviewIdentity && + view.mode() == ConversationView::Mode::Paused && + view.verticalScrollBar()->value() == scrollBefore && + anchorTopAfter == anchorTopBefore && paintedStable && + anchorWidget->mapTo(view.viewport(), QPoint{}).y() == + anchorTopBefore && + graphPassBudgetsWereRespected(view); + if (reviewIdentity) + view.verticalScrollBar()->setValue(std::clamp( + view.verticalScrollBar()->value() + + reviewIdentity->mapTo(view.viewport(), QPoint{}).y() - 24, + view.verticalScrollBar()->minimum(), + view.verticalScrollBar()->maximum())); + const bool terminalReviewSettled = spinUntil([&] { + return reviewIdentity && + !reviewIdentity->property("activeWork").toBool(); + }); + + return expect( + pausedViewportContract && terminalReviewSettled, + "a coalesced burst covering every conversation card kind, including " + "approval review and optimistic steering, materializes atomically under " + "the canonical You parent without moving a scrolled-up leaf anchor, " + "and every delayed-work border follows canonical lifecycle state"); +} + +bool testPausedNormalPromptTurnMaterializesWithoutAnchorJump() { + constexpr std::size_t InitialCount = 40; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef oldTurn; + std::vector initial; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", InitialCount); + thread = write.upsert( + {nodegraph::NodeKind::Thread, "paused-new-prompt-thread"}, + std::move(threadState)); + oldTurn = + write.upsert({nodegraph::NodeKind::Turn, "paused-new-prompt-old-turn"}); + write.setParent(thread, oldTurn); + for (std::size_t index = 0; index < InitialCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + "Retained history " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, + "paused-new-prompt-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(oldTurn, item); + initial.push_back(item); + } + write.relate(oldTurn, nodegraph::RelationKind::TurnRootItem, + initial.front()); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 420); + view.show(); + view.bindGraph(graph, thread); + const bool initialReady = spinUntil([&] { + return std::ranges::all_of(initial, [](const nodegraph::NodeRef &item) { + const ui::QtNodeAttachment *attachment = graphAttachment(item); + return attachment && attachment->widget; + }) && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 512); + view.verticalScrollBar()->setValue( + std::min(600, view.verticalScrollBar()->maximum() / 2)); + spin(40); + const auto anchorBefore = firstVisible(view); + const int verticalBefore = view.verticalScrollBar()->value(); + const int horizontalBefore = view.horizontalScrollBar()->value(); + std::vector> identities; + identities.reserve(initial.size()); + for (const nodegraph::NodeRef &item : initial) + identities.push_back(graphAttachment(item)->widget); + PaintAnchorProbe paints(view); + paints.start(); + const qulonglong atomicAttemptsBefore = + view.property("bulkMaterializationCommitAttempts").toULongLong(); + + nodegraph::NodeRef prompt; + nodegraph::GraphChange appended; + { + auto write = graph.write(); + nodegraph::NodeState turnState; + turnState.status = nodegraph::NodeStatus::Pending; + turnState.fields.emplace("type", "localTurn"); + turnState.fields.emplace("local", true); + nodegraph::NodeRef turn = write.upsert( + {nodegraph::NodeKind::Turn, "paused-new-prompt-current-turn"}, + std::move(turnState)); + nodegraph::NodeState promptState; + promptState.status = nodegraph::NodeStatus::Pending; + promptState.fields.emplace("type", "localPrompt"); + promptState.fields.emplace("submissionId", std::uint64_t{9001}); + promptState.fields.emplace("text", "A normal new prompt"); + promptState.fields.emplace("dispatchState", "inFlight"); + promptState.fields.emplace("startsTurn", true); + prompt = write.upsert( + {nodegraph::NodeKind::Item, "paused-new-prompt-local"}, + std::move(promptState)); + write.setParent(thread, turn); + write.setParent(turn, prompt); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, prompt); + write.setField(thread, "historyLoadedItemCount", InitialCount + 1); + appended = write.finish(); + } + view.graphChangedDeferred(appended.affected, appended.removed); + const bool promptReady = spinUntil([&] { + const ui::QtNodeAttachment *attachment = graphAttachment(prompt); + return attachment && attachment->widget && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 64); + spin(40); + paints.active = false; + const auto anchorAfter = firstVisible(view); + auto *promptCard = + graphAttachment(prompt) + ? qobject_cast( + graphAttachment(prompt)->widget.data()) + : nullptr; + auto *promptStatus = + promptCard + ? promptCard->findChild( + QStringLiteral("pendingPromptStatus")) + : nullptr; + const bool paintedStable = std::ranges::all_of( + paints.anchors, [&anchorBefore](const auto &anchor) { + return anchor.first.empty() || + (anchor.first == anchorBefore.first && + std::abs(anchor.second - anchorBefore.second) <= 1); + }); + bool retained = true; + for (std::size_t index = 0; index < initial.size(); ++index) + retained = retained && graphAttachment(initial[index]) && + graphAttachment(initial[index])->widget == identities[index]; + + if (!(initialReady && !anchorBefore.first.empty() && promptReady && + retained && + view.mode() == ConversationView::Mode::Paused && + view.verticalScrollBar()->value() == verticalBefore && + view.horizontalScrollBar()->value() == horizontalBefore && + anchorAfter.first == anchorBefore.first && + std::abs(anchorAfter.second - anchorBefore.second) <= 1 && + paintedStable && graphPassBudgetsWereRespected(view))) + std::cerr << "normal prompt anchor: before=" << anchorBefore.first << ':' + << anchorBefore.second << " after=" << anchorAfter.first << ':' + << anchorAfter.second << " scroll=" << verticalBefore << "->" + << view.verticalScrollBar()->value() << " horizontal=" + << horizontalBefore << "->" + << view.horizontalScrollBar()->value() << " mode=" + << static_cast(view.mode()) << " ready=" << promptReady + << " retained=" << retained << " paints=" << paints.anchors.size() + << " stable=" << paintedStable << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << " updates=" << view.viewport()->updatesEnabled() + << " retainedGeometry=" + << view.property("graphRetainedGeometryRecordCount").toULongLong() + << " target=" + << view.property("graphStructureScanTarget").toULongLong() + << " live=" + << view.property("graphLiveRecordCount").toULongLong() + << " cards=" << liveConversationWidgetCounts(view).cards + << " placeholders=" + << liveConversationWidgetCounts(view).itemPlaceholders + << " attempts=" + << view.property("bulkMaterializationCommitAttempts").toULongLong() + << " blocker=" + << view.property("bulkMaterializationBlocker") + .toString() + .toStdString() + << '\n'; + + return expect( + initialReady && !anchorBefore.first.empty() && promptReady && retained && + promptCard && + promptCard->property("authoritativeTurnActive").toBool() && + promptStatus && promptStatus->text() == QStringLiteral("pending") && + view.property("bulkMaterializationCommitAttempts").toULongLong() > + atomicAttemptsBefore && + paints.anchors.size() <= 1 && view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool() && + view.mode() == ConversationView::Mode::Paused && + view.verticalScrollBar()->value() == verticalBefore && + view.horizontalScrollBar()->value() == horizontalBefore && + anchorAfter.first == anchorBefore.first && + std::abs(anchorAfter.second - anchorBefore.second) <= 1 && + paintedStable && graphPassBudgetsWereRespected(view), + "a normal prompt appends and immediately materializes its active, " + "emphasized Turn/You card without moving or repaint-jumping a paused " + "history viewport"); +} + +bool testGraphHistoryPagingAndPausedTailGrowth() { + constexpr std::size_t InitialItemCount = 100; + constexpr std::size_t PrependedItemCount = 5; + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef currentTurn; + nodegraph::NodeRef root; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", InitialItemCount); + threadState.fields.emplace("historyHasMore", true); + threadState.fields.emplace("historyNextCursor", "older-page"); + thread = write.upsert({nodegraph::NodeKind::Thread, "paging-thread"}, + std::move(threadState)); + currentTurn = + write.upsert({nodegraph::NodeKind::Turn, "paging-current-turn"}); + write.setParent(thread, currentTurn); + for (std::size_t index = 0; index < InitialItemCount; ++index) { + nodegraph::NodeState state = graphMessageState( + index == 0 ? "userMessage" : "agentMessage", + index == 0 ? "Opening prompt" + : "Current history " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "paging-current-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(currentTurn, item); + if (index == 0) + root = item; + } + write.relate(currentTurn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } + + ConversationView view; + int providerPageRequests = 0; + view.setLoadMoreAction([&providerPageRequests] { ++providerPageRequests; }); + view.resize(620, 420); + view.show(); + view.bindGraph(graph, thread); + spin(80); + + const auto hiddenItemCount = [&view] { + qulonglong count = 0; + for (QWidget *widget : view.findChildren()) + if (widget->objectName() == + QStringLiteral("conversationHistoryPlaceholder")) + count += widget->property("hiddenItemCount").toULongLong(); + return count; + }; + + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + spin(80); + const auto anchorBeforePrepend = firstVisible(view); + const qulonglong hiddenBeforePrepend = hiddenItemCount(); + bool result = expect(view.mode() == ConversationView::Mode::Paused && + !anchorBeforePrepend.first.empty(), + "the retained graph history can be paused at a stable " + "anchor"); + + nodegraph::GraphChange prepended; + { + auto write = graph.write(); + nodegraph::NodeRef olderTurn = + write.upsert({nodegraph::NodeKind::Turn, "paging-older-turn"}); + for (std::size_t index = 0; index < PrependedItemCount; ++index) { + nodegraph::NodeState state = + graphMessageState(index == 0 ? "userMessage" : "agentMessage", + "Older history " + std::to_string(index)); + if (index != 0) + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = + write.upsert({nodegraph::NodeKind::Item, + "paging-older-item-" + std::to_string(index)}, + std::move(state)); + write.setParent(olderTurn, item); + } + std::vector turns{olderTurn, currentTurn}; + write.replaceChildren(thread, turns); + write.setField(thread, "historyLoadedItemCount", + InitialItemCount + PrependedItemCount); + prepended = write.finish(); + } + view.graphChanged(prepended.removed); + spin(100); + const auto anchorAfterPrepend = firstVisible(view); + const qulonglong hiddenAfterPrepend = hiddenItemCount(); + result &= expect( + hiddenAfterPrepend == hiddenBeforePrepend + PrependedItemCount && + anchorAfterPrepend.first == anchorBeforePrepend.first && + std::abs(anchorAfterPrepend.second - anchorBeforePrepend.second) <= 1, + "an older provider page stays above the paused requested window without " + "double-expanding it"); + + nodegraph::GraphChange appended; + { + auto write = graph.write(); + nodegraph::NodeState state = + graphMessageState("agentMessage", "New activity at the tail"); + state.fields.emplace("phase", "final_answer"); + nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "paging-newest-item"}, std::move(state)); + write.setParent(currentTurn, item); + write.setField(thread, "historyLoadedItemCount", + InitialItemCount + PrependedItemCount + 1); + appended = write.finish(); + } + view.graphChanged(appended.removed); + spin(100); + const auto anchorAfterAppend = firstVisible(view); + result &= expect( + hiddenItemCount() == hiddenAfterPrepend && + anchorAfterAppend.first == anchorBeforePrepend.first && + std::abs(anchorAfterAppend.second - anchorBeforePrepend.second) <= 1, + "a true paused tail append temporarily expands the effective window and " + "preserves its painted anchor"); + + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMaximum); + const bool requestedWindowRestored = spinUntil([&] { + return view.mode() == ConversationView::Mode::Following && + hiddenItemCount() == hiddenAfterPrepend + 1; + }); + if (!requestedWindowRestored) + std::cerr << "history resume: mode=" << static_cast(view.mode()) + << " hidden=" << hiddenItemCount() << " expected=" + << hiddenAfterPrepend + 1 << " live=" + << view.property("graphLiveRecordCount").toULongLong() + << " target=" + << view.property("graphStructureScanTarget").toULongLong() + << " retained=" + << view.property("graphRetainedGeometryRecordCount") + .toULongLong() + << " scroll=" << view.verticalScrollBar()->value() << '/' + << view.verticalScrollBar()->maximum() + << " frozen=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << '\n'; + result &= expect( + requestedWindowRestored && + view.mode() == ConversationView::Mode::Following && + hiddenItemCount() == hiddenAfterPrepend + 1, + "resuming following restores the requested history bound after its " + "temporary paused expansion"); + + QPushButton *loadMore = historyButton(view); + if (loadMore) + loadMore->click(); + result &= expect( + loadMore && providerPageRequests == 1, + "the final retained page requests the provider continuation exactly " + "once"); + return result; +} + +bool testGraphRootReplacementAndAttachmentRecovery() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef root; + nodegraph::NodeRef sibling; + nodegraph::NodeRef steering; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{3}); + thread = write.upsert({nodegraph::NodeKind::Thread, "replacement-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "replacement-turn"}); + write.setParent(thread, turn); + root = write.upsert({nodegraph::NodeKind::Item, "replacement-root"}, + graphMessageState("userMessage", "Prompt")); + sibling = write.upsert({nodegraph::NodeKind::Item, "replacement-sibling"}, + graphMessageState("agentMessage", "Answer")); + steering = write.upsert({nodegraph::NodeKind::Item, "replacement-steering"}, + graphMessageState("userMessage", "Steer")); + write.setParent(turn, root); + write.setParent(turn, sibling); + write.setParent(turn, steering); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } + + ConversationView view; + view.resize(620, 720); + view.show(); + view.bindGraph(graph, thread); + spin(100); + ui::QtNodeAttachment *rootAttachment = graphAttachment(root); + ui::QtNodeAttachment *siblingAttachment = graphAttachment(sibling); + ui::QtNodeAttachment *steeringAttachment = graphAttachment(steering); + QPointer oldRoot = rootAttachment ? rootAttachment->widget : nullptr; + QPointer siblingIdentity = + siblingAttachment ? siblingAttachment->widget : nullptr; + QPointer steeringIdentity = + steeringAttachment ? steeringAttachment->widget : nullptr; + bool result = + expect(oldRoot && siblingIdentity && steeringIdentity && + oldRoot->isAncestorOf(siblingIdentity) && + oldRoot->isAncestorOf(steeringIdentity), + "the materialized root initially owns its nested turn cards"); + + nodegraph::GraphChange replacementChange; + { + auto write = graph.write(); + nodegraph::NodeState replacement = + graphMessageState("agentMessage", "Replacement root"); + replacement.fields.emplace("phase", "final_answer"); + write.replaceState(root, std::move(replacement)); + replacementChange = write.finish(); + } + view.graphChanged(replacementChange.removed); + spin(100); + rootAttachment = graphAttachment(root); + auto *replacementRoot = + rootAttachment + ? qobject_cast(rootAttachment->widget.data()) + : nullptr; + result &= expect( + oldRoot.isNull() && replacementRoot && siblingIdentity && + steeringIdentity && graphAttachment(sibling) == siblingAttachment && + graphAttachment(steering) == steeringAttachment && + replacementRoot->isAncestorOf(siblingIdentity) && + replacementRoot->isAncestorOf(steeringIdentity), + "replacing a root card detaches nested widgets before deleting their " + "former QObject owner"); + + nodegraph::GraphChange recoveryChange; + { + auto write = graph.write(); + write.setField(sibling, "text", "Recovered current revision"); + recoveryChange = write.finish(); + } + delete siblingIdentity.data(); + result &= expect(siblingIdentity.isNull() && sibling->uiAttachment(), + "external QObject deletion leaves a detectable stale " + "opaque attachment"); + view.graphChanged(recoveryChange.removed); + spin(100); + siblingAttachment = graphAttachment(sibling); + auto *recoveredSibling = + siblingAttachment + ? qobject_cast(siblingAttachment->widget.data()) + : nullptr; + const auto *recoveredMessage = + recoveredSibling + ? std::get_if(&recoveredSibling->data().payload) + : nullptr; + result &= expect( + recoveredSibling && recoveredMessage && + recoveredMessage->text == "Recovered current revision" && + replacementRoot->isAncestorOf(recoveredSibling) && + siblingAttachment->renderedRevision == recoveryChange.revision, + "a null external QPointer clears the stale attachment and rematerializes " + "the latest node revision"); + return result; +} + +bool testGraphLastItemRemovalUpdatesChromeSynchronously() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef item; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{1}); + thread = write.upsert({nodegraph::NodeKind::Thread, "removal-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "removal-turn"}); + item = write.upsert({nodegraph::NodeKind::Item, "removal-item"}, + graphMessageState("agentMessage", "Only item")); + write.setParent(thread, turn); + write.setParent(turn, item); + static_cast(write.finish()); + } ConversationView view; - view.resize(980, 420); - bool result = expect( - view.reconcile(snapshot), - "retained prompt and partial final answer materialize initially"); - std::get(snapshot.sections.front().cards.back().payload) - .text = utf8(markdown); - result &= expect(view.reconcile(snapshot), - "retained hydration completes before first exposure"); - view.resize(560, 420); + view.resize(620, 360); view.show(); - ConversationCard *promptCard = card(view, stableKey(prompt.key)); - ConversationCard *answerCard = card(view, stableKey(answer.key)); - QLabel *answerBody = nullptr; - if (answerCard) - for (QLabel *label : answerCard->findChildren()) - if (label->property("markdownSource").toString() == markdown) { - answerBody = label; - break; - } - int documentHeight = 0; - if (answerBody) { - QTextDocument document; - document.setDefaultFont(answerBody->font()); - document.setDocumentMargin(0); - document.setHtml(answerBody->text()); - document.setTextWidth(answerBody->width()); - documentHeight = static_cast(std::ceil(document.size().height())); + view.bindGraph(graph, thread); + spin(60); + ui::QtNodeAttachment *attachment = graphAttachment(item); + QPointer removedWidget = attachment ? attachment->widget : nullptr; + QPushButton *loadMore = historyButton(view); + QLabel *empty = conversationEmptyLabel(view); + bool result = expect(removedWidget && loadMore && !loadMore->isVisible() && + empty && !empty->isVisible(), + "one graph item hides the empty state"); + + nodegraph::GraphChange removal; + { + auto write = graph.write(); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{0}); + write.remove(item); + removal = write.finish(); } + view.graphChanged(removal.removed); result &= expect( - promptCard && answerCard && answerBody && - promptCard->isAncestorOf(answerCard) && - answerBody->height() >= documentHeight && - answerBody->mapTo(answerCard, QPoint(0, answerBody->height())).y() <= - answerCard->contentsRect().bottom() + 1, - "an initially retained nested final answer fully fits its rendered " - "document and settled card"); - spin(); - qApp->setStyleSheet(originalStyleSheet); + item->uiAttachment() == nullptr && removedWidget.isNull() && + !loadMore->isVisible() && empty->isVisible(), + "removing the last graph item synchronously deletes its widget and " + "recomputes empty and Load More chrome"); + spin(20); return result; } -bool testBottomAnchoredCommandOutputGrowth() { - const std::string thread = "bottom-anchored-output"; - ConversationSnapshot snapshot = conversation(thread, 14); - VisibleCardData command{ - AuthoritativeItemKey{thread, "turn-2", "live-command"}, - CardKind::CommandExecution, - thread, - "turn-2", - "live-command", - CommandExecutionData{ - "run live command", {}, "inProgress", {}, std::nullopt}}; - snapshot.sections.back().cards.push_back(command); +bool testGraphLocalPromptMorphsWithoutReplacingItsWidget() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef localPrompt; + nodegraph::NodeRef activity; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{2}); + thread = write.upsert({nodegraph::NodeKind::Thread, "prompt-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "prompt-turn"}); + nodegraph::NodeState promptState; + promptState.status = nodegraph::NodeStatus::Pending; + promptState.fields.emplace("type", "localPrompt"); + promptState.fields.emplace("submissionId", std::uint64_t{42}); + promptState.fields.emplace("text", "Exact authored prompt"); + promptState.fields.emplace("dispatchState", "inFlight"); + promptState.fields.emplace( + "attachments", + nodegraph::Value::Array{nodegraph::Value( + nodegraph::Value::Object{{"path", "/tmp/prompt.png"}, + {"displayName", "prompt.png"}, + {"mimeType", "image/png"}})}); + localPrompt = write.upsert({nodegraph::NodeKind::Item, "local-prompt:42"}, + std::move(promptState)); + activity = write.upsert( + {nodegraph::NodeKind::Item, "prompt-activity"}, + graphMessageState("agentMessage", "Nested turn activity")); + write.setParent(thread, turn); + write.setParent(turn, localPrompt); + write.setParent(turn, activity); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, localPrompt); + static_cast(write.finish()); + } ConversationView view; view.resize(620, 360); view.show(); - view.reconcile(snapshot); - spin(); - ConversationCard *commandCard = card(view, stableKey(command.key)); - bool result = expect(setFolded(commandCard, false), - "live command expands from its compact default"); - wheel(view, -10000); - auto *metadata = - commandCard - ? commandCard->findChild(QStringLiteral("commandMetadata")) - : nullptr; - auto *status = - commandCard - ? commandCard->findChild(QStringLiteral("commandStatus")) - : nullptr; - auto *output = commandCard ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; - result &= expect(commandCard && metadata && metadata->isHidden() && status && - output && output->isHidden() && view.isAtBottom() && - status->property("tone") == "active", - "live command starts with a hidden zero-line output"); - if (!commandCard || !metadata || !status || !output) - return false; - const int cardBottomBefore = - commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); + std::vector acknowledgements; + bool callbackGraphWriteCompleted = false; + view.setPromptMaterializedAction([&acknowledgements, + &callbackGraphWriteCompleted, &graph, + &thread](nodegraph::NodeRef prompt) { + // This deliberately takes the exclusive graph lock synchronously. If + // ConversationView crosses the callback boundary with a read guard, + // this test deadlocks instead of masking the lock-order defect. + auto callbackWrite = graph.write(); + callbackWrite.setField(thread, "materializationCallbackObserved", true); + static_cast(callbackWrite.finish()); + callbackGraphWriteCompleted = true; + acknowledgements.emplace_back(std::move(prompt)); + return true; + }); + view.bindGraph(graph, thread); + spin(60); - auto &live = std::get( - snapshot.sections.back().cards.back().payload); - live.output = - "first wrapped output line with enough words to use real width\n" - "second output line\nthird output line\n\n"; - result &= expect(view.reconcile(snapshot), "live output becomes visible"); - const int cardBottomAfter = - commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())).y(); - result &= expect(!output->isHidden() && output->height() > 2 * 20 && - output->height() == output->sizeHint().height() && - cardBottomAfter == cardBottomBefore && view.isAtBottom(), - "multiline output takes its needed height and grows upward"); + ui::QtNodeAttachment *localAttachment = graphAttachment(localPrompt); + auto *localCard = + localAttachment + ? qobject_cast(localAttachment->widget.data()) + : nullptr; + QPointer stableCard(localCard); + ui::QtNodeAttachment *activityAttachment = graphAttachment(activity); + auto *activityCard = activityAttachment + ? qobject_cast( + activityAttachment->widget.data()) + : nullptr; + bool result = expect( + localCard && localCard->data().kind == CardKind::LocalPrompt && + localCard->property("turnContainer").toBool() && activityCard && + localCard->isAncestorOf(activityCard) && + std::get(localCard->data().payload).prompt == + "Exact authored prompt" && + std::get(localCard->data().payload).imagePaths == + std::vector{"/tmp/prompt.png"}, + "a starting graph prompt is the owning You card for its nested turn " + "activity while rendering exact authored content"); + const qulonglong retiredBefore = + view.property("graphRetiredGeometryRecordCount").toULongLong(); + PaintAnchorProbe ownershipProbe(view); + ownershipProbe.start(localCard); + ownershipProbe.trackOwnership(localCard, activityCard); + + nodegraph::NodeRef authoritative; + nodegraph::GraphChange materialized; + { + auto write = graph.write(); + authoritative = + write.upsert({nodegraph::NodeKind::Item, "provider-user-message"}, + graphMessageState("userMessage", "Exact authored prompt")); + write.setParent(turn, authoritative); + write.relate(authoritative, nodegraph::RelationKind::PromptMaterialization, + localPrompt); + write.replaceRelated(turn, nodegraph::RelationKind::TurnRootItem, + std::array{authoritative}); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{3}); + materialized = write.finish(); + } + view.graphChangedDeferred(materialized.affected, materialized.removed); + spin(80); - QString cappedOutput; - for (int line = 0; line < 80; ++line) - cappedOutput += QStringLiteral("scrollable line %1\n").arg(line); - live.output = utf8(cappedOutput); - result &= expect(view.reconcile(snapshot), "live output reaches its cap"); result &= expect( - output->height() == 220 && output->verticalScrollBar()->maximum() > 0 && - commandCard->mapTo(view.viewport(), QPoint(0, commandCard->height())) - .y() == cardBottomBefore, - "capped output keeps its scrollbar and fixed card bottom"); + graphAttachment(localPrompt) == localAttachment && + graphAttachment(authoritative) == nullptr && stableCard && + stableCard->data().kind == CardKind::LocalPrompt && + acknowledgements.empty(), + "a correlated authoritative item stays hidden behind the one stable " + "local card until exact prompt acknowledgement"); + + nodegraph::GraphChange failed; + { + auto write = graph.write(); + write.setField(localPrompt, "dispatchState", "failed"); + write.setField(localPrompt, "error", "result failed"); + write.setStatus(localPrompt, nodegraph::NodeStatus::Failed); + failed = write.finish(); + } + view.graphChangedDeferred(failed.affected, failed.removed); + spin(40); + result &= + expect(stableCard && graphAttachment(authoritative) == nullptr && + stableCard->data().kind == CardKind::LocalPrompt && + std::get(stableCard->data().payload).state == + PromptState::Failed, + "a failed exact result keeps one recoverable authored card and no " + "correlated duplicate"); + + nodegraph::GraphChange acknowledged; + { + auto write = graph.write(); + write.setField(localPrompt, "dispatchState", "awaitingMaterialization"); + write.setStatus(localPrompt, nodegraph::NodeStatus::Running); + acknowledged = write.finish(); + } + view.graphChangedDeferred(acknowledged.affected, acknowledged.removed); + spin(80); + ownershipProbe.active = false; + + ui::QtNodeAttachment *authoritativeAttachment = + graphAttachment(authoritative); + auto *authoritativeCard = authoritativeAttachment + ? qobject_cast( + authoritativeAttachment->widget.data()) + : nullptr; + result &= expect( + localPrompt->uiAttachment() == nullptr && stableCard && + authoritativeCard == stableCard && + authoritativeCard->data().kind == CardKind::UserMessage && + std::get(authoritativeCard->data().payload).text == + "Exact authored prompt" && + authoritativeCard->property("conversationAnchorKey").toString() == + QStringLiteral("prompt:42") && + callbackGraphWriteCompleted && + acknowledgements == std::vector{localPrompt}, + "correlation plus exact result acknowledgement runs after releasing " + "the graph read guard, then transfers and morphs the same You card"); + result &= expect( + authoritativeCard && + authoritativeCard->property("turnContainer").toBool() && + activityCard && authoritativeCard->isAncestorOf(activityCard), + "authoritative root transfer keeps nested activity owned by the same " + "morphed You card"); + result &= expect( + view.property("graphRetiredGeometryRecordCount").toULongLong() == + retiredBefore && + graphAttachment(activity) == activityAttachment && activityCard && + !ownershipProbe.ownership.empty() && + std::ranges::all_of(ownershipProbe.ownership, std::identity{}), + "normal prompt root promotion retains existing history geometry and " + "widgets, and no painted frame exposes a parentless nested card"); + + nodegraph::GraphChange removed; + { + auto write = graph.write(); + write.remove(localPrompt); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{2}); + removed = write.finish(); + } + view.graphChangedDeferred(removed.affected, removed.removed); + spin(30); + result &= expect(stableCard && graphAttachment(authoritative) && + graphAttachment(authoritative)->widget == stableCard, + "retiring the acknowledged local node preserves the " + "authoritative card attachment"); return result; } -bool testCommandOutputStateAcrossNavigation() { - const std::string thread = "command-navigation-thread"; - QString output; - for (int line = 0; line < 80; ++line) - output += QStringLiteral("retained line %1\n").arg(line); - const VisibleCardData command{ - AuthoritativeItemKey{thread, "turn", "command"}, - CardKind::CommandExecution, - thread, - "turn", - "command", - CommandExecutionData{"produce output", utf8(output), "completed", {}, 0}}; - const ConversationSnapshot commandThread{ - thread, {{"turn:command-navigation", "turn", {command}}}, 0, false}; +bool testGraphSteeringPromptAcknowledgementKeepsCanonicalParent() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + nodegraph::NodeRef root; + nodegraph::NodeRef steering; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{2}); + thread = write.upsert({nodegraph::NodeKind::Thread, "steering-ack-thread"}, + std::move(threadState)); + turn = write.upsert({nodegraph::NodeKind::Turn, "steering-ack-turn"}); + root = write.upsert({nodegraph::NodeKind::Item, "steering-ack-root"}, + graphMessageState("userMessage", "Opening prompt")); + nodegraph::NodeState steeringState; + steeringState.status = nodegraph::NodeStatus::Running; + steeringState.fields.emplace("type", "localPrompt"); + steeringState.fields.emplace("submissionId", std::uint64_t{77}); + steeringState.fields.emplace("text", "A steering prompt"); + steeringState.fields.emplace("dispatchState", "inFlight"); + steeringState.fields.emplace("startsTurn", false); + steering = write.upsert( + {nodegraph::NodeKind::Item, "steering-ack-local"}, + std::move(steeringState)); + write.setParent(thread, turn); + write.setParent(turn, root); + write.setParent(turn, steering); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } ConversationView view; - view.resize(650, 520); + view.resize(620, 420); view.show(); - view.reconcile(commandThread); - spin(); - ConversationCard *commandCard = card(view, stableKey(command.key)); - bool result = expect(setFolded(commandCard, false), - "navigation command expands from its compact default"); - auto *initialOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; - result &= - expect(initialOutput && initialOutput->verticalScrollBar()->maximum() > 0, - "navigation test has independently scrollable output"); - if (!initialOutput) - return false; - view.reconcile(conversation("other-thread", 8)); - spin(); - view.reconcile(commandThread); - spin(); - commandCard = card(view, stableKey(command.key)); - initialOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; - result &= expect(initialOutput && initialOutput->followsLatest() && - initialOutput->verticalScrollBar()->value() == - initialOutput->verticalScrollBar()->maximum(), - "framework geometry during navigation does not pause a " - "following command output"); - if (!initialOutput) - return false; - initialOutput->verticalScrollBar()->triggerAction( - QAbstractSlider::SliderSingleStepSub); - spin(); - const int pausedValue = initialOutput->verticalScrollBar()->value(); - result &= expect(!initialOutput->followsLatest(), - "command output is paused before thread navigation"); + std::vector acknowledgements; + view.setPromptMaterializedAction( + [&acknowledgements](nodegraph::NodeRef prompt) { + acknowledgements.push_back(std::move(prompt)); + return true; + }); + view.bindGraph(graph, thread); + const bool initialReady = spinUntil([&] { + const auto *rootAttachment = graphAttachment(root); + const auto *steeringAttachment = graphAttachment(steering); + return rootAttachment && rootAttachment->widget && steeringAttachment && + steeringAttachment->widget && view.viewport()->updatesEnabled(); + }, 128); + auto *rootCard = graphAttachment(root) + ? qobject_cast( + graphAttachment(root)->widget.data()) + : nullptr; + auto *steeringCard = graphAttachment(steering) + ? qobject_cast( + graphAttachment(steering)->widget.data()) + : nullptr; + QPointer stableSteering(steeringCard); + PaintAnchorProbe ownership(view); + ownership.start(steeringCard); + ownership.trackOwnership(rootCard, steeringCard); + + nodegraph::NodeRef authoritative; + nodegraph::GraphChange arrived; + { + auto write = graph.write(); + authoritative = write.upsert( + {nodegraph::NodeKind::Item, "steering-ack-authoritative"}, + graphMessageState("userMessage", "A steering prompt")); + write.setParent(turn, authoritative); + write.relate(authoritative, + nodegraph::RelationKind::PromptMaterialization, steering); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{3}); + arrived = write.finish(); + } + view.graphChangedDeferred(arrived.affected, arrived.removed); + spin(32); + const bool retainedBeforeResult = + stableSteering && graphAttachment(steering) && + graphAttachment(steering)->widget == stableSteering && + graphAttachment(authoritative) == nullptr && rootCard && + rootCard->isAncestorOf(stableSteering); + + // The app-server may emit other canonical items before the exact steer + // request result. Coalescing more than one tail append must not be mistaken + // for a provider reorder or rebuild the retained Turn/You hierarchy. + nodegraph::NodeRef review; + nodegraph::NodeRef progress; + nodegraph::GraphChange interleaved; + const qulonglong retiredBeforeInterleave = + view.property("graphRetiredGeometryRecordCount").toULongLong(); + { + auto write = graph.write(); + nodegraph::NodeState reviewState; + reviewState.status = nodegraph::NodeStatus::Running; + reviewState.fields.emplace("type", "autoApprovalReview"); + reviewState.fields.emplace("phase", "started"); + reviewState.fields.emplace("detail", "Reviewing the requested action"); + review = write.upsert( + {nodegraph::NodeKind::Item, "steering-ack-review"}, + std::move(reviewState)); + nodegraph::NodeState progressState = + graphMessageState("agentMessage", "Interleaved progress"); + progressState.fields.emplace("phase", "commentary"); + progress = write.upsert( + {nodegraph::NodeKind::Item, "steering-ack-progress"}, + std::move(progressState)); + write.setParent(turn, review); + write.setParent(turn, progress); + write.setField(thread, "historyLoadedItemCount", std::uint64_t{5}); + interleaved = write.finish(); + } + view.graphChangedDeferred(interleaved.affected, interleaved.removed); + const bool interleavedReady = spinUntil([&] { + const auto *reviewAttachment = graphAttachment(review); + const auto *progressAttachment = graphAttachment(progress); + return reviewAttachment && reviewAttachment->widget && + progressAttachment && progressAttachment->widget && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 128); + const bool stableDuringInterleave = + stableSteering && rootCard && rootCard->isAncestorOf(stableSteering) && + graphAttachment(steering) && + graphAttachment(steering)->widget == stableSteering && + graphAttachment(authoritative) == nullptr && + graphAttachment(review) && + rootCard->isAncestorOf(graphAttachment(review)->widget) && + graphAttachment(progress) && + rootCard->isAncestorOf(graphAttachment(progress)->widget) && + view.property("graphRetiredGeometryRecordCount").toULongLong() == + retiredBeforeInterleave; + + const qulonglong commitAttemptsBefore = + view.property("bulkMaterializationCommitAttempts").toULongLong(); + nodegraph::GraphChange acknowledged; + { + auto write = graph.write(); + write.setField(steering, "dispatchState", "awaitingMaterialization"); + acknowledged = write.finish(); + } + view.graphChangedDeferred(acknowledged.affected, acknowledged.removed); + const bool transferred = spinUntil([&] { + const auto *attachment = graphAttachment(authoritative); + return attachment && attachment->widget == stableSteering && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }, 128); + spin(16); + ownership.active = false; + const auto *authoritativeAttachment = graphAttachment(authoritative); + auto *authoritativeCard = + authoritativeAttachment + ? qobject_cast( + authoritativeAttachment->widget.data()) + : nullptr; + + return expect( + initialReady && retainedBeforeResult && interleavedReady && + stableDuringInterleave && transferred && stableSteering && + authoritativeCard == stableSteering && rootCard && + rootCard->property("turnContainer").toBool() && + rootCard->isAncestorOf(stableSteering) && + stableSteering->property("nestedConversationCard").toBool() && + steering->uiAttachment() == nullptr && + acknowledgements == std::vector{steering} && + view.property("bulkMaterializationCommitAttempts").toULongLong() > + commitAttemptsBefore && + !ownership.ownership.empty() && + std::ranges::all_of(ownership.ownership, std::identity{}), + "steering acknowledgement transfers one stable nested card atomically " + "without any painted parentless frame"); +} + +bool testStructuredTurnPlanRemainsInspectorOnly() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + nodegraph::NodeRef turn; + { + auto write = graph.write(); + nodegraph::NodeState threadState; + threadState.fields.emplace("historyLoadedItemCount", std::uint64_t{0}); + threadState.fields.emplace("hydrationState", "ready"); + thread = write.upsert({nodegraph::NodeKind::Thread, "plan-thread"}, + std::move(threadState)); + nodegraph::NodeState planState; + planState.status = nodegraph::NodeStatus::Running; + planState.fields.emplace("planExplanation", "Current graph plan"); + nodegraph::Value::Array steps; + nodegraph::Value::Object first; + first.emplace("step", "Inspect graph"); + first.emplace("status", "completed"); + steps.emplace_back(std::move(first)); + nodegraph::Value::Object second; + second.emplace("step", "Render plan"); + second.emplace("status", "inProgress"); + steps.emplace_back(std::move(second)); + planState.fields.emplace("plan", std::move(steps)); + turn = write.upsert({nodegraph::NodeKind::Turn, "plan-turn"}, + std::move(planState)); + write.setParent(thread, turn); + static_cast(write.finish()); + } - view.reconcile(conversation("other-thread", 8)); + ConversationView view; + view.resize(640, 400); + view.show(); + view.bindGraph(graph, thread); spin(); - view.reconcile(commandThread); + const std::string key = + stableKey(CardKey{TurnPlanKey{"plan-thread", "plan-turn"}}); + QPointer planCard = card(view, key); + bool result = expect(!planCard && graphAttachment(turn) == nullptr, + "structured turn plans remain Inspector-only and do " + "not create a duplicate conversation card"); + + nodegraph::GraphChange cleared; + { + auto write = graph.write(); + nodegraph::NodeState completed; + completed.status = nodegraph::NodeStatus::Completed; + write.replaceState(turn, std::move(completed)); + cleared = write.finish(); + } + view.graphChanged(cleared.removed); spin(); - commandCard = card(view, stableKey(command.key)); - auto *restoredOutput = commandCard - ? dynamic_cast( - commandCard->findChild( - QStringLiteral("commandOutputView"))) - : nullptr; - result &= - expect(restoredOutput && !restoredOutput->followsLatest() && - restoredOutput->verticalScrollBar()->value() == pausedValue, - "thread navigation restores paused command output state"); + result &= expect(!planCard && graphAttachment(turn) == nullptr && + !hasConversationItem(view, key), + "clearing Inspector-only plan state leaves conversation " + "geometry unchanged"); + return result; +} + +bool testOverduePromptStartsOnlyWhenVisible() { + constexpr int promptCount = 32; + const std::int64_t admittedAt = QDateTime::currentMSecsSinceEpoch() - + PendingAnimationDelayMilliseconds - 250; + ConversationGraphSpec snapshot; + snapshot.threadId = "overdue-prompts"; + TurnGraphSpec turn{"turn:overdue-prompts", "turn", {}}; + turn.cards.reserve(promptCount); + for (int index = 0; index < promptCount; ++index) { + const std::uint64_t submission = 7000U + static_cast(index); + turn.cards.push_back( + {LocalPromptKey{submission}, + CardKind::LocalPrompt, + snapshot.threadId, + turn.turnId, + {}, + LocalPromptData{submission, + "retained overdue prompt " + std::to_string(index), + PromptState::InFlight, + false, + {}, + {}, + admittedAt}}); + } + snapshot.sections.push_back(std::move(turn)); + + ConversationView view; + view.resize(620, 240); + view.show(); + applyConversation(view, snapshot); + + std::vector prompts; + prompts.reserve(promptCount); + { + auto read = snapshot.storage->graph.tryRead(); + if (read) { + for (int index = 0; index < promptCount; ++index) { + prompts.push_back(read->find( + {nodegraph::NodeKind::Item, + "fixture-local-prompt:" + std::to_string(7000 + index)})); + } + } + } + const bool loadedWindowReady = spinUntil([&] { + return prompts.size() == promptCount && + std::ranges::all_of(prompts, [](const nodegraph::NodeRef &prompt) { + const auto *attachment = graphAttachment(prompt); + return attachment && attachment->widget; + }) && + view.viewport()->updatesEnabled() && + !view.property("bulkMaterializationUpdatesSuppressed").toBool(); + }); + + bool foundDormantOverscan = false; + int attachedPrompts = 0; + int viewportPrompts = 0; + int activeAnimations = 0; + int activeDelays = 0; + for (const nodegraph::NodeRef &prompt : prompts) { + ui::QtNodeAttachment *attachment = graphAttachment(prompt); + if (!attachment || !attachment->widget) + continue; + ++attachedPrompts; + viewportPrompts += attachment->viewportVisible ? 1 : 0; + auto *promptCard = + qobject_cast(attachment->widget.data()); + QTimer *animation = promptCard + ? promptCard->findChild( + QStringLiteral("pendingAnimationTimer")) + : nullptr; + QTimer *delay = promptCard ? promptCard->findChild( + QStringLiteral("pendingDelayTimer")) + : nullptr; + activeAnimations += animation && animation->isActive() ? 1 : 0; + activeDelays += delay && delay->isActive() ? 1 : 0; + if (!attachment->viewportVisible && promptCard && animation && delay && + !animation->isActive() && + !delay->isActive()) { + foundDormantOverscan = true; + break; + } + } + if (!(loadedWindowReady && foundDormantOverscan)) { + std::cerr << "overdue visibility: ready=" << loadedWindowReady + << " prompts=" << prompts.size() + << " attached=" << attachedPrompts + << " viewport=" << viewportPrompts + << " animations=" << activeAnimations + << " delays=" << activeDelays + << " scroll=" << view.verticalScrollBar()->value() << '/' + << view.verticalScrollBar()->maximum() + << " blocked=" + << view.property("bulkMaterializationUpdatesSuppressed").toBool() + << '\n'; + } + bool result = expect( + loadedWindowReady && foundDormantOverscan, + "an overdue materialized overscan prompt performs no timer work while " + "its node attachment is outside the viewport"); + + const nodegraph::NodeRef first = prompts.empty() ? nullptr : prompts.front(); + ui::QtNodeAttachment *firstDormantAttachment = graphAttachment(first); + auto *firstDormantCard = + firstDormantAttachment + ? qobject_cast( + firstDormantAttachment->widget.data()) + : nullptr; + QTimer *firstDormantAnimation = + firstDormantCard + ? firstDormantCard->findChild( + QStringLiteral("pendingAnimationTimer")) + : nullptr; + QTimer *firstDormantDelay = + firstDormantCard + ? firstDormantCard->findChild( + QStringLiteral("pendingDelayTimer")) + : nullptr; + result &= expect( + firstDormantAttachment && firstDormantAttachment->widget && + !firstDormantAttachment->viewportVisible && firstDormantAnimation && + !firstDormantAnimation->isActive() && firstDormantDelay && + !firstDormantDelay->isActive(), + "a loaded distant overdue prompt retains its one materialized card but " + "performs no animation work until its viewport is requested"); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderToMinimum); + spin(160); + + ui::QtNodeAttachment *attachment = graphAttachment(first); + auto *promptCard = + attachment ? qobject_cast(attachment->widget.data()) + : nullptr; + QTimer *animation = promptCard ? promptCard->findChild( + QStringLiteral("pendingAnimationTimer")) + : nullptr; + QTimer *delay = + promptCard + ? promptCard->findChild(QStringLiteral("pendingDelayTimer")) + : nullptr; + const auto *promptData = + promptCard ? std::get_if(&promptCard->data().payload) + : nullptr; + result &= expect( + attachment && attachment->viewportVisible && promptCard && promptData && + promptData->admittedAtMs == admittedAt && + promptCard->property("pendingFeedbackVisible").toBool() && + animation && animation->isActive() && delay && !delay->isActive(), + "materializing an already-overdue graph prompt starts feedback " + "immediately from its retained admission deadline"); return result; } +#endif bool testPendingPromptAnimation() { VisibleCardData pending{ @@ -2378,16 +6797,20 @@ bool testPendingPromptAnimation() { "prompt-thread", {}, {}, - LocalPromptData{901, "pending prompt", PromptState::InFlight, false, {}, - {}}}; + LocalPromptData{ + 901, "pending prompt", PromptState::InFlight, false, {}, {}}}; ConversationCard card(pending); card.resize(560, 92); card.show(); spin(40); const QImage first = card.grab().toImage(); + auto *pendingStatus = + card.findChild(QStringLiteral("pendingPromptStatus")); spin(110); const QImage second = card.grab().toImage(); - bool result = expect(first == second, + bool result = expect(pendingStatus && + pendingStatus->text() == QStringLiteral("pending") && + first == second, "a newly admitted prompt begins as a calm static card"); result &= expect(first.pixelColor(10, first.height() - 10).blue() > first.pixelColor(10, first.height() - 10).red() && @@ -2395,6 +6818,13 @@ bool testPendingPromptAnimation() { first.pixelColor(10, first.height() - 10).green(), "the temporary You card stays in the blue identity family"); + spin(950); + const QImage delayedFirst = card.grab().toImage(); + spin(110); + result &= expect(delayedFirst != card.grab().toImage(), + "pending feedback starts locally after one second without " + "a worker or graph timer update"); + auto &prompt = std::get(pending.payload); prompt.showPendingAnimation = true; result &= expect(card.apply(pending), @@ -2431,8 +6861,8 @@ bool testPendingPromptAnimation() { "prompt-thread", "turn", {}, - LocalPromptData{902, "steering prompt", PromptState::InFlight, false, - {}, {}}}; + LocalPromptData{ + 902, "steering prompt", PromptState::InFlight, false, {}, {}}}; ConversationCard steeringCard(steering); steeringCard.setProperty("nestedConversationCard", true); steeringCard.resize(520, 92); @@ -2446,19 +6876,28 @@ bool testPendingPromptAnimation() { steeringPrompt.showPendingAnimation = true; result &= expect(steeringCard.apply(steering), "overdue steering starts its teal feedback sweep"); + auto *steeringStatus = steeringCard.findChild( + QStringLiteral("steeringMessagePhase")); + result &= expect( + steeringStatus && + steeringStatus->text() == QStringLiteral("steering · pending"), + "pending steering retains its identity and shows its lifecycle state"); const QImage steeringAnimated = steeringCard.grab().toImage(); spin(110); result &= expect(steeringAnimated != steeringCard.grab().toImage(), "the delayed steering sweep is visibly animated"); result &= expect( steeringAnimated.pixelColor(10, steeringAnimated.height() - 10).green() > - steeringAnimated.pixelColor(10, steeringAnimated.height() - 10) - .red(), + steeringAnimated.pixelColor(10, steeringAnimated.height() - 10).red(), "the steering feedback stays in the teal identity family"); steeringPrompt.state = PromptState::Accepted; steeringPrompt.showPendingAnimation = false; result &= expect(steeringCard.apply(steering), "steering acknowledgement stops its feedback sweep"); + result &= expect(steeringStatus && + steeringStatus->text() == QStringLiteral("steering"), + "acknowledgement clears pending from the steering header"); + spin(40); const QImage steeringSettled = steeringCard.grab().toImage(); spin(100); result &= expect(steeringSettled == steeringCard.grab().toImage(), @@ -2518,8 +6957,7 @@ bool testMessageImagePresentation() { if (!ribbon || !image) return false; const int top = image->mapTo(ribbon->viewport(), QPoint{}).y(); - const int bottom = - ribbon->viewport()->height() - top - image->height(); + const int bottom = ribbon->viewport()->height() - top - image->height(); return std::abs(top - bottom) <= 1; }; const QImage ribbonImage = ribbon ? ribbon->grab().toImage() : QImage{}; @@ -2761,6 +7199,13 @@ bool testGeneratedImagePresentationAndGenericBound() { details->text().endsWith( QStringLiteral("[Activity details truncated]")), "protocol labels are humanized without changing bounded raw details"); + auto &genericData = std::get(generic.payload); + genericData.displayDetail = "field: direct graph detail"; + result &= expect(genericCard.apply(generic) && details && + details->text() == + QStringLiteral("field: direct graph detail"), + "graph generic activity detail renders without JSON " + "construction"); return result; } @@ -2770,11 +7215,28 @@ bool testGeneratedImagePresentationAndGenericBound() { int main(int argc, char **argv) { QApplication application(argc, argv); using namespace codexui::codex::middle; + if (qEnvironmentVariableIsSet("CODEXUI_MUTABLE_CARD_TESTS")) + return testMutableCardsAndCommandOutput() ? 0 : 1; + if (qEnvironmentVariableIsSet("CODEXUI_FOLLOW_TESTS")) + return testFollowPauseAndStableAnchor() ? 0 : 1; + if (qEnvironmentVariableIsSet("CODEXUI_BORDER_TESTS")) + return testActiveWorkBordersFollowStatus() && testPendingPromptAnimation() + ? 0 + : 1; + if (qEnvironmentVariableIsSet("CODEXUI_SETTLEMENT_TESTS")) { + bool focused = testRetainedNestedFinalAnswerGeometrySettlement(); + focused &= testBottomAnchoredCommandOutputGrowth(); + if (focused) + std::cout << "Conversation settlement tests passed\n"; + return focused ? 0 : 1; + } bool result = testMessageIdentityPalette(); result &= testActiveWorkBordersFollowStatus(); result &= testStructuralOrderAndIdentity(); result &= testFollowPauseAndStableAnchor(); result &= testPausedExpandedCommandStaysPainted(); + result &= testCommandCompletionWithoutGeometryWork(); + result &= testStreamingAgentBecomesVisibleWithoutReselection(); result &= testThreadLocalScrollAndComposerExtent(); result &= testPromptAdmissionFollowOwnership(); result &= testCardCopyControls(); diff --git a/tests/codex/ConversationProjectionTest.cpp b/tests/codex/ConversationProjectionTest.cpp deleted file mode 100644 index 4b0b513..0000000 --- a/tests/codex/ConversationProjectionTest.cpp +++ /dev/null @@ -1,948 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/middle/ConversationProjection.h" -#include "codex/middle/PromptCoordinator.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace codexui::codex::middle { -namespace { - -bool expect(bool condition, const char *message) { - if (condition) - return true; - std::cerr << "FAILED: " << message << '\n'; - return false; -} - -ItemPresentation item(std::string id, nlohmann::json raw) { - return ItemPresentation{std::move(id), std::move(raw), {}}; -} - -void appendItem(ThreadPresentation &thread, const std::string &turnId, - ItemPresentation presentation) { - TurnPresentation &turn = thread.turns.at(turnId); - turn.itemOrder.push_back(presentation.id); - turn.items.emplace(presentation.id, std::move(presentation)); -} - -ThreadPresentation baseThread(std::string id) { - ThreadPresentation thread; - thread.id = std::move(id); - thread.turnOrder = {"turn-1"}; - TurnPresentation turn; - turn.id = "turn-1"; - turn.status = "completed"; - thread.turns.emplace(turn.id, std::move(turn)); - appendItem(thread, "turn-1", - item("user-old", - {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "old prompt"}}}}})); - appendItem(thread, "turn-1", - item("answer-old", {{"type", "agentMessage"}, - {"phase", "final_answer"}, - {"text", "old answer"}})); - return thread; -} - -void addTurn(ThreadPresentation &thread, const std::string &turnId, - std::string status = "inProgress") { - thread.turnOrder.push_back(turnId); - TurnPresentation turn; - turn.id = turnId; - turn.status = std::move(status); - thread.turns.emplace(turn.id, std::move(turn)); -} - -const VisibleCardData *cardForSubmission(const ConversationSnapshot &snapshot, - std::uint64_t id) { - return snapshot.find(LocalPromptKey{id}); -} - -bool testCanonicalGroupingAndProjection() { - ThreadPresentation thread = baseThread("thread-a"); - addTurn(thread, "turn-2"); - appendItem(thread, "turn-2", - item("command", {{"type", "commandExecution"}, - {"command", "true"}, - {"status", "completed"}, - {"aggregatedOutput", " \n\t\x1b[0m"}})); - - const ConversationSnapshot snapshot = ConversationProjection::project( - thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - bool result = expect(snapshot.sections.size() == 2, - "one transparent section is projected per turn"); - result &= expect(snapshot.sections[0].turnId == "turn-1" && - snapshot.sections[0].cards.size() == 2 && - snapshot.sections[1].turnId == "turn-2" && - snapshot.sections[1].cards.size() == 1, - "thread, turn, and item order are retained"); - const auto *command = - std::get_if(&snapshot.sections[1].cards[0].payload); - result &= expect(command && command->output.empty(), - "non-presentable command output is projected as absent"); - result &= expect(std::holds_alternative( - snapshot.sections[0].cards[0].key) && - stableKey(snapshot.sections[0].cards[0].key) != - stableKey(snapshot.sections[0].cards[1].key), - "authoritative cards have typed collision-free stable keys"); - - const ConversationSnapshot limited = - ConversationProjection::project(thread, {}, 1, 10); - result &= - expect(limited.hasMore && limited.hiddenAuthoritativeItemCount == 2 && - limited.cardKeys().size() == 1, - "history limit is based only on authoritative items"); - - ThreadPresentation emptyPlan = baseThread("thread-empty-plan"); - appendItem(emptyPlan, "turn-1", - item("empty-plan", {{"type", "plan"}, - {"text", ""}, - {"status", "completed"}})); - const ConversationSnapshot emptyPlanSnapshot = - ConversationProjection::project(emptyPlan, {}, 80, 10); - const VisibleCardData &emptyPlanCard = - emptyPlanSnapshot.sections.front().cards.back(); - const auto *generic = - std::get_if(&emptyPlanCard.payload); - result &= expect(emptyPlanCard.kind == CardKind::GenericActivity && generic && - generic->type == "plan" && - generic->status == "completed", - "generic fallbacks retain an available lifecycle state"); - return result; -} - -bool testStreamTruncationIsVisible() { - ThreadPresentation thread; - thread.id = "bounded-projection"; - addTurn(thread, "turn-1", "completed"); - ItemPresentation command = - item("command", {{"type", "commandExecution"}, - {"command", "generate output"}, - {"status", "completed"}, - {"aggregatedOutput", "retained tail"}}); - command.textRetention.push_back( - {"aggregatedOutput", std::string("retained tail").size(), 4096}); - appendItem(thread, "turn-1", std::move(command)); - const ConversationSnapshot snapshot = ConversationProjection::project( - thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const auto *projected = std::get_if( - &snapshot.sections.front().cards.front().payload); - return expect(projected && - projected->output.starts_with( - "[Earlier command output was truncated ") && - projected->output.find("4096 bytes omitted") != - std::string::npos && - projected->output.ends_with("retained tail"), - "bounded stream projection visibly discloses omitted output " - "before its retained tail"); -} - -bool testTurnRootSurvivesHistoryPaging() { - ThreadPresentation thread; - thread.id = "thread-long-turn"; - addTurn(thread, "turn-long"); - appendItem( - thread, "turn-long", - item("root-user", - {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "Root prompt"}}}}})); - for (int index = 0; index < 45; ++index) - appendItem(thread, "turn-long", - item("before-steer-" + std::to_string(index), - {{"type", "agentMessage"}, {"text", "activity"}})); - appendItem(thread, "turn-long", - item("steering-user", - {{"type", "userMessage"}, - {"content", - {{{"type", "text"}, {"text", "Later steering prompt"}}}}})); - for (int index = 0; index < 45; ++index) - appendItem(thread, "turn-long", - item("after-steer-" + std::to_string(index), - {{"type", "agentMessage"}, {"text", "activity"}})); - - const AuthoritativeItemKey rootKey{thread.id, "turn-long", "root-user"}; - const AuthoritativeItemKey steeringKey{thread.id, "turn-long", - "steering-user"}; - const ConversationSnapshot limited = - ConversationProjection::project(thread, {}, 80, 100); - bool result = expect( - limited.sections.size() == 1 && - limited.sections.front().rootCardKey == CardKey{rootKey} && - limited.sections.front().cards.front().key == CardKey{rootKey} && - limited.find(rootKey) && limited.find(steeringKey) && - limited.cardKeys().size() == 81 && - limited.hiddenAuthoritativeItemCount == 11 && limited.hasMore, - "a current long turn pins its real root outside the activity budget"); - - thread.turns.at("turn-long").status = "completed"; - const ConversationSnapshot completed = - ConversationProjection::project(thread, {}, 80, 101); - result &= - expect(completed.sections.front().rootCardKey == CardKey{rootKey} && - completed.find(rootKey) && completed.find(steeringKey) && - completed.hiddenAuthoritativeItemCount == 11, - "turn completion cannot release the retained activity's root"); - - const ConversationSnapshot loaded = - ConversationProjection::project(thread, {}, 160, 102); - result &= - expect(loaded.sections.size() == 1 && - loaded.sections.front().rootCardKey == CardKey{rootKey} && - std::ranges::count(loaded.cardKeys(), CardKey{rootKey}) == 1 && - loaded.find(steeringKey) && !loaded.hasMore && - loaded.hiddenAuthoritativeItemCount == 0, - "loading older activity retains one stable root and ordinary " - "paging semantics"); - - const auto indexed = indexAuthoritativeItems(thread.id, &thread); - result &= expect( - indexed.turnRootUserMessagePositions.at("turn-long") == 0, - "a cold-loaded thread indexes its root without local prompt state"); - - ThreadPresentation rootOnlyHidden; - rootOnlyHidden.id = "thread-root-only-hidden"; - addTurn(rootOnlyHidden, "turn"); - appendItem( - rootOnlyHidden, "turn", - item("root", {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "Root"}}}}})); - appendItem(rootOnlyHidden, "turn", - item("answer", {{"type", "agentMessage"}, {"text", "Answer"}})); - const ConversationSnapshot rootOnlyPinned = - ConversationProjection::project(rootOnlyHidden, {}, 1, 103); - result &= - expect(rootOnlyPinned.cardKeys().size() == 2 && - rootOnlyPinned.hiddenAuthoritativeItemCount == 0 && - !rootOnlyPinned.hasMore, - "pinning the only earlier root leaves no hidden activity to load"); - - ThreadPresentation conflicting; - conflicting.id = "thread-unique-root"; - PromptCoordinator prompts; - const auto localId = - prompts.admit(conflicting.id, "Locally admitted start", {}, - nlohmann::json::object(), nullptr, std::nullopt, 200); - result &= expect(prompts.beginNext(conflicting.id).has_value() && - prompts.acknowledge(conflicting.id, localId, - std::string("turn")), - "a locally admitted turn start reaches acknowledgment"); - addTurn(conflicting, "turn"); - appendItem(conflicting, "turn", - item("authoritative-root", - {{"type", "userMessage"}, - {"content", - {{{"type", "text"}, - {"text", "Different authoritative prompt"}}}}})); - prompts.reconcile(conflicting.id, conflicting); - const ConversationSnapshot uniqueRoot = ConversationProjection::project( - conflicting, prompts.submissions(conflicting.id), 80, 202); - const AuthoritativeItemKey authoritativeRoot{conflicting.id, "turn", - "authoritative-root"}; - result &= expect(uniqueRoot.sections.size() == 1 && - uniqueRoot.sections.front().rootCardKey == - CardKey{authoritativeRoot} && - uniqueRoot.find(LocalPromptKey{localId}), - "an unmatched local start cannot compete with an existing " - "authoritative root"); - return result; -} - -bool testQueueIsolationAndRealAcknowledgement() { - ThreadPresentation first = baseThread("thread-a"); - ThreadPresentation second = baseThread("thread-b"); - PromptCoordinator prompts; - const auto firstId = - prompts.admit(first.id, "same", {}, nlohmann::json::object(), &first, - std::nullopt, 100); - const auto secondId = - prompts.admit(second.id, "other", {}, nlohmann::json::object(), &second, - std::nullopt, 101); - - const auto firstDispatch = prompts.beginNext(first.id); - bool result = expect(firstDispatch && firstDispatch->id == firstId, - "the first queued prompt begins dispatch"); - result &= expect(!prompts.beginNext(first.id), - "a thread has at most one in-flight prompt"); - const auto secondDispatch = prompts.beginNext(second.id); - result &= expect(secondDispatch && secondDispatch->id == secondId, - "different threads have independent in-flight queues"); - - const auto pendingAnimationAt = [&first, &prompts, firstId]( - std::int64_t now) { - const ConversationSnapshot snapshot = ConversationProjection::project( - first, prompts.submissions(first.id), 80, now); - const VisibleCardData *card = snapshot.find(LocalPromptKey{firstId}); - const auto *prompt = - card ? std::get_if(&card->payload) : nullptr; - return prompt && prompt->showPendingAnimation; - }; - result &= expect(!pendingAnimationAt(1099), - "pending feedback stays calm during the first second"); - result &= expect(pendingAnimationAt(1100), - "pending feedback starts at the one-second boundary"); - - addTurn(first, "turn-2"); - appendItem( - first, "turn-2", - item("user-new", {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "same"}}}}})); - prompts.reconcile(first.id, first); - result &= expect(prompts.submission(first.id, firstId)->state == - PromptState::InFlight && - !prompts.submission(first.id, firstId)->materializedItem, - "events and elapsed time cannot manufacture an ack"); - - result &= expect(prompts.acknowledge(first.id, firstId, "turn-2"), - "the matching completion acknowledges the in-flight prompt"); - prompts.reconcile(first.id, first); - const PromptSubmission *accepted = prompts.submission(first.id, firstId); - result &= expect(!accepted, - "a correlated acknowledgement promotes a materialized " - "prompt immediately"); - - auto materializedItems = indexAuthoritativeItems(first.id, &first); - prompts.reconcile(first.id, materializedItems); - const ConversationSnapshot materialized = ConversationProjection::project( - materializedItems, &first, prompts.submissions(first.id), 80, 200); - const VisibleCardData *authoritative = - cardForSubmission(materialized, firstId); - result &= - expect(authoritative && authoritative->kind == CardKind::UserMessage && - authoritative->itemId == "user-new", - "the authoritative user item assumes the local stable key"); - result &= expect(authoritative && - stableKey(authoritative->key) == - stableKey(LocalPromptKey{firstId}), - "materialization does not change the visual identity"); - auto compactedItems = indexAuthoritativeItems(first.id, &first); - prompts.reconcile(first.id, compactedItems); - accepted = prompts.submission(first.id, firstId); - const ConversationSnapshot compacted = ConversationProjection::project( - compactedItems, &first, prompts.submissions(first.id), 80, 701); - result &= expect( - !accepted && prompts.submissions(first.id).empty() && - compacted.find(LocalPromptKey{firstId}) && - compacted.find(LocalPromptKey{firstId})->kind == - CardKind::UserMessage && - !compacted.find(AuthoritativeItemKey{first.id, "turn-2", "user-new"}), - "submission cleanup retains the compact local visual identity alias"); - auto retainedAliasItems = indexAuthoritativeItems(first.id, &first); - prompts.reconcile(first.id, retainedAliasItems); - const ConversationSnapshot retainedAlias = ConversationProjection::project( - retainedAliasItems, &first, prompts.submissions(first.id), 80, 702); - result &= - expect(retainedAlias.find(LocalPromptKey{firstId}) && - retainedAlias.find(LocalPromptKey{firstId})->kind == - CardKind::UserMessage, - "a later projection reapplies the retained visual identity alias"); - return result; -} - -bool testDispatchChoiceAndPreHydrationTail() { - ThreadPresentation thread = baseThread("thread-dispatch"); - PromptCoordinator prompts; - const auto id = prompts.admit(thread.id, "queued while active", {}, - nlohmann::json::object(), &thread, - std::string("turn-1"), 300); - const auto dispatch = prompts.beginNext(thread.id, std::nullopt); - bool result = - expect(dispatch && dispatch->id == id && !dispatch->expectedTurnId, - "dispatch-time state replaces a stale admission turn"); - - PromptCoordinator beforeHydration; - const auto tailId = beforeHydration.admit( - "thread-tail", "after retained history", {}, nlohmann::json::object(), - nullptr, std::nullopt, 400); - ThreadPresentation retained = baseThread("thread-tail"); - beforeHydration.reconcile(retained.id, retained); - const ConversationSnapshot atTail = ConversationProjection::project( - retained, beforeHydration.submissions(retained.id), 80, 401); - const auto keys = atTail.cardKeys(); - result &= - expect(keys.size() == 3 && keys.back() == CardKey{LocalPromptKey{tailId}}, - "a pre-hydration prompt stays after retained history"); - - PromptCoordinator recovering; - ThreadPresentation empty; - empty.id = "thread-recovering"; - const auto recoveringId = - recovering.admit(empty.id, "retry after resume", {}, - nlohmann::json::object(), &empty, std::nullopt, 500); - result &= expect(recovering.beginNext(empty.id).has_value() && - recovering.requeue(empty.id, recoveringId), - "an empty-thread dispatch can return to hydration"); - ThreadPresentation recovered = baseThread(empty.id); - const auto awaitingHydration = ConversationProjection::project( - recovered, recovering.submissions(empty.id), 80, 501); - result &= expect( - awaitingHydration.cardKeys().back() == - CardKey{LocalPromptKey{recoveringId}}, - "a requeued prompt returns to the unresolved retained-history tail"); - return result; -} - -bool testClientIdentityBindsBeforeAcknowledgement() { - ThreadPresentation thread = baseThread("thread-client-id"); - PromptCoordinator prompts; - const auto id = - prompts.admit(thread.id, "identity matched", {}, nlohmann::json::object(), - &thread, std::nullopt, 500); - const auto dispatch = prompts.beginNext(thread.id); - bool result = expect(dispatch && !dispatch->clientUserMessageId.empty(), - "every dispatch carries a stable client message id"); - if (!dispatch) - return false; - - addTurn(thread, "turn-client"); - appendItem( - thread, "turn-client", - item("user-client", - {{"type", "userMessage"}, - {"clientId", dispatch->clientUserMessageId}, - {"content", {{{"type", "text"}, {"text", "identity matched"}}}}})); - prompts.reconcile(thread.id, thread); - const PromptSubmission *pending = prompts.submission(thread.id, id); - result &= expect(pending && pending->state == PromptState::InFlight && - pending->materializedItem && - pending->materializedItem->itemId == "user-client", - "client identity binds without manufacturing an ack"); - const ConversationSnapshot snapshot = ConversationProjection::project( - thread, prompts.submissions(thread.id), 80, 501); - result &= expect( - snapshot.cardKeys().size() == 3 && snapshot.find(LocalPromptKey{id}) && - snapshot.find(LocalPromptKey{id})->kind == CardKind::LocalPrompt, - "early materialization keeps one awaiting visual card"); - result &= expect(prompts.fail(thread.id, id, "rejected"), - "the exact terminal callback can fail a bound prompt"); - const ConversationSnapshot failed = ConversationProjection::project( - thread, prompts.submissions(thread.id), 80, 502); - const VisibleCardData *failedCard = failed.find(LocalPromptKey{id}); - const auto *failedPrompt = - failedCard ? std::get_if(&failedCard->payload) : nullptr; - result &= expect(failed.cardKeys().size() == 3 && failedPrompt && - failedPrompt->state == PromptState::Failed && - failedPrompt->error == "rejected", - "a failure remains explicit after early materialization"); - return result; -} - -bool testFirstResponseOrderIsAdmissionStable() { - ThreadPresentation reasoningFirst; - reasoningFirst.id = "thread-reasoning-first"; - PromptCoordinator prompts; - const auto promptId = prompts.admit(reasoningFirst.id, "new prompt", {}, - nlohmann::json::object(), &reasoningFirst, - std::nullopt, 600); - const auto dispatch = prompts.beginNext(reasoningFirst.id); - bool result = - expect(dispatch.has_value(), "an empty-thread prompt begins dispatch"); - if (!dispatch) - return false; - - addTurn(reasoningFirst, "turn-new"); - appendItem(reasoningFirst, "turn-new", - item("reasoning", {{"type", "reasoning"}, - {"summary", nlohmann::json::array()}})); - prompts.reconcile(reasoningFirst.id, reasoningFirst); - const AuthoritativeItemKey reasoningKey{reasoningFirst.id, "turn-new", - "reasoning"}; - const auto beforeUser = ConversationProjection::project( - reasoningFirst, prompts.submissions(reasoningFirst.id), 80, 601); - result &= - expect(beforeUser.cardKeys() == - std::vector{LocalPromptKey{promptId}, reasoningKey}, - "reasoning arriving first remains after its admitted prompt"); - - appendItem(reasoningFirst, "turn-new", - item("user-new", - {{"type", "userMessage"}, - {"clientId", dispatch->clientUserMessageId}, - {"content", {{{"type", "text"}, {"text", "new prompt"}}}}})); - prompts.reconcile(reasoningFirst.id, reasoningFirst); - const auto materialized = ConversationProjection::project( - reasoningFirst, prompts.submissions(reasoningFirst.id), 80, 602); - result &= - expect(materialized.cardKeys() == - std::vector{LocalPromptKey{promptId}, reasoningKey}, - "early user-message materialization cannot invert the cards"); - - result &= expect(prompts.acknowledge(reasoningFirst.id, promptId, - std::string("turn-new")), - "the reasoning-first prompt is acknowledged"); - prompts.reconcile(reasoningFirst.id, reasoningFirst); - auto promotedItems = - indexAuthoritativeItems(reasoningFirst.id, &reasoningFirst); - prompts.reconcile(reasoningFirst.id, promotedItems); - const auto transitioning = ConversationProjection::project( - promotedItems, &reasoningFirst, prompts.submissions(reasoningFirst.id), - 80, 700); - result &= - expect(transitioning.cardKeys() == - std::vector{LocalPromptKey{promptId}, reasoningKey}, - "immediate promotion retains prompt order"); - - auto compactedItems = - indexAuthoritativeItems(reasoningFirst.id, &reasoningFirst); - prompts.reconcile(reasoningFirst.id, compactedItems); - const auto compacted = ConversationProjection::project( - compactedItems, &reasoningFirst, prompts.submissions(reasoningFirst.id), - 80, 1200); - const VisibleCardData *bluePrompt = compacted.find(LocalPromptKey{promptId}); - result &= expect( - compacted.cardKeys() == - std::vector{LocalPromptKey{promptId}, reasoningKey} && - bluePrompt && bluePrompt->kind == CardKind::UserMessage, - "the compact blue card retains the original admission boundary"); - - ThreadPresentation continued = baseThread("thread-continued"); - PromptCoordinator continuedPrompts; - const auto continuedId = continuedPrompts.admit( - continued.id, "continued prompt", {}, nlohmann::json::object(), - &continued, std::nullopt, 750); - const auto continuedDispatch = continuedPrompts.beginNext(continued.id); - result &= expect(continuedDispatch.has_value(), - "a continued-thread prompt begins dispatch"); - if (!continuedDispatch) - return false; - addTurn(continued, "turn-continued"); - appendItem( - continued, "turn-continued", - item("reasoning-continued", - {{"type", "reasoning"}, {"summary", nlohmann::json::array()}})); - appendItem( - continued, "turn-continued", - item("user-continued", - {{"type", "userMessage"}, - {"clientId", continuedDispatch->clientUserMessageId}, - {"content", {{{"type", "text"}, {"text", "continued prompt"}}}}})); - continuedPrompts.reconcile(continued.id, continued); - result &= - expect(continuedPrompts.acknowledge(continued.id, continuedId, - std::string("turn-continued")), - "the continued-thread prompt is acknowledged"); - auto continuedItems = indexAuthoritativeItems(continued.id, &continued); - continuedPrompts.reconcile(continued.id, continuedItems); - const auto continuedCompacted = ConversationProjection::project( - continuedItems, &continued, continuedPrompts.submissions(continued.id), - 80, 1300); - const auto continuedKeys = continuedCompacted.cardKeys(); - const auto continuedPrompt = - std::ranges::find(continuedKeys, CardKey{LocalPromptKey{continuedId}}); - const auto continuedReasoning = std::ranges::find( - continuedKeys, - CardKey{AuthoritativeItemKey{continued.id, "turn-continued", - "reasoning-continued"}}); - result &= expect( - continuedPrompt != continuedKeys.end() && - continuedReasoning != continuedKeys.end() && - continuedPrompt < continuedReasoning, - "a continued-thread blue card cannot move below earlier reasoning"); - - ThreadPresentation userFirst; - userFirst.id = "thread-user-first"; - PromptCoordinator ordinaryPrompts; - const auto ordinaryId = ordinaryPrompts.admit(userFirst.id, "ordinary prompt", - {}, nlohmann::json::object(), - &userFirst, std::nullopt, 800); - const auto ordinaryDispatch = ordinaryPrompts.beginNext(userFirst.id); - result &= expect(ordinaryDispatch.has_value(), - "the user-first prompt begins dispatch"); - if (!ordinaryDispatch) - return false; - addTurn(userFirst, "turn-ordinary"); - appendItem( - userFirst, "turn-ordinary", - item("user-ordinary", - {{"type", "userMessage"}, - {"clientId", ordinaryDispatch->clientUserMessageId}, - {"content", {{{"type", "text"}, {"text", "ordinary prompt"}}}}})); - appendItem( - userFirst, "turn-ordinary", - item("reasoning-ordinary", - {{"type", "reasoning"}, {"summary", nlohmann::json::array()}})); - ordinaryPrompts.reconcile(userFirst.id, userFirst); - const auto userBeforeReasoning = ConversationProjection::project( - userFirst, ordinaryPrompts.submissions(userFirst.id), 80, 801); - result &= expect(userBeforeReasoning.cardKeys() == - std::vector{ - LocalPromptKey{ordinaryId}, - AuthoritativeItemKey{userFirst.id, "turn-ordinary", - "reasoning-ordinary"}}, - "the ordinary user-first event order remains unchanged"); - return result; -} - -bool testAnchoredDuplicatePrompts() { - ThreadPresentation thread = baseThread("thread-duplicates"); - PromptCoordinator prompts; - const auto firstId = - prompts.admit(thread.id, "repeat", {}, nlohmann::json::object(), &thread, - std::nullopt, 1000); - const auto secondId = - prompts.admit(thread.id, "repeat", {}, nlohmann::json::object(), &thread, - std::nullopt, 1001); - - bool result = expect(prompts.beginNext(thread.id).has_value(), - "first duplicate dispatches"); - result &= expect(prompts.acknowledge(thread.id, firstId, "turn-2"), - "first duplicate is acknowledged by id"); - result &= expect(prompts.beginNext(thread.id, "turn-2").has_value(), - "second duplicate dispatches only after first ack"); - result &= expect(prompts.acknowledge(thread.id, secondId, "turn-2"), - "second duplicate is acknowledged by id"); - - addTurn(thread, "turn-2"); - appendItem(thread, "turn-2", - item("repeat-1", - {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); - prompts.reconcile(thread.id, thread); - auto partialItems = indexAuthoritativeItems(thread.id, &thread); - prompts.reconcile(thread.id, partialItems); - const ConversationSnapshot partiallyMaterialized = - ConversationProjection::project(partialItems, &thread, - prompts.submissions(thread.id), 80, 1600); - const auto partialKeys = partiallyMaterialized.cardKeys(); - const auto materializedFirst = - std::ranges::find(partialKeys, CardKey{LocalPromptKey{firstId}}); - const auto waitingSecond = - std::ranges::find(partialKeys, CardKey{LocalPromptKey{secondId}}); - result &= expect(materializedFirst != partialKeys.end() && - waitingSecond != partialKeys.end() && - materializedFirst < waitingSecond, - "partial materialization cannot invert prompt order"); - - appendItem(thread, "turn-2", - item("repeat-2", - {{"type", "userMessage"}, - {"content", {{{"type", "text"}, {"text", "repeat"}}}}})); - prompts.reconcile(thread.id, thread); - const PromptSubmission *first = prompts.submission(thread.id, firstId); - const PromptSubmission *second = prompts.submission(thread.id, secondId); - result &= expect(!first && !second, - "identical acknowledged prompts bind and promote without " - "collision"); - - auto waitingItems = indexAuthoritativeItems(thread.id, &thread); - prompts.reconcile(thread.id, waitingItems); - const ConversationSnapshot waiting = ConversationProjection::project( - waitingItems, &thread, prompts.submissions(thread.id), 80, 1021); - const auto keys = waiting.cardKeys(); - const auto firstPosition = - std::ranges::find(keys, CardKey{LocalPromptKey{firstId}}); - const auto secondPosition = - std::ranges::find(keys, CardKey{LocalPromptKey{secondId}}); - result &= - expect(firstPosition != keys.end() && secondPosition != keys.end() && - firstPosition < secondPosition, - "same-anchor local prompts retain admission order"); - result &= expect( - waiting.sections.size() == 2 && waiting.sections[1].turnId == "turn-2" && - waiting.sections[1].cards.size() == 2 && - waiting.sections[1].rootCardKey == CardKey{LocalPromptKey{firstId}}, - "acknowledged duplicates share one turn while steering stays nested"); - - PromptCoordinator moved; - const auto draftId = moved.admit("", "draft", {}, nlohmann::json::object(), - nullptr, std::nullopt, 1); - result &= expect(moved.reassignThread("", "assigned") && - moved.submission("assigned", draftId) && - stableKey(LocalPromptKey{draftId}) == - stableKey(CardKey{LocalPromptKey{draftId}}), - "new-thread assignment preserves the local prompt key"); - return result; -} - -bool testCommandOutputVisibility() { - bool result = - expect(!terminalOutputHasVisibleText({}), "empty output is not visible"); - result &= expect(!terminalOutputHasVisibleText(" \n\t"), - "whitespace output is not visible"); - result &= expect(!terminalOutputHasVisibleText("\x1b[0m\x1b]0;title\x07"), - "ANSI and control output is not visible"); - result &= expect(terminalOutputHasVisibleText("done\n"), - "printable command output is visible"); - result &= expect(trimTrailingEmptyLines("first\nsecond\n\n \t\r\n") == - "first\nsecond", - "trailing empty terminal lines are removed"); - result &= expect(trimTrailingEmptyLines(" meaningful spacing ") == - " meaningful spacing ", - "spacing on a non-empty final line is retained"); - result &= expect(trimTrailingEmptyLines(" \t\r\n").empty(), - "an entirely empty-line display normalizes to zero lines"); - return result; -} - -bool testUserMessageImages() { - ThreadPresentation thread = baseThread("image-thread"); - appendItem(thread, "turn-1", - item("user-images", - {{"type", "userMessage"}, - {"content", - {{{"type", "text"}, {"text", "image prompt"}}, - {{"type", "localImage"}, {"path", "/tmp/first.png"}}, - {{"type", "localImage"}, {"path", "/tmp/second.jpg"}}}}})); - appendItem(thread, "turn-1", - item("user-image-only", - {{"type", "userMessage"}, - {"content", - {{{"type", "localImage"}, {"path", "/tmp/only.png"}}}}})); - - const ConversationSnapshot authoritative = ConversationProjection::project( - thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const auto &cards = authoritative.sections.front().cards; - const auto *mixed = std::get_if(&cards[2].payload); - const auto *imageOnly = std::get_if(&cards[3].payload); - bool result = expect( - mixed && mixed->text == "image prompt" && - mixed->imagePaths == - std::vector{"/tmp/first.png", "/tmp/second.jpg"}, - "authoritative user messages retain text and local image paths"); - result &= expect(imageOnly && imageOnly->text.empty() && - imageOnly->imagePaths == - std::vector{"/tmp/only.png"}, - "an image-only user message remains presentable"); - - PromptSubmission pending; - pending.id = 41; - pending.threadId = thread.id; - pending.prompt = "pending image"; - pending.state = PromptState::InFlight; - pending.attachments = {{"/tmp/pending.png", "pending.png", "image/png", 10}, - {"/tmp/note.txt", "note.txt", "text/plain", 10}}; - const std::array submissions{pending}; - const ConversationSnapshot local = ConversationProjection::project( - thread, submissions, - ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const auto *localCard = local.find(LocalPromptKey{41}); - const auto *localPrompt = - localCard ? std::get_if(&localCard->payload) : nullptr; - result &= - expect(localPrompt && localPrompt->imagePaths == - std::vector{"/tmp/pending.png"}, - "temporary prompts expose only their image attachment paths"); - - ThreadPresentation replacement = baseThread("replacement-thread"); - addTurn(replacement, "turn-image"); - PromptCoordinator prompts; - const auto submissionId = prompts.admit( - replacement.id, "replacement image", - {{"/tmp/replacement.png", "replacement.png", "image/png", 10}}, - nlohmann::json::object(), &replacement, std::nullopt, 100); - const auto dispatch = prompts.beginNext(replacement.id); - result &= - expect(dispatch && prompts.acknowledge(replacement.id, submissionId, - std::string("turn-image")), - "image prompt receives a real acknowledgement"); - appendItem( - replacement, "turn-image", - item("authoritative-image", - {{"type", "userMessage"}, - {"clientId", dispatch ? dispatch->clientUserMessageId : ""}, - {"content", - {{{"type", "text"}, {"text", "replacement image"}}, - {{"type", "localImage"}, {"path", "/tmp/replacement.png"}}}}})); - prompts.reconcile(replacement.id, replacement); - const ConversationSnapshot replaced = ConversationProjection::project( - replacement, prompts.submissions(replacement.id), 80, 800); - const VisibleCardData *replacedCard = replaced.find(AuthoritativeItemKey{ - replacement.id, "turn-image", "authoritative-image"}); - const auto *replacedMessage = - replacedCard ? std::get_if(&replacedCard->payload) - : nullptr; - result &= expect( - replacedCard && replacedCard->kind == CardKind::UserMessage && - replacedMessage && - replacedMessage->imagePaths == - std::vector{"/tmp/replacement.png"} && - !prompts.submission(replacement.id, submissionId), - "authoritative image presentation survives local payload compaction"); - return result; -} - -bool testGeneratedImageProjection() { - ThreadPresentation thread = baseThread("generated-image-thread"); - appendItem(thread, "turn-1", - item("generated-image", - {{"type", "imageGeneration"}, - {"status", "completed"}, - {"savedPath", "/tmp/generated.png"}, - {"revisedPrompt", "A restrained CodexUI color proposal"}, - {"result", std::string(100000, 'A')}})); - appendItem( - thread, "turn-1", - item("image-view", {{"type", "imageView"}, {"path", "/tmp/review.png"}})); - - const ConversationSnapshot snapshot = ConversationProjection::project( - thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const VisibleCardData *card = snapshot.find( - AuthoritativeItemKey{thread.id, "turn-1", "generated-image"}); - const auto *image = - card ? std::get_if(&card->payload) : nullptr; - const VisibleCardData *viewCard = - snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "image-view"}); - const auto *viewImage = - viewCard ? std::get_if(&viewCard->payload) : nullptr; - bool result = expect( - card && card->kind == CardKind::ImageGeneration && image && - image->path == "/tmp/generated.png" && image->status == "completed" && - image->revisedPrompt == "A restrained CodexUI color proposal", - "generated images project their saved path without exposing base64"); - result &= - expect(viewCard && viewCard->kind == CardKind::ImageGeneration && - viewImage && viewImage->path == "/tmp/review.png" && - viewImage->status == "completed" && - viewImage->revisedPrompt.empty(), - "materialized image-view items expose their completed state"); - return result; -} - -bool testTruthfulActivityProjection() { - ThreadPresentation thread = baseThread("activity-thread"); - TurnPresentation &turn = thread.turns.at("turn-1"); - turn.plan = { - {"explanation", "Keep the conversation chronology compact"}, - {"steps", - nlohmann::json::array( - {{{"step", "Inspect protocol data"}, {"status", "completed"}}, - {{"step", "Render the cards"}, {"status", "inProgress"}}})}}; - appendItem(thread, "turn-1", - item("text-plan", {{"type", "plan"}, - {"text", "A textual plan-mode response"}})); - appendItem(thread, "turn-1", - item("empty-reasoning", {{"type", "reasoning"}, - {"summary", nlohmann::json::array()}})); - appendItem(thread, "turn-1", - item("command", {{"type", "commandExecution"}, - {"command", "true"}, - {"status", "completed"}, - {"durationMs", 2400}})); - appendItem( - thread, "turn-1", - item("files", - {{"type", "fileChange"}, - {"status", "completed"}, - {"changes", - nlohmann::json::array( - {{{"path", "src/card.cpp"}, - {"kind", "update"}, - {"diff", "--- a/src/card.cpp\n+++ b/src/card.cpp\n-old\n" - "+new\n++++literal\n+extra\n"}}, - {{"path", "tests/card.cpp"}, - {"kind", "add"}, - {"diff", - "--- /dev/null\n+++ b/tests/card.cpp\n+test\n"}}})}})); - appendItem(thread, "turn-1", - item("agent", {{"type", "subAgentActivity"}, - {"status", "inProgress"}, - {"agentThreadId", "child-thread"}, - {"model", "gpt-current"}, - {"reasoningEffort", "medium"}, - {"senderThreadId", "activity-thread"}})); - - const ConversationSnapshot snapshot = ConversationProjection::project( - thread, {}, ConversationProjection::DefaultAuthoritativeItemLimit, 10); - const VisibleCardData *structured = - snapshot.find(TurnPlanKey{thread.id, "turn-1"}); - const VisibleCardData *textual = - snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "text-plan"}); - const VisibleCardData *reasoning = snapshot.find( - AuthoritativeItemKey{thread.id, "turn-1", "empty-reasoning"}); - const VisibleCardData *command = - snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "command"}); - const VisibleCardData *files = - snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "files"}); - const VisibleCardData *agent = - snapshot.find(AuthoritativeItemKey{thread.id, "turn-1", "agent"}); - const auto *textPlan = - textual ? std::get_if(&textual->payload) : nullptr; - const auto *execution = - command ? std::get_if(&command->payload) : nullptr; - const auto *fileData = - files ? std::get_if(&files->payload) : nullptr; - const auto *agentData = - agent ? std::get_if(&agent->payload) : nullptr; - - bool result = expect( - !structured, - "structured plan state remains Inspector-only in production projection"); - result &= - expect(textPlan && textPlan->legacyText == "A textual plan-mode response", - "textual plan items remain supported conversation content"); - const auto *reasoningData = - reasoning ? std::get_if(&reasoning->payload) : nullptr; - result &= expect( - reasoningData && reasoningData->summary.empty(), - "reasoning remains a stable progress card without a public summary"); - result &= expect(execution && execution->durationMilliseconds == 2400, - "command duration is retained when supplied"); - result &= expect( - fileData && fileData->changes.size() == 2 && - fileData->changes[0].additions == 3 && - fileData->changes[0].deletions == 1 && - fileData->changes[1].additions == 1 && - fileData->changes[1].deletions == 0, - "file-change rows and unified-diff counts are projected truthfully"); - result &= - expect(agentData && agentData->childThreadId == "child-thread" && - agentData->model == "gpt-current" && - agentData->reasoningEffort == "medium" && - agentData->senderThreadId == "activity-thread", - "available agent identity and execution settings are retained"); - return result; -} - -bool testFileLinksArePartOfTheCanonicalPrompt() { - const std::vector attachments{ - {"/tmp/review notes [final] (2).pdf", "review notes [final] (2).pdf", - "application/pdf", 10}, - {"/tmp/image.png", "image.png", "image/png", 10}, - {"/tmp/audio.ogg", "audio.ogg", "audio/ogg", 10}}; - const std::string composed = promptWithFileLinks("Review this", attachments); - const std::string expected = - "Review this\n\nAttached files:\n" - "- [review notes \\[final\\] (2).pdf]" - "(file:///tmp/review%20notes%20%5Bfinal%5D%20%282%29.pdf)"; - bool result = expect(composed == expected, - "ordinary files become escaped durable Markdown links"); - - PromptCoordinator prompts; - const auto id = - prompts.admit("thread-files", composed, attachments, - nlohmann::json::object(), nullptr, std::nullopt, 100); - const auto dispatch = prompts.beginNext("thread-files"); - result &= - expect(dispatch && dispatch->id == id && dispatch->prompt == composed, - "temporary presentation and transport share one prompt"); - return result; -} - -} // namespace -} // namespace codexui::codex::middle - -int main() { - using namespace codexui::codex::middle; - bool result = testCanonicalGroupingAndProjection(); - result &= testStreamTruncationIsVisible(); - result &= testTurnRootSurvivesHistoryPaging(); - result &= testQueueIsolationAndRealAcknowledgement(); - result &= testDispatchChoiceAndPreHydrationTail(); - result &= testClientIdentityBindsBeforeAcknowledgement(); - result &= testFirstResponseOrderIsAdmissionStable(); - result &= testAnchoredDuplicatePrompts(); - result &= testCommandOutputVisibility(); - result &= testUserMessageImages(); - result &= testGeneratedImageProjection(); - result &= testTruthfulActivityProjection(); - result &= testFileLinksArePartOfTheCanonicalPrompt(); - if (result) - std::cout << "Conversation projection tests passed\n"; - return result ? 0 : 1; -} diff --git a/tests/codex/EstablishedUiUxTest.cpp b/tests/codex/EstablishedUiUxTest.cpp new file mode 100644 index 0000000..8555e92 --- /dev/null +++ b/tests/codex/EstablishedUiUxTest.cpp @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/MiddleRegionWidget.h" +#include "codex/middle/ThreadPane.h" +#include "codex/ui/ExpandingPromptEditor.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace codexui::codex::middle { +namespace { + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +void sendKey(codexui::ExpandingPromptEditor &editor, int key, + Qt::KeyboardModifiers modifiers = Qt::NoModifier) { + QKeyEvent event(QEvent::KeyPress, key, modifiers); + QCoreApplication::sendEvent(&editor, &event); +} + +bool promptKeyboardAndFocusContract() { + codexui::ExpandingPromptEditor editor; + editor.resize(480, 80); + editor.show(); + editor.setFocus(); + QCoreApplication::processEvents(); + int submissions = 0; + QObject::connect(&editor, &codexui::ExpandingPromptEditor::submitRequested, + [&] { ++submissions; }); + editor.setPlainText(QStringLiteral("prompt")); + editor.moveCursor(QTextCursor::End); + sendKey(editor, Qt::Key_Return); + bool result = expect(submissions == 1 && editor.hasFocus(), + "Return submits without losing prompt focus"); + editor.setPlainText(QStringLiteral("prompt")); + editor.moveCursor(QTextCursor::End); + sendKey(editor, Qt::Key_Return, Qt::ShiftModifier); + result &= expect(submissions == 1 && + editor.toPlainText() == QStringLiteral("prompt\n"), + "Shift+Return inserts a newline without submission"); + result &= expect(editor.accessibleName() == QStringLiteral("Message Codex"), + "the prompt retains its accessible identity"); + return result; +} + +ui::ThreadListRow row(std::string id, std::string title, + std::string status = {}) { + ui::ThreadListRow result; + result.id = std::move(id); + result.title = std::move(title); + result.status = std::move(status); + result.cwd = "/workspace"; + return result; +} + +bool threadPaneSnapshotAndActionContract() { + ThreadPane pane; + pane.resize(320, 520); + ui::ThreadListSnapshot snapshot; + snapshot.selectedThreadId = "child"; + snapshot.providerReady = true; + snapshot.canControl = true; + ui::ThreadListRow root = row("root", "Root", "running"); + root.children.push_back(row("child", "Child", "completed")); + snapshot.roots.push_back(std::move(root)); + + std::string selected; + ThreadPane::Actions actions; + actions.select = [&](const std::string &id) { selected = id; }; + pane.setActions(std::move(actions)); + pane.refresh(snapshot); + pane.show(); + QCoreApplication::processEvents(); + auto *list = pane.findChild(QStringLiteral("threadList")); + bool result = expect(list && list->count() == 2, + "a selected child retains its root hierarchy"); + if (list) { + selected.clear(); + list->setCurrentRow(0); + list->setCurrentRow(1); + QCoreApplication::processEvents(); + result &= expect(selected == "child", + "the established action API emits the canonical ID"); + } + + pane.beginOptimisticThread("draft:new-thread", "New thread", "/workspace"); + pane.refresh(snapshot); + QCoreApplication::processEvents(); + result &= expect(list && list->count() == 3, + "the optimistic row appears through normal refresh"); + pane.confirmOptimisticThread("draft:new-thread"); + pane.refresh(snapshot); + QCoreApplication::processEvents(); + result &= expect(list && list->count() == 2, + "confirming the draft removes only its optimistic row"); + return result; +} + +VisibleCardData card(CardKind kind, std::string item, + CardPayload payload) { + VisibleCardData result; + result.key = AuthoritativeItemKey{"thread", "turn", item}; + result.kind = kind; + result.threadId = "thread"; + result.turnId = "turn"; + result.itemId = std::move(item); + result.payload = std::move(payload); + return result; +} + +bool conversationOwnershipAndAtomicReconcileContract() { + ConversationView view; + view.resize(820, 620); + view.show(); + ConversationSnapshot snapshot; + snapshot.threadId = "thread"; + TurnSection section; + section.key = "turn"; + section.turnId = "turn"; + section.cards.push_back( + card(CardKind::UserMessage, "user", UserMessageData{"Question", {}})); + section.rootCardKey = section.cards.front().key; + section.cards.push_back(card(CardKind::AgentMessage, "agent", + AgentMessageData{"Answer", true})); + snapshot.sections.push_back(std::move(section)); + const bool changed = view.reconcile(snapshot); + QCoreApplication::processEvents(); + + ConversationCard *owner = nullptr; + ConversationCard *answer = nullptr; + for (ConversationCard *candidate : view.findChildren()) { + if (candidate->property("turnContainer").toBool()) + owner = candidate; + if (const auto *agent = + std::get_if(&candidate->data().payload); + agent && agent->text == "Answer") + answer = candidate; + } + bool nested = false; + for (QWidget *parent = answer ? answer->parentWidget() : nullptr; parent; + parent = parent->parentWidget()) + if (parent == owner) { + nested = true; + break; + } + bool result = expect(changed && owner && answer && nested, + "one reconcile exposes a complete parented turn"); + const qulonglong presentationPasses = + view.property("graphRefreshPasses").toULongLong(); + result &= expect(!view.reconcile(snapshot) && + view.property("graphRefreshPasses").toULongLong() == + presentationPasses, + "repeating identical visible state performs no Qt " + "presentation pass"); + return result; +} + +bool completeMiddleSurfaceRetainsPaneAndHeadingBehavior() { + MiddleRegionWidget region; + region.resize(1500, 850); + region.show(); + region.setThreadHeading(QStringLiteral("Thread title"), + QStringLiteral("/workspace"), + QStringLiteral("Last activity: 12:00"), + QStringLiteral("running"), + QStringLiteral("active")); + QCoreApplication::processEvents(); + bool result = expect(region.sidebarVisible() && region.inspectorVisible(), + "the complete three-pane workspace starts visible"); + result &= expect(region.findChild( + QStringLiteral("conversationTitle")) != nullptr, + "the established conversation heading remains present"); + region.showSidebar(false); + region.showInspector(false); + QCoreApplication::processEvents(); + result &= expect(!region.sidebarVisible() && !region.inspectorVisible(), + "pane visibility remains user-controlled"); + region.showSidebar(true); + region.showInspector(true); + QCoreApplication::processEvents(); + result &= expect(region.sidebarVisible() && region.inspectorVisible(), + "hidden panes restore without reconstructing the shell"); + return result; +} + +} // namespace +} // namespace codexui::codex::middle + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex::middle; + const bool passed = promptKeyboardAndFocusContract() && + threadPaneSnapshotAndActionContract() && + conversationOwnershipAndAtomicReconcileContract() && + completeMiddleSurfaceRetainsPaneAndHeadingBehavior(); + if (passed) + std::cout << "Established UI/UX compatibility tests passed\n"; + return passed ? 0 : 1; +} diff --git a/tests/codex/NodeGraphConversationUiTest.cpp b/tests/codex/NodeGraphConversationUiTest.cpp new file mode 100644 index 0000000..dbf4a57 --- /dev/null +++ b/tests/codex/NodeGraphConversationUiTest.cpp @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" +#include "codex/ui/NodeGraphUiAdapter.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace codexui::codex { +namespace { + +using nodegraph::NodeKind; +using nodegraph::NodeRef; +using nodegraph::NodeState; + +bool require(bool condition, const char *message) { + if (condition) + return true; + std::cerr << message << '\n'; + return false; +} + +NodeState state(std::string id, std::string type = {}, std::string text = {}) { + NodeState value; + value.fields.emplace("id", std::move(id)); + if (!type.empty()) + value.fields.emplace("type", std::move(type)); + if (!text.empty()) + value.fields.emplace("text", std::move(text)); + return value; +} + +struct Fixture { + nodegraph::NodeGraph graph; + NodeRef thread; + int turnSerial = 0; + + Fixture() { + auto write = graph.write(); + thread = write.upsert({NodeKind::Thread, "thread-ui"}, + state("thread-ui")); + static_cast(write.finish()); + } + + void appendTurn(std::string answerText) { + const int serial = turnSerial++; + const std::string turnId = "turn-" + std::to_string(serial); + const std::string promptId = "prompt-" + std::to_string(serial); + const std::string answerId = "answer-" + std::to_string(serial); + auto write = graph.write(); + NodeRef turn = + write.upsert({NodeKind::Turn, turnId}, state(turnId)); + NodeRef prompt = write.upsert( + {NodeKind::Item, promptId}, + state(promptId, "userMessage", "prompt " + std::to_string(serial))); + NodeRef answer = write.upsert( + {NodeKind::Item, answerId}, + state(answerId, "agentMessage", std::move(answerText))); + write.setParent(thread, turn); + write.setParent(turn, prompt); + write.setParent(turn, answer); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, prompt); + static_cast(write.finish()); + } +}; + +middle::ConversationCard *firstPaintedCard(middle::ConversationView &view) { + middle::ConversationCard *result = nullptr; + int best = std::numeric_limits::max(); + for (middle::ConversationCard *card : + view.findChildren()) { + const QPoint top = card->mapTo(view.viewport(), QPoint{}); + if (top.y() + card->height() <= 0 || top.y() >= view.viewport()->height()) + continue; + if (top.y() < best) { + best = top.y(); + result = card; + } + } + return result; +} + +bool oldUiConsumesAdapterSnapshotsAtomically() { + Fixture fixture; + for (int index = 0; index < 24; ++index) + fixture.appendTurn("answer " + std::to_string(index)); + + ui::NodeGraphUiAdapter adapter(fixture.graph); + middle::ConversationView view; + view.resize(760, 560); + view.show(); + QApplication::processEvents(); + + const auto initial = + adapter.conversation(fixture.thread, 80, {true, true}); + if (!require(initial.has_value(), "initial adapter read failed") || + !require(view.reconcile(*initial), "initial UI reconciliation was empty")) + return false; + QApplication::processEvents(); + + const auto cards = view.findChildren(); + if (!require(cards.size() == 48, + "selected history was not materialized in one reconciliation") || + !require(view.findChildren( + QStringLiteral("conversationCardPlaceholder")) + .empty(), + "old UI unexpectedly retained graph placeholders") || + !require(view.verticalScrollBar()->value() == + view.verticalScrollBar()->maximum(), + "initial following position is not the final bottom")) + return false; + + int owners = 0; + for (middle::ConversationCard *card : cards) + if (card->property("turnContainer").toBool()) + ++owners; + if (!require(owners == 24, "not every turn has exactly one owning card")) + return false; + + fixture.appendTurn("new following answer"); + const auto appended = + adapter.conversation(fixture.thread, 80, {true, true}); + if (!require(appended.has_value(), "appended adapter read failed") || + !require(view.reconcile(*appended), "new cards were not presented")) + return false; + QApplication::processEvents(); + return require(view.findChildren().size() == 50, + "new cards failed to appear immediately") && + require(view.verticalScrollBar()->value() == + view.verticalScrollBar()->maximum(), + "following update did not settle at its final bottom"); +} + +bool pausedViewportKeepsItsPaintedAnchor() { + Fixture fixture; + for (int index = 0; index < 30; ++index) + fixture.appendTurn(std::string(180, static_cast('a' + index % 20))); + ui::NodeGraphUiAdapter adapter(fixture.graph); + middle::ConversationView view; + view.resize(760, 520); + view.show(); + const auto initial = + adapter.conversation(fixture.thread, 80, {true, true}); + if (!initial || !view.reconcile(*initial)) + return false; + QApplication::processEvents(); + + view.verticalScrollBar()->setValue(view.verticalScrollBar()->maximum() / 2); + view.verticalScrollBar()->triggerAction(QAbstractSlider::SliderSingleStepSub); + QApplication::processEvents(); + middle::ConversationCard *anchor = firstPaintedCard(view); + if (!require(anchor != nullptr, "paused viewport has no painted anchor")) + return false; + const std::string key = + anchor->property("conversationAnchorKey").toString().toStdString(); + const int y = anchor->mapTo(view.viewport(), QPoint{}).y(); + + fixture.appendTurn("offscreen tail"); + const auto appended = + adapter.conversation(fixture.thread, 80, {true, true}); + if (!appended || !view.reconcile(*appended)) + return false; + QApplication::processEvents(); + + for (middle::ConversationCard *card : + view.findChildren()) { + if (card->property("conversationAnchorKey").toString().toStdString() != key) + continue; + return require(card->mapTo(view.viewport(), QPoint{}).y() == y, + "paused incoming tail moved the painted anchor"); + } + return require(false, "paused incoming tail replaced the anchor widget"); +} + +bool promptMorphPreservesExactTargetAndWidget() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef prompt; + { + auto write = graph.write(); + thread = write.upsert({NodeKind::Thread, "thread-prompt"}, + state("thread-prompt")); + turn = write.upsert({NodeKind::Turn, "turn-prompt"}, + state("turn-prompt")); + NodeState promptState = state("local-prompt", "localPrompt", "hello"); + promptState.fields.emplace("submissionId", std::uint64_t{41}); + promptState.fields.emplace("dispatchState", "inFlight"); + prompt = write.upsert({NodeKind::Item, "local-prompt"}, + std::move(promptState)); + write.setParent(thread, turn); + write.setParent(turn, prompt); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, prompt); + static_cast(write.finish()); + } + + ui::NodeGraphUiAdapter adapter(graph); + middle::ConversationView view; + view.resize(700, 480); + view.show(); + int acknowledgements = 0; + NodeRef acknowledged; + view.setPromptMaterializedAction( + [&](NodeRef target) { + ++acknowledgements; + acknowledged = std::move(target); + return true; + }); + auto snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot || !view.reconcile(*snapshot)) + return false; + QApplication::processEvents(); + const auto before = view.findChildren(); + if (!require(before.size() == 1, "local prompt did not render once") || + !require(acknowledgements == 0, + "local prompt acknowledged before authoritative identity")) + return false; + middle::ConversationCard *stable = before.front(); + + NodeRef authoritative; + { + auto write = graph.write(); + write.setField(prompt, "dispatchState", "awaitingMaterialization"); + authoritative = write.upsert( + {NodeKind::Item, "authoritative-prompt"}, + state("authoritative-prompt", "userMessage", "hello")); + write.setField(authoritative, "localSubmissionId", std::uint64_t{41}); + write.setParent(turn, authoritative); + write.relate(authoritative, + nodegraph::RelationKind::PromptMaterialization, prompt); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, authoritative); + static_cast(write.finish()); + } + snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot || !view.reconcile(*snapshot)) + return false; + QApplication::processEvents(); + const auto after = view.findChildren(); + if (!require(after.size() == 1, "prompt morph created a duplicate card") || + !require(after.front() == stable, "prompt morph replaced its widget") || + !require(acknowledgements == 1, + "prompt morph did not acknowledge exactly once") || + !require(acknowledged == prompt, + "prompt morph discarded its exact NodeRef target")) + return false; + + static_cast(view.reconcile(*snapshot)); + if (!require(acknowledgements == 1, + "unchanged prompt projection acknowledged twice")) + return false; + + const int promotedTop = stable->mapTo(view.viewport(), QPoint{}).y(); + { + auto write = graph.write(); + write.remove(prompt); + static_cast(write.finish()); + } + snapshot = adapter.conversation(thread, 80, {true, true}); + if (!require(snapshot.has_value(), + "local retirement did not project the authoritative card")) + return false; + static_cast(view.reconcile(*snapshot)); + QApplication::processEvents(); + const auto retired = view.findChildren(); + bool result = require(retired.size() == 1 && retired.front() == stable, + "local retirement replaced the promoted QWidget"); + result &= require(retired.size() == 1 && + retired.front()->data().target == authoritative, + "local retirement did not transfer the action target"); + result &= require(retired.size() == 1 && + retired.front() + ->mapTo(view.viewport(), QPoint{}) + .y() == promotedTop, + "local retirement moved the promoted card"); + result &= require(acknowledgements == 1, + "local retirement acknowledged the prompt again"); + return result; +} + +bool steeringMorphKeepsItsSlotThroughRetirement() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef root; + NodeRef steering; + NodeRef progress; + { + auto write = graph.write(); + thread = write.upsert({NodeKind::Thread, "thread-steering"}, + state("thread-steering")); + turn = write.upsert({NodeKind::Turn, "turn-steering"}, + state("turn-steering")); + root = write.upsert({NodeKind::Item, "root-steering"}, + state("root-steering", "userMessage", "Start")); + NodeState local = state("local-steering", "localPrompt", "Steer here"); + local.fields.emplace("submissionId", std::uint64_t{72}); + local.fields.emplace("dispatchState", "inFlight"); + local.fields.emplace("startsTurn", false); + steering = write.upsert({NodeKind::Item, "local-steering"}, + std::move(local)); + progress = write.upsert( + {NodeKind::Item, "later-progress"}, + state("later-progress", "agentMessage", "Later progress")); + write.setParent(thread, turn); + write.setParent(turn, root); + write.setParent(turn, steering); + write.setParent(turn, progress); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, root); + static_cast(write.finish()); + } + + ui::NodeGraphUiAdapter adapter(graph); + middle::ConversationView view; + view.resize(700, 520); + view.show(); + int acknowledgements = 0; + view.setPromptMaterializedAction([&](NodeRef target) { + ++acknowledgements; + return target == steering; + }); + auto snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot || !view.reconcile(*snapshot)) + return false; + QApplication::processEvents(); + const auto findCard = [&view](const std::string &key) { + for (middle::ConversationCard *card : + view.findChildren()) + if (card->property("conversationAnchorKey").toString().toStdString() == + key) + return card; + return static_cast(nullptr); + }; + middle::ConversationCard *stable = + findCard(middle::stableKey(middle::LocalPromptKey{72})); + middle::ConversationCard *progressCard = findCard(middle::stableKey( + middle::AuthoritativeItemKey{"thread-steering", "turn-steering", + "later-progress"})); + if (!require(stable && progressCard && + stable->mapTo(view.viewport(), QPoint{}).y() < + progressCard->mapTo(view.viewport(), QPoint{}).y(), + "steering did not begin ahead of its later activity")) + return false; + + { + auto write = graph.write(); + write.setField(steering, "dispatchState", "awaitingMaterialization"); + write.setField(steering, "showPendingAnimation", true); + static_cast(write.finish()); + } + snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot) + return false; + static_cast(view.reconcile(*snapshot)); + QApplication::processEvents(); + QTimer *animation = stable->findChild( + QStringLiteral("pendingAnimationTimer")); + if (!require(animation && animation->isActive() && + stable->data().kind == middle::CardKind::LocalPrompt && + acknowledgements == 0, + "accepted steering stopped while awaiting its authoritative " + "user item")) + return false; + + NodeRef authoritative; + { + auto write = graph.write(); + authoritative = write.upsert( + {NodeKind::Item, "provider-steering"}, + state("provider-steering", "userMessage", "Steer here")); + write.setField(authoritative, "localSubmissionId", std::uint64_t{72}); + write.setParent(turn, authoritative); + write.relate(authoritative, + nodegraph::RelationKind::PromptMaterialization, steering); + write.replaceChildren( + turn, std::array{root, steering, authoritative, progress}); + static_cast(write.finish()); + } + snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot) + return false; + static_cast(view.reconcile(*snapshot)); + QApplication::processEvents(); + const int promotedTop = stable->mapTo(view.viewport(), QPoint{}).y(); + if (!require(stable->data().kind == middle::CardKind::UserMessage && + animation && !animation->isActive() && + acknowledgements == 1 && promotedTop < + progressCard->mapTo(view.viewport(), QPoint{}).y(), + "authoritative steering materialization did not stop its " + "animation in the original submitted slot")) + return false; + + { + auto write = graph.write(); + write.remove(steering); + static_cast(write.finish()); + } + snapshot = adapter.conversation(thread, 80, {true, true}); + if (!snapshot) + return false; + static_cast(view.reconcile(*snapshot)); + QApplication::processEvents(); + return require( + findCard(middle::stableKey(middle::LocalPromptKey{72})) == stable && + stable->data().target == authoritative && + stable->mapTo(view.viewport(), QPoint{}).y() == promotedTop && + promotedTop < progressCard->mapTo(view.viewport(), QPoint{}).y() && + acknowledgements == 1, + "steering retirement recreated, moved, or reordered its stable card"); +} + +} // namespace +} // namespace codexui::codex + +int main(int argc, char **argv) { + QApplication application(argc, argv); + using namespace codexui::codex; + if (!oldUiConsumesAdapterSnapshotsAtomically() || + !pausedViewportKeepsItsPaintedAnchor() || + !promptMorphPreservesExactTargetAndWidget() || + !steeringMorphKeepsItsSlotThroughRetirement()) + return EXIT_FAILURE; + std::cout << "NodeGraph conversation UI tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/codex/NodeGraphInspectorUiTest.cpp b/tests/codex/NodeGraphInspectorUiTest.cpp new file mode 100644 index 0000000..3943a55 --- /dev/null +++ b/tests/codex/NodeGraphInspectorUiTest.cpp @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/InspectorPane.h" +#include "codex/nodegraph/NodeGraph.h" +#include "codex/ui/NodeGraphUiAdapter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace codexui::codex { +namespace { + +bool expect(bool condition, const char *message) { + if (condition) + return true; + std::cerr << "FAILED: " << message << '\n'; + return false; +} + +nodegraph::NodeRef addActivity(nodegraph::NodeGraph::WriteAccess &write, + const nodegraph::NodeRef &turn, + std::string id, std::string kind, + std::string childId, std::string status, + std::string tool = {}) { + nodegraph::NodeState state; + state.status = status == "completed" + ? nodegraph::NodeStatus::Completed + : status == "interrupted" + ? nodegraph::NodeStatus::Interrupted + : nodegraph::NodeStatus::Running; + state.fields = {{"type", nodegraph::Value("subAgentActivity")}, + {"kind", nodegraph::Value(std::move(kind))}, + {"agentThreadId", nodegraph::Value(std::move(childId))}, + {"status", nodegraph::Value(std::move(status))}, + {"tool", nodegraph::Value(std::move(tool))}}; + const nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, std::move(id)}, std::move(state)); + write.setParent(turn, item); + return item; +} + +bool logicalAgentsRemainDeduplicated() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef owner; + nodegraph::NodeRef turn; + nodegraph::NodeRef childOne; + { + auto write = graph.write(); + owner = write.upsert({nodegraph::NodeKind::Thread, "owner"}); + turn = write.upsert({nodegraph::NodeKind::Turn, "turn"}); + write.setParent(owner, turn); + childOne = write.upsert({nodegraph::NodeKind::Thread, "child-one"}, + {nodegraph::NodeStatus::Running, {}}); + const nodegraph::NodeRef started = + addActivity(write, turn, "start", "started", "child-one", + "inProgress", "spawn_agent"); + write.relate(started, nodegraph::RelationKind::AgentChildThread, + childOne); + static_cast(write.finish()); + } + + ui::NodeGraphUiAdapter adapter(graph); + auto snapshot = adapter.inspector(owner); + bool result = expect(snapshot && snapshot->agents.agents.size() == 1 && + snapshot->agents.agents.front().childThreadId == + "child-one", + "a child start creates one logical Agent row"); + + { + auto write = graph.write(); + static_cast( + addActivity(write, turn, "progress", "progress", "child-one", + "inProgress")); + static_cast( + addActivity(write, turn, "replay", "started", "child-one", + "inProgress", "spawn_agent")); + static_cast( + addActivity(write, turn, "complete", "completed", "child-one", + "completed")); + write.setStatus(childOne, nodegraph::NodeStatus::Completed); + write.setField(childOne, "status", "completed"); + static_cast(write.finish()); + } + snapshot = adapter.inspector(owner); + result &= expect(snapshot && snapshot->agents.agents.size() == 1 && + snapshot->agents.agents.front().status == "completed", + "start, progress, replay and completion remain one row"); + + { + auto write = graph.write(); + static_cast(addActivity(write, turn, "stale", "progress", + "child-one", "inProgress")); + static_cast(write.finish()); + } + snapshot = adapter.inspector(owner); + result &= expect(snapshot && snapshot->agents.agents.front().status == + "completed", + "a stale active update cannot overwrite terminal status"); + + { + auto write = graph.write(); + nodegraph::NodeState ordinary; + ordinary.fields = { + {"type", nodegraph::Value("collabAgentToolCall")}, + {"tool", nodegraph::Value("send_message")}, + {"receiverThreadIds", + nodegraph::Value(nodegraph::Value::Array{ + nodegraph::Value("not-a-spawn")})}}; + const nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "ordinary-collaboration"}, + std::move(ordinary)); + write.setParent(turn, item); + static_cast(write.finish()); + } + snapshot = adapter.inspector(owner); + result &= expect(snapshot && snapshot->agents.agents.size() == 1, + "a non-spawn collaboration call creates no Agent row"); + + { + auto write = graph.write(); + nodegraph::NodeState spawn; + spawn.status = nodegraph::NodeStatus::Running; + spawn.fields = { + {"type", nodegraph::Value("collabAgentToolCall")}, + {"tool", nodegraph::Value("spawn_agents_on_csv")}, + {"receiverThreadIds", + nodegraph::Value(nodegraph::Value::Array{ + nodegraph::Value("child-two"), + nodegraph::Value("child-two")})}}; + const nodegraph::NodeRef item = write.upsert( + {nodegraph::NodeKind::Item, "multi-spawn"}, std::move(spawn)); + write.setParent(turn, item); + static_cast(write.finish()); + } + snapshot = adapter.inspector(owner); + result &= expect(snapshot && snapshot->agents.agents.size() == 2 && + snapshot->agents.agents[0].childThreadId == "child-one" && + snapshot->agents.agents[1].childThreadId == "child-two", + "two distinct children produce two stable ordered rows"); + + { + auto write = graph.write(); + static_cast(addActivity(write, turn, "interrupt", "interrupted", + "child-one", "interrupted")); + write.setStatus(childOne, nodegraph::NodeStatus::Interrupted); + write.setField(childOne, "status", "interrupted"); + static_cast(write.finish()); + } + snapshot = adapter.inspector(owner); + result &= expect(snapshot && snapshot->agents.agents.size() == 2 && + snapshot->agents.agents[0].childThreadId == "child-one" && + snapshot->agents.agents[0].status == "interrupted" && + snapshot->agents.agents[1].childThreadId == "child-two", + "interruption patches the existing row without reordering"); + const auto agentsOnly = + adapter.inspector(owner, ui::InspectorProjection::Agents); + result &= expect( + agentsOnly && agentsOnly->agents.agents.size() == 2 && + !agentsOnly->plan.plan && !agentsOnly->plan.planItem && + agentsOnly->state.state.empty() && + agentsOnly->requests.requests.empty(), + "the active Agents projection does not construct State, Plan, or " + "Requests presentation data"); + return result; +} + +bool establishedAgentsWidgetContractIsRetained() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef owner; + { + auto write = graph.write(); + owner = write.upsert({nodegraph::NodeKind::Thread, "widget-owner"}); + const nodegraph::NodeRef turn = + write.upsert({nodegraph::NodeKind::Turn, "widget-turn"}); + write.setParent(owner, turn); + static_cast(addActivity(write, turn, "widget-agent", "started", + "widget-child", "inProgress", + "spawn_agent")); + static_cast(write.finish()); + } + ui::NodeGraphUiAdapter adapter(graph); + const auto snapshot = adapter.inspector(owner); + middle::InspectorPane pane; + pane.resize(440, 700); + pane.show(); + pane.refresh(*snapshot); + QCoreApplication::processEvents(); + + bool result = expect( + pane.findChildren(QStringLiteral("inspectorAgentFrame")) + .empty(), + "the hidden Agents tab constructs no Agent row widgets"); + pane.tabs()->setCurrentIndex(1); + QCoreApplication::processEvents(); + const auto frames = + pane.findChildren(QStringLiteral("inspectorAgentFrame")); + result &= expect(frames.size() == 1, + "activating Agents materializes the current logical row"); + if (!frames.empty()) { + QFrame *stableFrame = frames.front(); + auto *content = frames.front()->findChild( + QStringLiteral("agentCardContent")); + auto *disclosure = frames.front()->findChild( + QStringLiteral("agentDisclosureButton")); + result &= expect(content && disclosure && !content->isVisible(), + "the established Agent row starts collapsed"); + if (disclosure) + disclosure->click(); + QCoreApplication::processEvents(); + result &= expect(content && content->isVisible(), + "the established disclosure behavior is retained"); + const qulonglong constructions = + pane.property("agentRowConstructions").toULongLong(); + ui::InspectorSnapshot updated = *snapshot; + updated.agents.agents.front().status = "completed"; + pane.refresh(updated); + QCoreApplication::processEvents(); + const auto updatedFrames = + pane.findChildren(QStringLiteral("inspectorAgentFrame")); + auto *updatedContent = stableFrame->findChild( + QStringLiteral("agentCardContent")); + result &= expect(updatedFrames.size() == 1 && + updatedFrames.front() == stableFrame && + updatedContent && updatedContent->isVisible() && + pane.property("agentRowConstructions").toULongLong() == + constructions, + "an Agent state change patches the stable expanded row " + "without rebuilding the Agents surface"); + const qulonglong patches = + pane.property("agentRowPatches").toULongLong(); + pane.refresh(updated); + QCoreApplication::processEvents(); + result &= expect(pane.property("agentRowPatches").toULongLong() == patches, + "repeating identical Agent state performs zero row work"); + } + return result; +} + +bool planAndRequestUpdatesRetainUnaffectedRows() { + ui::InspectorSnapshot snapshot; + snapshot.plan.threadId = "stable-inspector"; + snapshot.plan.threadPresent = true; + snapshot.plan.plan = ui::InspectorPlan{ + "Stable explanation", + {{"first stable step", "in_progress"}, + {"second stable step", "pending"}}}; + snapshot.agents.threadId = "stable-inspector"; + snapshot.agents.threadPresent = true; + snapshot.requests.requests = { + {"request-one", "command-approval", "stable-inspector", 1, + "one", {}, {}, std::nullopt, true}, + {"request-two", "command-approval", "stable-inspector", 1, + "two", {}, {}, std::nullopt, true}}; + + middle::InspectorPane pane; + pane.resize(440, 700); + pane.show(); + pane.refresh(snapshot); + QCoreApplication::processEvents(); + auto planFrames = pane.findChildren( + QStringLiteral("inspectorPlanStepFrame")); + QFrame *firstPlan = nullptr; + QFrame *secondPlan = nullptr; + for (QFrame *frame : planFrames) { + if (frame->property("planStep").toString() == + QStringLiteral("first stable step")) + firstPlan = frame; + if (frame->property("planStep").toString() == + QStringLiteral("second stable step")) + secondPlan = frame; + } + const qulonglong planConstructions = + pane.property("planRowConstructions").toULongLong(); + snapshot.plan.plan->steps.front().status = "completed"; + pane.refresh(snapshot); + QCoreApplication::processEvents(); + planFrames = pane.findChildren( + QStringLiteral("inspectorPlanStepFrame")); + bool result = expect( + planFrames.contains(firstPlan) && planFrames.contains(secondPlan) && + pane.property("planRowConstructions").toULongLong() == + planConstructions, + "a Plan status update retains both stable rows and patches only the " + "changed presentation"); + + pane.tabs()->setCurrentIndex(3); + QCoreApplication::processEvents(); + auto requestFrames = pane.findChildren( + QStringLiteral("inspectorRequestFrame")); + QFrame *requestOne = nullptr; + QFrame *requestTwo = nullptr; + for (QFrame *frame : requestFrames) { + if (frame->property("requestId").toString() == + QStringLiteral("request-one")) + requestOne = frame; + if (frame->property("requestId").toString() == + QStringLiteral("request-two")) + requestTwo = frame; + } + const qulonglong requestConstructions = + pane.property("requestRowConstructions").toULongLong(); + snapshot.requests.requests.front().actionable = false; + pane.refresh(snapshot); + QCoreApplication::processEvents(); + requestFrames = pane.findChildren( + QStringLiteral("inspectorRequestFrame")); + result &= expect( + requestFrames.contains(requestOne) && requestFrames.contains(requestTwo) && + pane.property("requestRowConstructions").toULongLong() == + requestConstructions, + "a Request state update retains both stable request rows without a " + "whole-tab rebuild"); + return result; +} + +bool stateAndProtocolRemainUsefulBoundedAndRedacted() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef thread; + { + auto write = graph.write(); + nodegraph::NodeState state; + state.status = nodegraph::NodeStatus::Running; + state.fields = {{"name", nodegraph::Value("Visible thread")}, + {"prompt", nodegraph::Value("must-not-leak")}, + {"status", nodegraph::Value("running")}}; + thread = write.upsert({nodegraph::NodeKind::Thread, "state-thread"}, + std::move(state)); + static_cast(write.finish()); + } + ui::NodeGraphUiAdapter adapter(graph); + const auto snapshot = adapter.inspector(thread); + bool result = expect(snapshot && + snapshot->state.state.dump().find("must-not-leak") == + std::string::npos && + snapshot->state.state.dump().find("") != + std::string::npos, + "State preserves useful graph metadata without secrets"); + + middle::InspectorPane pane; + pane.resize(440, 700); + pane.refresh(*snapshot); + nodegraph::UiEffect diagnostic; + diagnostic.kind = nodegraph::UiEffectKind::ProtocolDiagnostic; + diagnostic.details = { + {"sequence", nodegraph::Value(std::uint64_t{7})}, + {"direction", nodegraph::Value("server notification")}, + {"subject", nodegraph::Value("item/completed")}, + {"authority", nodegraph::Value("merge")}, + {"threadId", nodegraph::Value("state-thread")}, + {"correlation", nodegraph::Value("corr-7")}, + {"error", nodegraph::Value("Bearer secret-value")}}; + pane.appendProtocolDiagnostic(diagnostic); + auto *protocol = + pane.findChild(QStringLiteral("protocolInfoLog")); + result &= expect(protocol && protocol->toPlainText().isEmpty(), + "a hidden Inspector diagnostic performs no QWidget update"); + + pane.show(); + pane.tabs()->setCurrentIndex(4); + auto *protocolChoice = + pane.findChild(QStringLiteral("protocolInfoChoice")); + if (protocolChoice) + protocolChoice->click(); + QCoreApplication::processEvents(); + result &= expect( + protocol && + protocol->toPlainText().contains(QStringLiteral("item/completed")) && + protocol->toPlainText().contains(QStringLiteral("corr-7")) && + protocol->toPlainText().contains( + QStringLiteral("")) && + !protocol->toPlainText().contains(QStringLiteral("secret-value")), + "Protocol retains bounded metadata and redacts sensitive text"); + + auto *stateChoice = + pane.findChild(QStringLiteral("stateInfoChoice")); + QPushButton *protocolBack = nullptr; + for (QPushButton *button : pane.findChildren()) + if (button->text().contains(QStringLiteral("Info"))) { + protocolBack = button; + break; + } + if (protocolBack) + protocolBack->click(); + if (stateChoice) + stateChoice->click(); + QCoreApplication::processEvents(); + auto *state = + pane.findChild(QStringLiteral("stateInfoView")); + result &= expect(state && + state->toPlainText().contains( + QStringLiteral("sharedNodeGraph")) && + state->toPlainText().contains( + QStringLiteral("Visible thread")) && + !state->toPlainText().contains( + QStringLiteral("must-not-leak")), + "the visible State page retains graph inspection content"); + return result; +} + +} // namespace +} // namespace codexui::codex + +int main(int argc, char **argv) { + QApplication application(argc, argv); + const bool passed = + codexui::codex::logicalAgentsRemainDeduplicated() && + codexui::codex::establishedAgentsWidgetContractIsRetained() && + codexui::codex::planAndRequestUpdatesRetainUnaffectedRows() && + codexui::codex::stateAndProtocolRemainUsefulBoundedAndRedacted(); + if (passed) + std::cout << "NodeGraph Inspector UI adapter tests passed\n"; + return passed ? 0 : 1; +} diff --git a/tests/codex/NodeGraphThreadPaneUiTest.cpp b/tests/codex/NodeGraphThreadPaneUiTest.cpp new file mode 100644 index 0000000..bdaea28 --- /dev/null +++ b/tests/codex/NodeGraphThreadPaneUiTest.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/middle/ThreadPane.h" +#include "codex/ui/NodeGraphUiAdapter.h" + +#include +#include + +#include +#include + +namespace codexui::codex { +namespace { + +bool require(bool condition, const char *message) { + if (condition) + return true; + std::cerr << message << '\n'; + return false; +} + +bool selectedChildRetainsRootAndCanonicalIdentity() { + nodegraph::NodeGraph graph; + nodegraph::NodeRef root; + nodegraph::NodeRef child; + { + auto write = graph.write(); + nodegraph::NodeRef runtime = + write.upsert({nodegraph::NodeKind::Runtime, "runtime"}); + nodegraph::NodeState rootState; + rootState.fields.emplace("name", "Root"); + root = write.upsert({nodegraph::NodeKind::Thread, "root"}, + std::move(rootState)); + nodegraph::NodeState childState; + childState.fields.emplace("name", "Child"); + child = write.upsert({nodegraph::NodeKind::Thread, "child"}, + std::move(childState)); + write.relate(runtime, nodegraph::RelationKind::RootThread, root); + write.relate(root, nodegraph::RelationKind::AgentChildThread, child); + static_cast(write.finish()); + } + + ui::NodeGraphUiAdapter adapter(graph); + const auto snapshot = adapter.threads(child); + if (!require(snapshot.has_value(), "thread adapter read failed")) + return false; + + middle::ThreadPane pane; + pane.resize(300, 500); + std::string selected; + middle::ThreadPane::Actions actions; + actions.select = [&](const std::string &id) { selected = id; }; + pane.setActions(std::move(actions)); + pane.refresh(*snapshot); + pane.show(); + QApplication::processEvents(); + + auto *list = pane.findChild(QStringLiteral("threadList")); + if (!require(list != nullptr, "thread list widget missing") || + !require(list->count() == 2, + "selected child did not retain its visible root")) + return false; + list->setCurrentRow(0); + QApplication::processEvents(); + selected.clear(); + list->setCurrentRow(1); + QApplication::processEvents(); + return require(selected == child->id().canonical, + "thread UI changed the canonical action identity") && + require(list->item(0)->data(Qt::UserRole).toString() == + QStringLiteral("root"), + "selecting a child removed its root row"); +} + +} // namespace +} // namespace codexui::codex + +int main(int argc, char **argv) { + QApplication application(argc, argv); + if (!codexui::codex::selectedChildRetainsRootAndCanonicalIdentity()) + return EXIT_FAILURE; + std::cout << "NodeGraph ThreadPane UI tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/codex/NodeGraphUiAdapterTest.cpp b/tests/codex/NodeGraphUiAdapterTest.cpp new file mode 100644 index 0000000..e800989 --- /dev/null +++ b/tests/codex/NodeGraphUiAdapterTest.cpp @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/ui/NodeGraphUiAdapter.h" + +#include +#include +#include + +namespace codexui::codex::ui { +namespace { + +using nodegraph::NodeId; +using nodegraph::NodeKind; +using nodegraph::NodeRef; +using nodegraph::NodeState; + +bool require(bool condition, const char *message) { + if (condition) + return true; + std::cerr << message << '\n'; + return false; +} + +NodeState itemState(std::string id, std::string type, std::string text) { + NodeState state; + state.fields.emplace("id", std::move(id)); + state.fields.emplace("type", std::move(type)); + state.fields.emplace("text", std::move(text)); + return state; +} + +bool projectsCanonicalTurnStructureAndRoot() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef firstTurn; + NodeRef secondTurn; + NodeRef firstPrompt; + NodeRef answer; + NodeRef secondPrompt; + { + auto write = graph.write(); + NodeState threadState; + threadState.fields.emplace("id", "thread-1"); + thread = write.upsert({NodeKind::Thread, "thread-1"}, + std::move(threadState)); + + NodeState turnOneState; + turnOneState.fields.emplace("id", "turn-1"); + firstTurn = write.upsert({NodeKind::Turn, "turn-1"}, + std::move(turnOneState)); + firstPrompt = write.upsert( + {NodeKind::Item, "prompt-1"}, + itemState("prompt-1", "userMessage", "first prompt")); + answer = write.upsert( + {NodeKind::Item, "answer-1"}, + itemState("answer-1", "agentMessage", "first answer")); + + NodeState turnTwoState; + turnTwoState.fields.emplace("id", "turn-2"); + secondTurn = write.upsert({NodeKind::Turn, "turn-2"}, + std::move(turnTwoState)); + secondPrompt = write.upsert( + {NodeKind::Item, "prompt-2"}, + itemState("prompt-2", "userMessage", "second prompt")); + + write.setParent(thread, firstTurn); + write.setParent(firstTurn, firstPrompt); + write.setParent(firstTurn, answer); + write.relate(firstTurn, nodegraph::RelationKind::TurnRootItem, + firstPrompt); + write.setParent(thread, secondTurn); + write.setParent(secondTurn, secondPrompt); + write.relate(secondTurn, nodegraph::RelationKind::TurnRootItem, + secondPrompt); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto result = adapter.conversation(thread, 80, {true, true}); + if (!require(result.has_value(), "adapter projection was unavailable") || + !require(result->threadId == "thread-1", "wrong projected thread") || + !require(result->sections.size() == 2, "wrong turn count") || + !require(result->sections[0].cards.size() == 2, + "wrong first-turn card count") || + !require(result->sections[1].cards.size() == 1, + "wrong second-turn card count") || + !require(result->sections[0].key == + "turn:8:thread-16:turn-1", + "the established stable section identity changed") || + !require(result->sections[0].rootCardKey.has_value(), + "first turn lost its canonical root") || + !require(result->sections[0].cards[0].key == + *result->sections[0].rootCardKey, + "first root is not the owning card")) + return false; + + const auto *prompt = std::get_if( + &result->sections[0].cards[0].payload); + const auto *agent = std::get_if( + &result->sections[0].cards[1].payload); + return require(prompt && prompt->text == "first prompt", + "user card projection changed") && + require(agent && agent->text == "first answer", + "agent card projection changed"); +} + +bool limitsHistoryButPinsTheOwningPrompt() { + nodegraph::NodeGraph graph; + NodeRef thread; + NodeRef turn; + NodeRef prompt; + { + auto write = graph.write(); + NodeState threadState; + threadState.fields.emplace("id", "thread-limit"); + thread = write.upsert({NodeKind::Thread, "thread-limit"}, + std::move(threadState)); + NodeState turnState; + turnState.fields.emplace("id", "turn-limit"); + turn = write.upsert({NodeKind::Turn, "turn-limit"}, + std::move(turnState)); + write.setParent(thread, turn); + prompt = write.upsert( + {NodeKind::Item, "root"}, + itemState("root", "userMessage", "owning prompt")); + write.setParent(turn, prompt); + write.relate(turn, nodegraph::RelationKind::TurnRootItem, prompt); + for (int index = 0; index < 5; ++index) { + const std::string id = "answer-" + std::to_string(index); + NodeRef item = write.upsert( + {NodeKind::Item, id}, itemState(id, "agentMessage", id)); + write.setParent(turn, item); + } + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto result = adapter.conversation(thread, 2, {true, true}); + return require(result.has_value(), "bounded projection unavailable") && + require(result->hasMore, "bounded projection lost Load More") && + require(result->hiddenAuthoritativeItemCount == 3, + "wrong hidden item count") && + require(result->sections.size() == 1, + "bounded projection lost its turn") && + require(result->sections[0].cards.size() == 3, + "root was not pinned beside retained suffix") && + require(result->sections[0].cards.front().key == + *result->sections[0].rootCardKey, + "pinned root does not own the turn"); +} + +bool preservesReadinessActivityAndAuthoritativeBudgetSemantics() { + nodegraph::NodeGraph graph; + NodeRef runtime; + NodeRef thread; + { + auto write = graph.write(); + runtime = write.upsert({NodeKind::Runtime, "runtime"}); + NodeState connection; + connection.status = nodegraph::NodeStatus::Disconnected; + connection.fields = {{"transportState", "disconnected"}, + {"providerState", "ready"}, + {"role", "controller"}}; + static_cast(write.upsert({NodeKind::Connection, "connection"}, + std::move(connection))); + NodeState threadState; + threadState.fields = {{"name", "Compatibility thread"}, + {"updatedAt", std::int64_t{4}}, + {"recencyAt", std::int64_t{6}}, + {"lastActivityAt", std::int64_t{5}}, + {"localActivityAt", std::int64_t{9}}, + {"localPromptActivityAt", std::int64_t{8}}, + {"hydrationState", "loading"}, + {"historyHasMore", true}}; + thread = write.upsert({NodeKind::Thread, "compatibility-thread"}, + std::move(threadState)); + const NodeRef turn = write.upsert({NodeKind::Turn, "compatibility-turn"}); + write.setParent(thread, turn); + const NodeRef provider = write.upsert( + {NodeKind::Item, "provider-item"}, + itemState("provider-item", "agentMessage", "provider")); + write.setParent(turn, provider); + NodeState local = itemState("local-item", "localPrompt", "local"); + const NodeRef localPrompt = + write.upsert({NodeKind::Item, "local-item"}, std::move(local)); + write.setParent(turn, localPrompt); + write.relate(runtime, nodegraph::RelationKind::RootThread, thread); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto info = adapter.conversationInfo(thread); + const auto threads = adapter.threads(thread); + return require(info && !info->readyForDisplay && + !info->hydrationFailed && info->providerHasMore && + info->authoritativeItemCount == 1, + "hydration or authoritative history budget changed") && + require(threads && !threads->providerReady && !threads->canControl, + "disconnected transport exposed ready thread controls") && + require(threads && threads->roots.size() == 1 && + threads->roots.front().lastActivityAt == + std::optional{9}, + "canonical effective thread activity changed"); +} + +bool preservesThreadRootsAndExactChildTargets() { + nodegraph::NodeGraph graph; + NodeRef runtime; + NodeRef root; + NodeRef child; + NodeRef orphan; + { + auto write = graph.write(); + runtime = write.upsert({NodeKind::Runtime, "runtime"}); + NodeState rootState; + rootState.fields.emplace("name", "Root thread"); + root = write.upsert({NodeKind::Thread, "root"}, std::move(rootState)); + NodeState childState; + childState.fields.emplace("name", "Child agent"); + child = write.upsert({NodeKind::Thread, "child"}, std::move(childState)); + NodeState orphanState; + orphanState.fields.emplace("name", "Paged orphan"); + orphan = + write.upsert({NodeKind::Thread, "orphan"}, std::move(orphanState)); + write.relate(runtime, nodegraph::RelationKind::RootThread, root); + write.relate(root, nodegraph::RelationKind::AgentChildThread, child); + static_cast(write.finish()); + } + + NodeGraphUiAdapter adapter(graph); + const auto result = adapter.threads(child); + return require(result.has_value(), "thread projection unavailable") && + require(result->selectedThreadId == "child", + "selected child identity was lost") && + require(result->roots.size() == 2, + "root or unreachable paged thread disappeared") && + require(result->roots[0].id == root->id().canonical, + "canonical root identity changed") && + require(result->roots[0].children.size() == 1, + "child hierarchy was flattened") && + require(result->roots[0].children[0].id == child->id().canonical, + "canonical child identity changed") && + require(result->roots[1].id == orphan->id().canonical, + "unreachable canonical thread was hidden"); +} + +} // namespace +} // namespace codexui::codex::ui + +int main() { + using namespace codexui::codex::ui; + if (!projectsCanonicalTurnStructureAndRoot() || + !limitsHistoryButPinsTheOwningPrompt() || + !preservesThreadRootsAndExactChildTargets() || + !preservesReadinessActivityAndAuthoritativeBudgetSemantics()) + return EXIT_FAILURE; + std::cout << "NodeGraph UI adapter tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp deleted file mode 100644 index 51195f1..0000000 --- a/tests/codex/PresentationPipelineTest.cpp +++ /dev/null @@ -1,1151 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/PresentationModel.h" -#include "codex/PresentationProtocol.h" -#include "codex/PresentationStatus.h" -#include "codex/ProtocolNormalizer.h" - -#include -#include -#include -#include -#include - -namespace { - -bool expect(bool condition, const char *message) { - std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; - return condition; -} - -std::string stringMember(const nlohmann::json &value, const char *name) { - const auto member = value.find(name); - return member != value.end() && member->is_string() - ? member->get() - : std::string{}; -} - -const codexui::codex::TextRetentionPresentation * -textRetention(const codexui::codex::ItemPresentation &item, - const std::string &field) { - const auto value = std::find_if( - item.textRetention.begin(), item.textRetention.end(), - [&field](const codexui::codex::TextRetentionPresentation &entry) { - return entry.field == field; - }); - return value == item.textRetention.end() ? nullptr : &*value; -} - -} // namespace - -int main() { - using codexui::codex::PresentationModel; - using codexui::codex::ProtocolNormalizer; - - PresentationModel model; - std::vector frames; - ProtocolNormalizer normalizer([&](const nlohmann::json &frame) { - frames.push_back(frame); - model.applyEvent(frame); - return true; - }); - - normalizer.transportEvent("connected"); - normalizer.bridgeEvent({{"kind", "bridge.connection"}, - {"event", "connected"}, - {"connectionId", "frontend-test"}, - {"role", "observer"}}); - normalizer.bridgeEvent({{"kind", "bridge.controller"}, - {"controllerConnectionId", "frontend-test"}}); - normalizer.connectionSettings( - {{"selected", "ipv6"}, - {"available", nlohmann::json::array({{{"key", "ipv6"}, - {"label", "IPv6"}, - {"kind", "network"}, - {"host", "::1"}, - {"port", 4500}, - {"tls", false}}})}}); - - normalizer.operationResult( - "threads.list", "list-1", nlohmann::json::object(), - {{"id", "list-1"}, - {"result", - {{"data", nlohmann::json::array({{{"id", "thread-1"}, - {"preview", "Architecture pipeline"}, - {"cwd", "/workspace"}, - {"status", {{"type", "idle"}}}}})}, - {"nextCursor", nullptr}, - {"backwardsCursor", nullptr}}}}); - - normalizer.operationResult( - "thread.read", "read-1", {{"threadId", "thread-1"}}, - {{"id", "read-1"}, - {"result", - {{"thread", - {{"id", "thread-1"}, - {"preview", "Architecture pipeline"}, - {"cwd", "/workspace"}, - {"status", {{"type", "idle"}}}, - {"turns", - nlohmann::json::array( - {{{"id", "turn-1"}, - {"status", "completed"}, - {"items", nlohmann::json::array( - {{{"id", "user-1"}, - {"type", "userMessage"}, - {"content", - nlohmann::json::array( - {{{"type", "text"}, - {"text", "Inspect"}}})}}})}}})}}}}}}); - - normalizer.serverNotification("turn/started", - {{"threadId", "thread-1"}, - {"turn", - {{"id", "turn-2"}, - {"status", "inProgress"}, - {"items", nlohmann::json::array()}}}}); - normalizer.serverNotification("item/started", - {{"threadId", "thread-1"}, - {"turnId", "turn-2"}, - {"item", - {{"id", "command-1"}, - {"type", "commandExecution"}, - {"command", "printf PIPELINE_OK"}, - {"cwd", "/workspace"}, - {"status", "inProgress"}, - {"aggregatedOutput", nullptr}, - {"exitCode", nullptr}}}}); - normalizer.serverNotification("item/commandExecution/outputDelta", - {{"threadId", "thread-1"}, - {"turnId", "turn-2"}, - {"itemId", "command-1"}, - {"delta", "PIPELINE_OK\n"}}); - normalizer.serverNotification("item/completed", - {{"threadId", "thread-1"}, - {"turnId", "turn-2"}, - {"item", - {{"id", "command-1"}, - {"type", "commandExecution"}, - {"command", "printf PIPELINE_OK"}, - {"cwd", "/workspace"}, - {"status", "completed"}, - {"aggregatedOutput", "PIPELINE_OK\n"}, - {"exitCode", 0}}}}); - normalizer.serverNotification( - "turn/diff/updated", - {{"threadId", "thread-1"}, - {"turnId", "turn-2"}, - {"diff", "diff --git a/README.md b/README.md\n+PIPELINE_OK\n"}}); - normalizer.serverNotification( - "turn/plan/updated", - {{"threadId", "thread-1"}, - {"turnId", "turn-2"}, - {"explanation", "Keep live inspector state"}, - {"plan", nlohmann::json::array({{{"step", "Retain the plan"}, - {"status", "completed"}}})}}); - normalizer.serverNotification("turn/completed", - {{"threadId", "thread-1"}, - {"turn", - {{"id", "turn-2"}, - {"status", "completed"}, - {"items", nlohmann::json::array()}}}}); - - normalizer.operationResult("thread.read", "read-2", - {{"threadId", "thread-1"}}, - {{"id", "read-2"}, - {"result", - {{"thread", - {{"id", "thread-1"}, - {"preview", "Architecture pipeline"}, - {"cwd", "/workspace"}, - {"status", {{"type", "idle"}}}, - {"turns", nlohmann::json::array()}}}}}}); - - normalizer.operationResult( - "thread.resume", "resume-1", {"threadId", "thread-1"}, - {{"id", "resume-1"}, - {"result", - {{"thread", {{"id", "thread-1"}}}, - {"model", "gpt-current"}, - {"reasoningEffort", "high"}, - {"approvalPolicy", "never"}, - {"sandbox", "workspaceWrite"}}}}); - normalizer.serverNotification( - "thread/settings/updated", - {{"threadId", "thread-1"}, - {"threadSettings", - {{"model", "gpt-current"}, {"personality", "friendly"}}}}); - normalizer.serverNotification( - "thread/settings/updated", - {{"threadId", "thread-1"}, - {"threadSettings", {{"personality", nullptr}}}}); - normalizer.serverNotification( - "thread/settings/updated", - {{"threadId", "thread-2"}, - {"threadSettings", {{"model", "gpt-background"}}}}); - - bool validFrames = !frames.empty(); - std::uint64_t expectedSequence = 1; - for (const nlohmann::json &frame : frames) { - validFrames &= codexui::codex::presentation::isPresentationFrame(frame); - validFrames &= frame.value("sequence", 0ULL) == expectedSequence++; - validFrames &= frame.value("generation", 0ULL) == 1; - } - - const auto *thread = model.thread("thread-1"); - const auto *turn = thread == nullptr - ? nullptr - : [&]() -> const codexui::codex::TurnPresentation * { - const auto found = thread->turns.find("turn-2"); - return found == thread->turns.end() ? nullptr : &found->second; - }(); - const auto *item = turn == nullptr - ? nullptr - : [&]() -> const codexui::codex::ItemPresentation * { - const auto found = turn->items.find("command-1"); - return found == turn->items.end() ? nullptr : &found->second; - }(); - - bool passed = true; - passed &= expect( - codexui::codex::displayStatus("inProgress") == "running" && - codexui::codex::displayStatus("notLoaded") == "not loaded" && - codexui::codex::displayStatus("futureProviderState") == - "future provider state", - "status presentation is lowercase, semantic, and never leaks camelCase"); - passed &= expect(validFrames, - "normalizer emits ordered versioned generation frames"); - passed &= expect( - model.connection().connected && - model.connection().connectionId == "frontend-test" && - model.connection().role == "controller" && - stringMember(model.connection().settings, "selected") == "ipv6", - "connection, controller, and transport settings form coherent state"); - passed &= expect(thread != nullptr && thread->turnOrder.size() == 2 && - thread->cwd == "/workspace" && - stringMember(thread->raw, "model") == "gpt-current" && - stringMember(thread->raw, "reasoningEffort") == "high", - "list, full read, and live events retain one stable thread"); - const auto settings = - thread == nullptr ? nullptr - : &thread->domains.at("thread.settings.changed") - .at("threadSettings"); - const auto *background = model.thread("thread-2"); - passed &= expect( - settings != nullptr && settings->contains("personality") && - settings->at("personality").is_null() && - thread->settingsRevision == 2 && background != nullptr && - background->settingsRevision == 1 && - stringMember(background->latestSettingsUpdate, "model") == - "gpt-background", - "settings updates retain explicit defaults and remain thread scoped"); - passed &= expect(turn != nullptr && turn->status == "completed" && - turn->itemOrder.size() == 1, - "live turn lifecycle resolves one stable turn"); - passed &= expect( - item != nullptr && - stringMember(item->raw, "command") == "printf PIPELINE_OK" && - stringMember(item->raw, "aggregatedOutput") == "PIPELINE_OK\n" && - item->raw.value("exitCode", -1) == 0 && - stringMember(item->raw, "status") == "completed", - "command lifecycle retains its authoritative result"); - passed &= - expect(turn != nullptr && - stringMember(turn->domains.at("turn.diff.changed"), "diff") == - "diff --git a/README.md b/README.md\n+PIPELINE_OK\n", - "live turn diff is retained in its authoritative turn scope"); - passed &= - expect(turn != nullptr && - stringMember(turn->plan, "explanation") == - "Keep live inspector state" && - turn->plan.value("steps", nlohmann::json::array()).size() == 1, - "incomplete thread reads preserve live plan and inspector state"); - passed &= expect(!model.activeTurnId("thread-1").has_value(), - "completed stream leaves no active turn"); - passed &= expect(model.thread("thread-1") != nullptr && - model.thread("thread-1")->status == "idle", - "completed stream retains idle thread status"); - - normalizer.operationResult( - "thread.read", "stale-active-read", {{"threadId", "thread-1"}}, - {{"id", "stale-active-read"}, - {"result", - {{"thread", - {{"id", "thread-1"}, - {"status", {{"type", "active"}}}, - {"turns", - nlohmann::json::array( - {{{"id", "turn-1"}, {"status", "completed"}}, - {{"id", "turn-2"}, {"status", "inProgress"}}})}}}}}}); - thread = model.thread("thread-1"); - turn = thread == nullptr ? nullptr : &thread->turns.at("turn-2"); - passed &= expect(turn != nullptr && turn->status == "completed" && - !model.activeTurnId("thread-1").has_value(), - "a stale authoritative read cannot reactivate a completed " - "turn"); - passed &= expect(thread != nullptr && thread->status == "idle", - "a stale authoritative read cannot restore running thread " - "chrome"); - - normalizer.serverNotification( - "turn/started", - {{"threadId", "thread-1"}, {"turn", {{"id", "turn-3"}}}}); - passed &= expect(model.activeTurnId("thread-1") == "turn-3" && - model.thread("thread-1")->status == "active", - "started lifecycle supplies a missing active status"); - normalizer.serverNotification( - "thread/status/changed", - {{"threadId", "thread-1"}, {"status", {{"type", "completed"}}}}); - passed &= expect(!model.activeTurnId("thread-1").has_value(), - "completed thread chrome vetoes a stale active turn"); - normalizer.serverNotification( - "turn/completed", - {{"threadId", "thread-1"}, {"turn", {{"id", "turn-3"}}}}); - passed &= expect(!model.activeTurnId("thread-1").has_value(), - "completed lifecycle clears activity without turn status"); - - PresentationModel hydratedModel; - ProtocolNormalizer hydratedNormalizer( - [&](const nlohmann::json &frame) { - hydratedModel.applyEvent(frame); - return true; - }); - hydratedNormalizer.transportEvent("connected"); - hydratedNormalizer.operationResult( - "thread.read", "repository-hints", {{"threadId", "repository-thread"}}, - {{"id", "repository-hints"}, - {"result", - {{"thread", - {{"id", "repository-thread"}, - {"cwd", "/workspace"}, - {"turns", - nlohmann::json::array( - {{{"id", "repository-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "repository-command"}, - {"type", "commandExecution"}, - {"cwd", "/workspace/project/src"}}, - {{"id", "repository-change"}, - {"type", "fileChange"}, - {"changes", - nlohmann::json::array( - {{{"path", "lib/example.cpp"}}, - {{"path", "removed.txt"}}})}}})}}})}}}}}}); - const auto *repositoryThread = hydratedModel.thread("repository-thread"); - passed &= expect( - repositoryThread != nullptr && - repositoryThread->commandCwds == - std::vector{"/workspace/project/src"} && - repositoryThread->changedPaths == - std::vector{"lib/example.cpp", "removed.txt"}, - "authoritative thread hydration retains compact repository hints"); - - PresentationModel orderingModel; - orderingModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "thread.upsert", {{"thread", {{"id", "retained-a"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "retained-a"}})); - orderingModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "thread.upsert", {{"thread", {{"id", "retained-b"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "retained-b"}})); - orderingModel.applyEvent(codexui::codex::presentation::result( - 3, 1, "threads.list", "ordered-threads", true, - {{"threads", - nlohmann::json::array({{{"id", "provider-a"}}, - {{"id", "provider-b"}}, - {{"id", "provider-a"}}})}}, - codexui::codex::presentation::Authority::Merge)); - passed &= expect( - orderingModel.threadOrder() == - std::vector{"provider-a", "provider-b", "retained-b", - "retained-a"}, - "thread discovery preserves provider order and one retained tail"); - - PresentationModel structuralModel; - structuralModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "turn.upsert", - {{"turn", {{"id", "placeholder-turn"}, {"status", "inProgress"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "placeholder"}, {"turnId", "placeholder-turn"}})); - passed &= expect(structuralModel.thread("placeholder") != nullptr && - structuralModel.threadOrder().empty(), - "thread-scoped events retain invisible placeholders"); - structuralModel.applyEvent(codexui::codex::presentation::result( - 2, 1, "thread.resume", "resume-placeholder", true, - {{"thread", {{"id", "placeholder"}, {"parentThreadId", nullptr}}}}, - codexui::codex::presentation::Authority::Merge)); - passed &= expect( - structuralModel.threadOrder() == std::vector{"placeholder"}, - "an explicit root resume admits an existing placeholder"); - structuralModel.applyEvent(codexui::codex::presentation::result( - 3, 1, "threads.list", "structural-threads", true, - {{"threads", nlohmann::json::array( - {{{"id", "child"}, {"parentThreadId", "parent"}}, - {{"id", "parent"}, {"parentThreadId", nullptr}}})}}, - codexui::codex::presentation::Authority::Merge)); - const auto *structuralOwnership = structuralModel.childOwnership("child"); - passed &= expect( - structuralOwnership && structuralOwnership->parentThreadId == "parent" && - structuralOwnership->agentId.empty() && - structuralModel.threadOrder() == - std::vector{"parent", "placeholder"}, - "parentThreadId hides structural children before agent correlation"); - structuralModel.applyEvent(codexui::codex::presentation::result( - 4, 1, "thread.read", "read-structural-parent", true, - {{"thread", {{"id", "parent"}, - {"parentThreadId", nullptr}, - {"turns", nlohmann::json::array()}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "parent"}})); - passed &= expect( - structuralModel.childOwnership("child") && - structuralModel.threadOrder() == - std::vector{"parent", "placeholder"}, - "parent hydration preserves protocol-declared structural ownership"); - - PresentationModel ownershipModel; - ownershipModel.applyEvent(codexui::codex::presentation::result( - 1, 1, "threads.list", "ownership-roots", true, - {{"threads", - nlohmann::json::array({{{"id", "parent"}}, - {{"id", "child-one"}}, - {{"id", "second-root"}}})}}, - codexui::codex::presentation::Authority::Merge)); - ownershipModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "conversation.item.upsert", - {{"item", - {{"id", "spawn-one"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "child-one"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "parent"}, - {"turnId", "parent-turn"}, - {"itemId", "spawn-one"}})); - ownershipModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "conversation.item.upsert", - {{"item", - {{"id", "spawn-one"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "child-one"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "parent"}, - {"turnId", "parent-turn"}, - {"itemId", "spawn-one"}})); - ownershipModel.applyEvent(codexui::codex::presentation::event( - 4, 1, "conversation.item.upsert", - {{"item", - {{"id", "spawn-two"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "child-two"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "parent"}, - {"turnId", "parent-turn"}, - {"itemId", "spawn-two"}})); - ownershipModel.applyEvent(codexui::codex::presentation::event( - 5, 1, "conversation.item.upsert", - {{"item", - {{"id", "spawn-grandchild"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "grandchild"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "child-one"}, - {"turnId", "child-turn"}, - {"itemId", "spawn-grandchild"}})); - const auto *childOneOwnership = - ownershipModel.childOwnership("child-one"); - const auto *grandchildOwnership = - ownershipModel.childOwnership("grandchild"); - const auto *parent = ownershipModel.thread("parent"); - const auto *childOne = ownershipModel.thread("child-one"); - passed &= expect( - childOneOwnership && childOneOwnership->parentThreadId == "parent" && - childOneOwnership->agentId == "spawn-one" && - grandchildOwnership && - grandchildOwnership->parentThreadId == "child-one" && - parent && - parent->childThreadOrder == - std::vector{"child-one", "child-two"} && - childOne && childOne->childThreadOrder == - std::vector{"grandchild"} && - ownershipModel.threadOrder() == - std::vector{"parent", "second-root"}, - "ownership is unique, ordered, nested, and excluded from root order"); - - ownershipModel.notePromptActivity("grandchild", 100); - parent = ownershipModel.thread("parent"); - childOne = ownershipModel.thread("child-one"); - const auto *grandchild = ownershipModel.thread("grandchild"); - passed &= expect( - parent && parent->recencyAt == 100 && childOne && - childOne->recencyAt == 100 && grandchild && - grandchild->recencyAt == 100, - "nested prompt activity advances the child and every owning ancestor"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 6, 1, "agents.activity.upsert", - {{"activity", - {{"id", "peer-interaction"}, - {"type", "subAgentActivity"}, - {"kind", "interacted"}, - {"agentPath", "/root/child-two"}, - {"agentThreadId", "child-two"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "child-one"}, - {"turnId", "child-turn"}, - {"itemId", "peer-interaction"}})); - parent = ownershipModel.thread("parent"); - childOne = ownershipModel.thread("child-one"); - passed &= expect( - parent && childOne && - parent->childThreadOrder == - std::vector{"child-one", "child-two"} && - childOne->childThreadOrder == - std::vector{"grandchild"} && - ownershipModel.childOwnership("child-two") && - ownershipModel.childOwnership("child-two")->parentThreadId == - "parent" && - !childOne->agents.contains("peer-interaction") && - parent->agents.at("spawn-two").status == "started" && - stringMember(parent->agents.at("spawn-two").raw, "agentPath") == - "/root/child-two", - "peer interaction cannot reparent a sibling as a nested child"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 7, 1, "turn.upsert", - {{"turn", {{"id", "child-turn"}, {"status", "completed"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "child-one"}, {"turnId", "child-turn"}})); - ownershipModel.applyEvent(codexui::codex::presentation::event( - 8, 1, "conversation.item.upsert", - {{"item", - {{"id", "child-answer"}, - {"type", "agentMessage"}, - {"text", "direct child result"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "child-one"}, - {"turnId", "child-turn"}, - {"itemId", "child-answer"}})); - parent = ownershipModel.thread("parent"); - const auto ownerAgent = parent == nullptr - ? nullptr - : [&]() -> const codexui::codex::AgentPresentation * { - const auto found = - parent->agents.find("spawn-one"); - return found == parent->agents.end() - ? nullptr - : &found->second; - }(); - const auto *ownerSourceItem = - parent ? &parent->turns.at("parent-turn").items.at("spawn-one") - : nullptr; - passed &= expect( - ownerAgent && ownerAgent->status == "completed" && - stringMember(ownerAgent->raw, "resultText") == - "direct child result" && - ownerSourceItem && - stringMember(ownerSourceItem->raw, "resultText") == - "direct child result" && - parent->agents.at("spawn-two").status == "started", - "child completion and results route only to the indexed owning agent"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 9, 1, "agents.activity.upsert", - {{"activity", - {{"id", "wait-state"}, - {"type", "collabAgentToolCall"}, - {"tool", "wait_agent"}, - {"agentsStates", - {{"child-one", - {{"status", "completed"}, - {"message", "state-correlated result"}}}}}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "parent"}, - {"turnId", "parent-turn"}, - {"itemId", "wait-state"}})); - parent = ownershipModel.thread("parent"); - ownerSourceItem = - parent ? &parent->turns.at("parent-turn").items.at("spawn-one") - : nullptr; - passed &= expect( - parent && - stringMember(parent->agents.at("spawn-one").raw, "resultText") == - "state-correlated result" && - ownerSourceItem && - stringMember(ownerSourceItem->raw, "resultText") == - "state-correlated result", - "agent state results route to the indexed owner and its source item"); - - ownershipModel.applyEvent(codexui::codex::presentation::result( - 10, 1, "thread.read", "replace-child", true, - {{"thread", - {{"id", "child-one"}, - {"status", {{"type", "idle"}}}, - {"turns", - nlohmann::json::array( - {{{"id", "child-turn"}, - {"status", "inProgress"}, - {"items", - nlohmann::json::array( - {{{"id", "spawn-grandchild"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "grandchild"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "child-one"}})); - parent = ownershipModel.thread("parent"); - passed &= expect( - parent && parent->agents.at("spawn-one").status == "idle" && - !parent->agents.at("spawn-one").raw.contains("resultText") && - ownershipModel.childOwnership("child-one") != nullptr && - ownershipModel.childOwnership("grandchild") != nullptr, - "authoritative child hydration clears stale results without losing ownership"); - - ownershipModel.applyEvent(codexui::codex::presentation::result( - 11, 1, "threads.list", "relisted-owned-child", true, - {{"threads", - nlohmann::json::array({{{"id", "child-one"}}, - {{"id", "second-root"}}, - {{"id", "parent"}}})}}, - codexui::codex::presentation::Authority::Merge)); - passed &= expect( - ownershipModel.threadOrder() == - std::vector{"second-root", "parent"}, - "thread relisting cannot reintroduce an owned child as a root"); - - ownershipModel.applyEvent(codexui::codex::presentation::result( - 12, 1, "thread.read", "replace-parent", true, - {{"thread", - {{"id", "parent"}, - {"turns", - nlohmann::json::array( - {{{"id", "replacement-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "spawn-two"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "child-two"}}, - {{"id", "spawn-one"}, - {"type", "subAgentActivity"}, - {"status", "completed"}, - {"agentThreadId", "child-one"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "parent"}})); - parent = ownershipModel.thread("parent"); - passed &= expect( - parent && - parent->childThreadOrder == - std::vector{"child-two", "child-one"} && - ownershipModel.childOwnership("child-one") && - ownershipModel.childOwnership("child-two") && - ownershipModel.threadOrder() == - std::vector{"second-root", "parent"}, - "authoritative parent hydration rebuilds ordered ownership in one pass"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 13, 1, "thread.removed", nlohmann::json::object(), - codexui::codex::presentation::Authority::Remove, - {{"threadId", "child-one"}})); - parent = ownershipModel.thread("parent"); - passed &= expect( - ownershipModel.thread("child-one") == nullptr && parent && - parent->childThreadOrder == - std::vector{"child-two"} && - ownershipModel.childOwnership("child-one") == nullptr && - ownershipModel.childOwnership("grandchild") == nullptr && - ownershipModel.threadOrder() == - std::vector{"second-root", "parent", - "grandchild"}, - "authoritative child removal prunes ownership and promotes surviving descendants"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 14, 1, "thread.removed", nlohmann::json::object(), - codexui::codex::presentation::Authority::Remove, - {{"threadId", "parent"}})); - passed &= expect( - ownershipModel.thread("parent") == nullptr && - ownershipModel.childOwnership("child-two") == nullptr && - ownershipModel.threadOrder() == - std::vector{"second-root", "child-two", - "grandchild"}, - "authoritative parent removal promotes children in retained root order"); - - ownershipModel.applyEvent(codexui::codex::presentation::event( - 15, 1, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, - codexui::codex::presentation::Authority::Replace)); - passed &= expect(ownershipModel.threadOrder().empty() && - ownershipModel.thread("child-two") == nullptr && - ownershipModel.childOwnership("child-two") == nullptr, - "provider loss clears threads and ownership atomically"); - - PresentationModel authorityModel; - authorityModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "notice.added", {{"message", "scoped telemetry"}}, - codexui::codex::presentation::Authority::None, - {{"threadId", "phantom-none"}})); - passed &= expect( - authorityModel.thread("phantom-none") == nullptr && - authorityModel.telemetry().size() == 1, - "authority-none scoped telemetry cannot materialize a thread"); - authorityModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "thread.goal.changed", nlohmann::json::object(), - codexui::codex::presentation::Authority::Remove, - {{"threadId", "phantom-remove"}})); - passed &= expect( - authorityModel.thread("phantom-remove") == nullptr, - "authority-remove cannot materialize an absent scoped thread"); - authorityModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "turn.upsert", - {{"turn", {{"id", "real-turn"}, {"status", "inProgress"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "real-thread"}, {"turnId", "real-turn"}})); - passed &= expect( - authorityModel.thread("real-thread") != nullptr, - "authoritative merge still materializes represented thread state"); - - PresentationModel transportLossModel; - transportLossModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "connection.lifecycle", {{"state", "connected"}}, - codexui::codex::presentation::Authority::Replace)); - transportLossModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "ready"}}, - codexui::codex::presentation::Authority::Replace)); - transportLossModel.applyEvent(codexui::codex::presentation::result( - 3, 1, "threads.list", "transport-threads", true, - {{"threads", nlohmann::json::array({{{"id", "transport-thread"}}})}}, - codexui::codex::presentation::Authority::Merge)); - transportLossModel.applyEvent(codexui::codex::presentation::event( - 4, 1, "connection.lifecycle", {{"state", "disconnected"}}, - codexui::codex::presentation::Authority::Replace)); - passed &= expect( - transportLossModel.threadOrder().empty() && - transportLossModel.connection().providerState.empty(), - "transport loss invalidates stale provider readiness and authority"); - - PresentationModel reconnectOwnershipModel; - reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( - 1, 1, "thread.read", "hydrate-owner", true, - {{"thread", - {{"id", "hydrated-parent"}, - {"turns", - nlohmann::json::array( - {{{"id", "hydrated-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "hydrated-spawn"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "hydrated-child"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "hydrated-parent"}})); - reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( - 2, 1, "thread.read", "hydrate-child", true, - {{"thread", - {{"id", "hydrated-child"}, - {"status", {{"type", "notLoaded"}}}, - {"turns", - nlohmann::json::array( - {{{"id", "stale-outer-turn"}, - {"status", "interrupted"}, - {"items", nlohmann::json::array()}}, - {{"id", "child-turn"}, - {"status", "completed"}, - {"items", - nlohmann::json::array( - {{{"id", "nested-spawn"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "hydrated-grandchild"}}, - {{"id", "hydrated-result"}, - {"type", "agentMessage"}, - {"text", "hydrated answer"}}})}}})}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "hydrated-child"}})); - const auto *hydratedParent = - reconnectOwnershipModel.thread("hydrated-parent"); - const auto *hydratedSourceItem = - hydratedParent - ? &hydratedParent->turns.at("hydrated-turn") - .items.at("hydrated-spawn") - : nullptr; - passed &= expect( - hydratedParent && - reconnectOwnershipModel.childOwnership("hydrated-child") && - reconnectOwnershipModel.childOwnership("hydrated-grandchild") && - hydratedParent->agents.at("hydrated-spawn").status == "completed" && - stringMember(hydratedParent->agents.at("hydrated-spawn").raw, - "resultText") == "hydrated answer" && - hydratedSourceItem && - stringMember(hydratedSourceItem->raw, "status") == "completed" && - stringMember(hydratedSourceItem->raw, "resultText") == - "hydrated answer", - "parent-first and nested child hydration retain direct correlation"); - reconnectOwnershipModel.applyEvent(codexui::codex::presentation::event( - 1, 2, "connection.lifecycle", {{"state", "connected"}}, - codexui::codex::presentation::Authority::Replace)); - reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( - 2, 2, "threads.list", "post-reconnect-roots", true, - {{"threads", - nlohmann::json::array({{{"id", "hydrated-child"}}, - {{"id", "hydrated-parent"}}})}}, - codexui::codex::presentation::Authority::Merge)); - passed &= expect( - reconnectOwnershipModel.childOwnership("hydrated-child") && - reconnectOwnershipModel.threadOrder() == - std::vector{"hydrated-parent"}, - "connection-generation reconnect preserves ownership and root filtering"); - - PresentationModel inheritedHistoryModel; - inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( - 1, 1, "thread.read", "hydrate-sibling-parent", true, - {{"thread", - {{"id", "sibling-parent"}, - {"turns", - nlohmann::json::array( - {{{"id", "parent-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "spawn-a"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "sibling-a"}}, - {{"id", "spawn-b"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "sibling-b"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "sibling-parent"}})); - inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( - 2, 1, "thread.read", "hydrate-sibling-b", true, - {{"thread", - {{"id", "sibling-b"}, - {"turns", - nlohmann::json::array( - {{{"id", "inherited-parent-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "spawn-a"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "sibling-a"}}})}}})}}}}, - codexui::codex::presentation::Authority::Replace, - {{"threadId", "sibling-b"}})); - inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( - 3, 1, "thread.read", "complete-sibling-a", true, - {{"thread", - {{"id", "sibling-a"}, - {"status", {{"type", "notLoaded"}}}, - {"turns", - nlohmann::json::array( - {{{"id", "sibling-a-turn"}, - {"status", "completed"}, - {"items", - nlohmann::json::array( - {{{"id", "sibling-a-answer"}, - {"type", "agentMessage"}, - {"text", "sibling answer"}}})}}})}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "sibling-a"}})); - const auto *siblingParent = inheritedHistoryModel.thread("sibling-parent"); - const auto *siblingB = inheritedHistoryModel.thread("sibling-b"); - const auto *siblingOwnership = - inheritedHistoryModel.childOwnership("sibling-a"); - passed &= expect( - siblingParent && siblingB && siblingOwnership && - siblingOwnership->parentThreadId == "sibling-parent" && - siblingOwnership->agentId == "spawn-a" && - siblingParent->childThreadOrder == - std::vector{"sibling-a", "sibling-b"} && - siblingParent->agents.at("spawn-a").childThreadId == "sibling-a" && - siblingParent->agents.at("spawn-a").status == "completed" && - stringMember(siblingParent->agents.at("spawn-a").raw, - "resultText") == "sibling answer" && - siblingB->agents.at("spawn-a").childThreadId.empty(), - "inherited sibling history cannot steal direct child ownership"); - - PresentationModel reboundOwnershipModel; - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "thread.upsert", {{"thread", {{"id", "rebind-parent"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "rebind-parent"}})); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "conversation.item.upsert", - {{"item", - {{"type", "subAgentActivity"}, - {"id", "stable-agent"}, - {"status", "completed"}, - {"resultText", "old result"}, - {"agentThreadId", "old-child"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "rebind-parent"}, - {"turnId", "rebind-turn"}, - {"itemId", "stable-agent"}})); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "agents.activity.upsert", - {{"activity", - {{"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "new-child"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "rebind-parent"}, - {"turnId", "rebind-turn"}, - {"itemId", "stable-agent"}})); - const auto *rebindParent = - reboundOwnershipModel.thread("rebind-parent"); - passed &= expect( - rebindParent && - rebindParent->childThreadOrder == - std::vector{"new-child"} && - reboundOwnershipModel.childOwnership("old-child") == nullptr && - reboundOwnershipModel.childOwnership("new-child") && - reboundOwnershipModel.threadOrder() == - std::vector{"rebind-parent", "old-child"}, - "rebinding one stable agent detaches and promotes the old child"); - passed &= expect( - rebindParent && - rebindParent->agents.at("stable-agent").childThreadId == - "new-child" && - rebindParent->agents.at("stable-agent").status == "started" && - !rebindParent->agents.at("stable-agent").raw.contains("resultText") && - !rebindParent->turns.at("rebind-turn") - .items.at("stable-agent") - .raw.contains("resultText"), - "rebinding one stable agent resets its stale completion and result"); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 4, 1, "turn.upsert", - {{"turn", {{"id", "new-child-turn"}, {"status", "completed"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "new-child"}, {"turnId", "new-child-turn"}})); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 5, 1, "conversation.item.upsert", - {{"item", - {{"id", "new-child-answer"}, - {"type", "agentMessage"}, - {"text", "new child result"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "new-child"}, - {"turnId", "new-child-turn"}, - {"itemId", "new-child-answer"}})); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( - 6, 1, "thread.read", "stale-parent-merge", true, - {{"thread", - {{"id", "rebind-parent"}, - {"turns", - nlohmann::json::array( - {{{"id", "rebind-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "stable-agent"}, - {"type", "subAgentActivity"}, - {"status", "inProgress"}, - {"kind", "started"}, - {"agentThreadId", "new-child"}}})}}})}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "rebind-parent"}})); - rebindParent = reboundOwnershipModel.thread("rebind-parent"); - const auto *reboundSourceItem = - rebindParent - ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") - : nullptr; - passed &= expect( - rebindParent && - rebindParent->agents.at("stable-agent").status == "completed" && - reboundSourceItem && - stringMember(reboundSourceItem->raw, "status") == "completed", - "stale merged parent hydration cannot reactivate a completed child agent"); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( - 7, 1, "thread.read", "stale-former-child-merge", true, - {{"thread", - {{"id", "rebind-parent"}, - {"turns", - nlohmann::json::array( - {{{"id", "rebind-turn"}, - {"items", - nlohmann::json::array( - {{{"id", "stable-agent"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "old-child"}}})}}})}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "rebind-parent"}})); - rebindParent = reboundOwnershipModel.thread("rebind-parent"); - const auto *reboundSourceAfterFormerChild = - rebindParent - ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") - : nullptr; - passed &= expect( - rebindParent && - rebindParent->agents.at("stable-agent").childThreadId == - "new-child" && - rebindParent->agents.at("stable-agent").status == "completed" && - stringMember(rebindParent->agents.at("stable-agent").raw, - "resultText") == "new child result" && - reboundSourceAfterFormerChild && - stringMember(reboundSourceAfterFormerChild->raw, "status") == - "completed" && - stringMember(reboundSourceAfterFormerChild->raw, "resultText") == - "new child result" && - reboundOwnershipModel.childOwnership("old-child") == nullptr && - reboundOwnershipModel.childOwnership("new-child") && - reboundOwnershipModel.childOwnership("new-child")->parentThreadId == - "rebind-parent", - "stale merged identity cannot undo a live child rebind"); - reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( - 8, 1, "agents.activity.upsert", - {{"activity", - {{"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "rebind-parent"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "new-child"}, - {"turnId", "cycle-turn"}, - {"itemId", "cycle-agent"}})); - passed &= expect( - reboundOwnershipModel.childOwnership("rebind-parent") == nullptr && - reboundOwnershipModel.thread("new-child") && - reboundOwnershipModel.thread("new-child") - ->childThreadOrder.empty(), - "ancestor ownership cycles are rejected without disturbing the tree"); - - normalizer.bridgeEvent({{"kind", "bridge.provider"}, - {"state", "disconnected"}, - {"providerGeneration", std::uint64_t{1}}, - {"reason", "test provider restart"}}); - passed &= expect(model.thread("thread-1") == nullptr && - model.connection().providerGeneration == 1 && - model.connection().providerState == "disconnected", - "provider loss clears provider-scoped presentation state"); - normalizer.bridgeEvent({{"kind", "bridge.provider"}, - {"state", "ready"}, - {"providerGeneration", std::uint64_t{2}}}); - passed &= expect(model.connection().providerGeneration == 2 && - model.connection().providerState == "ready", - "a new provider generation is accepted for rehydration"); - - PresentationModel connectionGenerationModel; - connectionGenerationModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "connection.lifecycle", {{"state", "connected"}}, - codexui::codex::presentation::Authority::Replace)); - connectionGenerationModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "connection.provider", - {{"generation", std::uint64_t{10}}, {"state", "ready"}}, - codexui::codex::presentation::Authority::Replace)); - connectionGenerationModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "thread.upsert", {{"thread", {{"id", "old-provider"}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "old-provider"}})); - connectionGenerationModel.applyEvent(codexui::codex::presentation::event( - 1, 2, "connection.lifecycle", {{"state", "connected"}}, - codexui::codex::presentation::Authority::Replace)); - connectionGenerationModel.applyEvent(codexui::codex::presentation::event( - 2, 2, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "ready"}}, - codexui::codex::presentation::Authority::Replace)); - passed &= expect( - connectionGenerationModel.thread("old-provider") != nullptr && - connectionGenerationModel.connection().providerGeneration == 1 && - connectionGenerationModel.connection().providerState == "ready", - "a new connection resets the provider generation floor without discarding retained ownership"); - - const std::string oversizedText(300 * 1024, 'A'); - PresentationModel boundedStreamModel; - boundedStreamModel.applyEvent(codexui::codex::presentation::event( - 1, 1, "conversation.item.upsert", - {{"item", - {{"id", "bounded-command"}, - {"type", "commandExecution"}, - {"aggregatedOutput", oversizedText}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "bounded-thread"}, - {"turnId", "bounded-turn"}, - {"itemId", "bounded-command"}})); - const auto *boundedThread = boundedStreamModel.thread("bounded-thread"); - const auto *boundedCommand = - boundedThread - ? &boundedThread->turns.at("bounded-turn").items.at("bounded-command") - : nullptr; - passed &= expect( - boundedCommand && - stringMember(boundedCommand->raw, "aggregatedOutput").size() <= - 256 * 1024 && - textRetention(*boundedCommand, "aggregatedOutput") && - textRetention(*boundedCommand, "aggregatedOutput")->discardedBytes > - 0, - "authoritative command output retains a bounded tail with explicit discarded bytes"); - boundedStreamModel.applyEvent(codexui::codex::presentation::event( - 2, 1, "conversation.item.append", - {{"field", "aggregatedOutput"}, - {"text", std::string(300 * 1024, 'B')}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "bounded-thread"}, - {"turnId", "bounded-turn"}, - {"itemId", "bounded-command"}})); - boundedThread = boundedStreamModel.thread("bounded-thread"); - boundedCommand = - boundedThread - ? &boundedThread->turns.at("bounded-turn").items.at("bounded-command") - : nullptr; - passed &= expect( - boundedCommand && - stringMember(boundedCommand->raw, "aggregatedOutput").size() <= - 256 * 1024 && - stringMember(boundedCommand->raw, "aggregatedOutput").front() == - 'B' && - textRetention(*boundedCommand, "aggregatedOutput") && - textRetention(*boundedCommand, "aggregatedOutput")->discardedBytes >= - 300 * 1024, - "oversized live deltas replace the retained tail without unbounded concatenation"); - boundedStreamModel.applyEvent(codexui::codex::presentation::event( - 3, 1, "conversation.item.upsert", - {{"item", - {{"id", "bounded-reasoning"}, - {"type", "reasoning"}, - {"summary", - nlohmann::json::array( - {std::string(160 * 1024, 'C'), - std::string(160 * 1024, 'D')})}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "bounded-thread"}, - {"turnId", "bounded-turn"}, - {"itemId", "bounded-reasoning"}})); - boundedThread = boundedStreamModel.thread("bounded-thread"); - const auto *boundedReasoning = - boundedThread ? &boundedThread->turns.at("bounded-turn") - .items.at("bounded-reasoning") - : nullptr; - passed &= expect( - boundedReasoning && - textRetention(*boundedReasoning, "summary") && - textRetention(*boundedReasoning, "summary")->retainedBytes <= - 256 * 1024 && - textRetention(*boundedReasoning, "summary")->discardedBytes > 0, - "indexed reasoning streams share one bounded retained-text budget"); - boundedStreamModel.applyEvent(codexui::codex::presentation::event( - 4, 1, "conversation.item.upsert", - {{"item", - {{"id", "large-prompt"}, - {"type", "userMessage"}, - {"text", oversizedText}}}}, - codexui::codex::presentation::Authority::Merge, - {{"threadId", "bounded-thread"}, - {"turnId", "bounded-turn"}, - {"itemId", "large-prompt"}})); - boundedThread = boundedStreamModel.thread("bounded-thread"); - const auto *largePrompt = - boundedThread ? &boundedThread->turns.at("bounded-turn") - .items.at("large-prompt") - : nullptr; - passed &= expect( - largePrompt && stringMember(largePrompt->raw, "text") == oversizedText && - largePrompt->textRetention.empty(), - "complete user-authored prompts are not treated as disposable streams"); - return passed ? 0 : 1; -} diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 1e5a6d2..e2bf553 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -6,303 +6,173 @@ #include "codex/Configuration.h" #include "codex/FrontendSession.h" #include "codex/PendingRequestDialog.h" -#include "codex/PresentationProtocol.h" #include "codex/ShellWidget.h" +#include "codex/middle/ComposerPane.h" #include "codex/middle/ConversationCards.h" +#include "codex/middle/ConversationView.h" +#include "codex/middle/InspectorPane.h" +#include "codex/middle/ThreadPane.h" +#include "codex/nodegraph/WorkerLogic.h" #include "codex/ui/ExpandingPromptEditor.h" +#include #include #include #include #include #include +#include #include #include #include #include +#include #include +#include #include +#include #include -#include #include #include #include -#include -#include -#include +#include +#include +#include +#include #include #include #include #include #include -#include +#include +#include #include #include #include namespace codexui::codex { +// The production API intentionally exposes no graph writer or mailbox +// consumer to Qt. This friend is confined to this integration test and acts +// as the worker side of that boundary without adding a third execution path. class FrontendSessionTestPeer final { public: - static int takeClientDescriptor(FrontendSession &session) { - return std::exchange(session.clientDescriptor, -1); + static nodegraph::NodeGraph &graph(FrontendSession &session) { + return session.graph; } - static void receive(FrontendSession &session, nlohmann::json frame) { - session.receiveMessage(std::move(frame)); + static nodegraph::ThreadChannels &channels(FrontendSession &session) { + return session.channels; } - static void failOutstanding(FrontendSession &session, int code, - std::string message) { - session.failAllPending(code, std::move(message)); + static void drainWorkerMessages(FrontendSession &session) { + session.drainWorkerMessages(); } }; namespace { -using presentation::Authority; +using namespace codexui::nodegraph; -bool expect(bool condition, const char *message) { +int failures = 0; + +void require(bool condition, std::string_view message) { if (condition) - return true; + return; + ++failures; std::cerr << "FAILED: " << message << '\n'; - return false; -} - -bool verifyFrontendBoundaryOrdering(Configuration &configuration) { - FrontendSession session(configuration); - std::vector order; - std::vector activity; - nlohmann::json completed; - session.setEventHandler( - [&order](const nlohmann::json &) { order.emplace_back("event"); }); - session.setActivityHandler([&activity](const std::string &threadId) { - activity.push_back(threadId); - }); - const std::string correlation = - session.request("thread.read", {{"threadId", "ordering"}}, - [&order, &completed](const nlohmann::json &result) { - order.emplace_back("completion"); - completed = result; - }); - FrontendSessionTestPeer::receive( - session, - presentation::result(1, 1, "thread.read", correlation, true, - {{"thread", {{"id", "ordering"}}}}, - Authority::Replace, {{"threadId", "ordering"}})); - bool result = expect( - order == std::vector{"completion", "event"}, - "a correlated completion remains ordered before global frame delivery"); - result &= - expect(presentation::isPresentationFrame(completed) && - completed.value("action", std::string{}) == "thread.read", - "a successful completion receives a complete presentation result"); - result &= - expect(activity.empty(), - "selection hydration requests and results do not report activity"); - - const std::string renameCorrelation = session.request( - "thread.rename", {{"threadId", "ordering"}, {"name", "Renamed"}}); - FrontendSessionTestPeer::receive( - session, - presentation::result(2, 1, "thread.rename", renameCorrelation, true, - nlohmann::json::object(), Authority::Merge, - {{"threadId", "ordering"}})); - result &= - expect(activity == std::vector{"ordering", "ordering"}, - "meaningful thread requests and results both report activity"); - - nlohmann::json failed; - const std::string failedCorrelation = session.request( - "thread.resume", {{"threadId", "ordering"}}, - [&failed](const nlohmann::json &response) { failed = response; }); - FrontendSessionTestPeer::failOutstanding(session, -32020, - "test connection loss"); - result &= expect( - presentation::isPresentationFrame(failed) && - failed.value("action", std::string{}) == "thread.resume" && - failed.value("correlationId", std::string{}) == failedCorrelation && - !failed.value("ok", true), - "locally failed operations preserve the complete result contract"); - return result; } -void spin(int milliseconds = 0) { - milliseconds = std::max(milliseconds, 20); +void spin(int milliseconds = 20) { QElapsedTimer timer; timer.start(); do { QCoreApplication::processEvents(QEventLoop::AllEvents, 10); - if (milliseconds > 0) - QThread::msleep(1); - } while (timer.elapsed() < milliseconds); + QThread::msleep(1); + } while (timer.elapsed() < std::max(milliseconds, 1)); } -class PresentationPeer final { -public: - explicit PresentationPeer(int descriptor) : descriptor_(descriptor) {} - ~PresentationPeer() { - if (descriptor_ >= 0) - ::close(descriptor_); - } - - PresentationPeer(const PresentationPeer &) = delete; - PresentationPeer &operator=(const PresentationPeer &) = delete; - - bool send(const nlohmann::json &frame) { - std::string encoded = frame.dump(); - encoded.push_back('\n'); - std::size_t offset = 0; - QElapsedTimer timer; - timer.start(); - while (offset < encoded.size() && timer.elapsed() < 1000) { - const ssize_t written = ::write(descriptor_, encoded.data() + offset, - encoded.size() - offset); - if (written > 0) { - offset += static_cast(written); - } else if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) { - spin(1); - } else { - return false; - } - } +bool spinUntil(const std::function &predicate, + int timeoutMilliseconds = 1000) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < timeoutMilliseconds) spin(2); - return offset == encoded.size(); - } - - std::optional waitFor(std::string_view action, - std::string_view threadId = {}, - int timeoutMilliseconds = 1000) { - QElapsedTimer timer; - timer.start(); - while (timer.elapsed() < timeoutMilliseconds) { - pump(); - const auto found = std::find_if( - frames_.begin(), frames_.end(), [&](const nlohmann::json &frame) { - if (frame.value("action", std::string{}) != action) - return false; - if (threadId.empty()) - return true; - const nlohmann::json data = - frame.value("data", nlohmann::json::object()); - return data.value("threadId", std::string{}) == threadId; - }); - if (found != frames_.end()) { - nlohmann::json result = std::move(*found); - frames_.erase(found); - return result; - } - spin(1); - } - return std::nullopt; - } + return predicate(); +} - bool has(std::string_view action) { - pump(); - return std::ranges::any_of(frames_, [&](const nlohmann::json &frame) { - return frame.value("action", std::string{}) == action; - }); - } +const Value *field(const std::shared_ptr &state, + std::string_view name) { + if (!state) + return nullptr; + const auto found = state->fields.find(name); + return found == state->fields.end() ? nullptr : &found->second; +} - void discard() { - pump(); - frames_.clear(); - } +bool stringFieldEquals(const std::shared_ptr &state, + std::string_view name, std::string_view expected) { + const Value *value = field(state, name); + return value && value->asString() && *value->asString() == expected; +} -private: - void pump() { - char buffer[8192]; - for (;;) { - const ssize_t count = ::read(descriptor_, buffer, sizeof(buffer)); - if (count > 0) { - incoming_.append(buffer, static_cast(count)); - continue; - } - if (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) - std::cerr << "peer read failed: " << std::strerror(errno) << '\n'; - break; - } - for (;;) { - const std::size_t newline = incoming_.find('\n'); - if (newline == std::string::npos) - break; - const std::string line = incoming_.substr(0, newline); - incoming_.erase(0, newline + 1); - if (!line.empty()) - frames_.push_back(nlohmann::json::parse(line)); - } +std::vector takeQtMessages(ThreadChannels &channels) { + static_cast(channels.drainQtToWorkerWake()); + std::vector messages; + QtToWorkerMessage message; + while (channels.tryReceiveForWorker(message)) { + messages.emplace_back(std::move(message)); + message = ShutdownRequest{}; } + return messages; +} - int descriptor_ = -1; - std::string incoming_; - std::deque frames_; -}; - -nlohmann::json thread(std::string id, std::string name, - std::string status = "idle", - std::string activeTurnId = {}) { - nlohmann::json turns = nlohmann::json::array(); - if (status == "active") { - if (activeTurnId.empty()) - activeTurnId = id + "-turn"; - turns.push_back({{"id", std::move(activeTurnId)}, - {"status", "inProgress"}, - {"items", nlohmann::json::array()}}); - } - return {{"id", std::move(id)}, - {"name", std::move(name)}, - {"cwd", "/tmp/codexui-shell-test"}, - {"status", std::move(status)}, - {"turns", std::move(turns)}}; +void applyThread(WorkerLogic &worker, std::string id, + std::string name = "Graph thread") { + static_cast(worker.apply(DecodedMessage{ + DecodedMessageKind::ServerNotification, + "thread/started", + std::nullopt, + {{"thread", Value(Value::Object{{"id", Value(std::move(id))}, + {"name", Value(std::move(name))}, + {"cwd", Value("/tmp")}})}}})); } -nlohmann::json threadWithAgentMessage(std::string id, std::string name, - std::string message) { - const std::string turnId = id + "-turn"; - const std::string messageId = id + "-message"; - nlohmann::json value = thread(std::move(id), std::move(name)); - value["turns"] = nlohmann::json::array( - {{{"id", turnId}, - {"status", "completed"}, - {"items", nlohmann::json::array({{{"id", messageId}, - {"type", "agentMessage"}, - {"phase", "final_answer"}, - {"text", std::move(message)}}})}}}); - return value; +void makeReady(WorkerLogic &worker) { + static_cast(worker.transportEvent("connected")); + static_cast(worker.bridgeState("test-controller", "controller", + "test-controller", 1, "ready")); } -nlohmann::json threadWithPlanAndAgent(std::string id, std::string name) { - const std::string turnId = id + "-turn"; - const std::string planId = id + "-plan"; - const std::string agentId = id + "-agent"; - nlohmann::json value = thread(std::move(id), std::move(name)); - value["turns"] = nlohmann::json::array( - {{{"id", turnId}, - {"status", "completed"}, - {"items", - nlohmann::json::array({{{"id", planId}, - {"type", "plan"}, - {"text", "retained plan marker"}}, - {{"id", agentId}, - {"type", "subAgentActivity"}, - {"status", "completed"}, - {"prompt", "retained agent marker"}}})}}}); - return value; +void markThreadReady(FrontendSession &session, WorkerLogic &worker, + std::string_view id) { + NodeRef thread; + { + auto read = FrontendSessionTestPeer::graph(session).tryRead(); + thread = read ? read->find({NodeKind::Thread, std::string(id)}) : NodeRef{}; + } + if (thread) + static_cast(worker.threadHydration(thread, "ready")); } -bool selectThread(QListWidget *list, std::string_view id) { +QListWidgetItem *threadItem(QListWidget *list, std::string_view id) { if (!list) - return false; + return nullptr; for (int row = 0; row < list->count(); ++row) { QListWidgetItem *item = list->item(row); - if (item && item->data(Qt::UserRole).toString().toStdString() == id) { - list->setCurrentRow(row); - spin(2); - return true; - } + if (item && item->data(Qt::UserRole).toString().toStdString() == id) + return item; } - return false; + return nullptr; +} + +bool selectThread(QListWidget *list, std::string_view id) { + QListWidgetItem *item = threadItem(list, id); + if (!item) + return false; + list->setCurrentItem(item); + spin(); + return list->currentItem() == item; } bool submit(codexui::ExpandingPromptEditor *editor, const QString &prompt) { @@ -313,8 +183,8 @@ bool submit(codexui::ExpandingPromptEditor *editor, const QString &prompt) { Qt::DirectConnection); } -const middle::LocalPromptData *localPrompt(ShellWidget &shell, - const QString &prompt) { +middle::ConversationCard *localPromptCard(ShellWidget &shell, + std::string_view prompt) { for (QWidget *widget : shell.findChildren()) { auto *card = dynamic_cast(widget); if (!card) @@ -322,797 +192,2459 @@ const middle::LocalPromptData *localPrompt(ShellWidget &shell, const auto *local = std::get_if(&card->data().payload); if (local && local->prompt == prompt) - return local; + return card; } return nullptr; } -middle::ConversationCard *userMessage(ShellWidget &shell, - const std::string &message) { +middle::ConversationCard *agentMessageCard(ShellWidget &shell, + std::string_view message) { for (QWidget *widget : shell.findChildren()) { auto *card = dynamic_cast(widget); if (!card) continue; - const auto *user = - std::get_if(&card->data().payload); - if (user && user->text == message) + const auto *agent = + std::get_if(&card->data().payload); + if (agent && agent->text == message) return card; } return nullptr; } -bool hasAgentMessage(ShellWidget &shell, const QString &message) { - for (QWidget *widget : shell.findChildren()) { - auto *card = dynamic_cast(widget); - if (!card) +void graphNotificationsDetachBeforeRetirement(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + + std::size_t notifications = 0; + bool ordinaryDetached = false; + bool detachedDuringRescan = false; + NodeRef ordinaryRemoved; + NodeRef coalescedRemoved; + int ordinaryAttachment = 1; + int coalescedAttachment = 2; + session.setGraphChangedHandler([&](const GraphChanged &changed) { + ++notifications; + for (const NodeRef &node : changed.removed) { + if (!node) + continue; + if (node->uiAttachment() == &ordinaryAttachment) { + ordinaryRemoved = node; + node->setUiAttachment(nullptr); + ordinaryDetached = true; + } else if (node->uiAttachment() == &coalescedAttachment) { + coalescedRemoved = node; + node->setUiAttachment(nullptr); + detachedDuringRescan = changed.rescanRequired; + } + } + }); + + applyThread(worker, "ordinary-removal"); + spin(); + NodeRef ordinary; + { + auto read = session.nodeGraph().tryRead(); + ordinary = + read ? read->find({NodeKind::Thread, "ordinary-removal"}) : NodeRef{}; + } + require(ordinary != nullptr, + "worker update is visible through the shared graph"); + if (ordinary) + ordinary->setUiAttachment(&ordinaryAttachment); + static_cast(worker.apply({DecodedMessageKind::ServerNotification, + "thread/deleted", + std::nullopt, + {{"threadId", Value("ordinary-removal")}}})); + require( + spinUntil([&] { return channels.qtToWorkerSizeApprox() != 0; }), + "Qt receives removal and queues its typed detachment acknowledgement"); + + std::vector commands = takeQtMessages(channels); + NodeRef ordinaryAcknowledgement; + for (QtToWorkerMessage &command : commands) { + if (auto *action = std::get_if(&command); + action && action->kind == NodeActionKind::UiDetached) + ordinaryAcknowledgement = std::move(action->target); + } + require(ordinaryDetached && ordinaryRemoved == ordinary && + ordinaryAcknowledgement == ordinary, + "Qt clears a removed node attachment before acknowledging it"); + static_cast( + worker.acknowledgeUiDetached(std::move(ordinaryAcknowledgement))); + { + auto read = session.nodeGraph().tryRead(); + require(read && read->retiredNodes().empty(), + "the worker releases ordinary retirement only after UiDetached"); + } + + applyThread(worker, "coalesced-removal"); + spin(); + NodeRef coalesced; + { + auto read = session.nodeGraph().tryRead(); + coalesced = + read ? read->find({NodeKind::Thread, "coalesced-removal"}) : NodeRef{}; + } + require(coalesced != nullptr, "coalescing fixture has a live shared node"); + if (coalesced) + coalesced->setUiAttachment(&coalescedAttachment); + + std::size_t fillerCount = 0; + for (;;) { + UiEffect filler; + filler.text = "fill-" + std::to_string(fillerCount); + const ChannelSendStatus status = channels.sendUiEffect(filler); + if (status == ChannelSendStatus::QueueFull) + break; + require(status == ChannelSendStatus::Accepted, + "ordinary worker-to-Qt filler is admitted normally"); + ++fillerCount; + } + require(fillerCount + ThreadChannels::WorkerToQtReservedSlots == + ThreadChannels::WorkerToQtCapacity, + "worker mailbox saturation preserves critical and terminal slots"); + + const ChannelSendStatus coalescedStatus = + worker.apply({DecodedMessageKind::ServerNotification, + "thread/deleted", + std::nullopt, + {{"threadId", Value("coalesced-removal")}}}); + require(coalescedStatus == ChannelSendStatus::CoalescedRescan, + "a saturated graph notification becomes an explicit rescan"); + FrontendSessionTestPeer::drainWorkerMessages(session); + require(detachedDuringRescan && channels.qtToWorkerSizeApprox() == 0 && + channels.workerToQtSizeApprox() != 0, + "Qt detaches a rescan retirement but defers its acknowledgement " + "until every older queued notification has drained"); + while (channels.workerToQtSizeApprox() != 0 || channels.rescanPending()) + FrontendSessionTestPeer::drainWorkerMessages(session); + require(channels.qtToWorkerSizeApprox() != 0, + "Qt acknowledges the detached retirement after the stale backlog"); + + commands = takeQtMessages(channels); + NodeRef coalescedAcknowledgement; + for (QtToWorkerMessage &command : commands) { + if (auto *action = std::get_if(&command); + action && action->kind == NodeActionKind::UiDetached && + action->target == coalesced) + coalescedAcknowledgement = std::move(action->target); + } + require(coalescedRemoved == coalesced && + coalescedAcknowledgement == coalesced, + "rescan retirement carries the stable NodeRef through UiDetached"); + static_cast( + worker.acknowledgeUiDetached(std::move(coalescedAcknowledgement))); + { + auto read = session.nodeGraph().tryRead(); + require(read && read->retiredNodes().empty(), + "coalesced retirement is released after Qt detachment"); + } + require(notifications >= 4, + "eventfd delivery exposes committed changes and synthesized rescan"); +} + +void massRetirementIsSliced(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + + constexpr std::size_t NodeCount = 640; + std::vector nodes; + nodes.reserve(NodeCount); + { + auto write = graph.write(); + for (std::size_t index = 0; index < NodeCount; ++index) { + nodes.emplace_back(write.upsert( + {NodeKind::Item, "mass-retired-" + std::to_string(index)})); + } + static_cast(write.finish()); + } + + int attachment = 1; + for (const NodeRef &node : nodes) + node->setUiAttachment(&attachment); + + std::unordered_set detached; + std::size_t largestBatch = 0; + session.setGraphChangedHandler([&](const GraphChanged &changed) { + if (!changed.rescanRequired) + return; + largestBatch = std::max(largestBatch, changed.removed.size()); + for (const NodeRef &node : changed.removed) { + if (node && node->uiAttachment() != nullptr) { + node->setUiAttachment(nullptr); + detached.insert(node.get()); + } + } + }); + + GraphChange removal; + { + auto write = graph.write(); + for (const NodeRef &node : nodes) + write.remove(node); + removal = write.finish(); + } + require(channels.sendGraphChanged(std::move(removal)) == + ChannelSendStatus::CoalescedRescan, + "an oversized mass removal requests an explicit graph rescan even " + "when the worker mailbox has space"); + + FrontendSessionTestPeer::drainWorkerMessages(session); + require(!detached.empty() && detached.size() <= 64 && largestBatch <= 64, + "one Qt pass detaches only a bounded retirement batch"); + + std::size_t acknowledgements = 0; + for (std::size_t pass = 0; pass < 128; ++pass) { + FrontendSessionTestPeer::drainWorkerMessages(session); + std::vector commands = takeQtMessages(channels); + for (QtToWorkerMessage &command : commands) { + auto *action = std::get_if(&command); + if (!action || action->kind != NodeActionKind::UiDetached) + continue; + ++acknowledgements; + static_cast( + worker.acknowledgeUiDetached(std::move(action->target))); + } + auto read = graph.tryRead(); + if (read && read->retiredCount() == 0 && + channels.workerToQtSizeApprox() == 0 && + channels.qtToWorkerSizeApprox() == 0) + break; + } + + auto read = graph.tryRead(); + require(detached.size() == NodeCount && acknowledgements == NodeCount && + largestBatch <= 64 && read && read->retiredCount() == 0, + "sliced mass retirement detaches and acknowledges every NodeRef " + "exactly once without an unbounded Qt graph read"); +} + +void selectedRemovalUnbindsBeforeWorkerRetirement( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "removed-selected", "Removed selected"); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("removed-selected")}, + {"turnId", Value("removed-turn")}, + {"item", Value(Value::Object{{"id", Value("removed-item")}, + {"type", Value("agentMessage")}, + {"text", Value("remove this card")}})}}})); + markThreadReady(session, worker, "removed-selected"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require( + spinUntil( + [&] { return threadItem(list, "removed-selected") != nullptr; }) && + selectThread(list, "removed-selected") && spinUntil([&] { + return agentMessageCard(shell, "remove this card") != nullptr; + }), + "selected-removal fixture materializes a graph-attached row and card"); + static_cast(takeQtMessages(channels)); // discard hydration + + static_cast(worker.apply({DecodedMessageKind::ServerNotification, + "thread/deleted", + std::nullopt, + {{"threadId", Value("removed-selected")}}})); + require( + spinUntil([&] { return channels.qtToWorkerSizeApprox() != 0; }), + "selected removal detaches Qt and queues retirement acknowledgements"); + std::vector acknowledgements = takeQtMessages(channels); + std::size_t released = 0; + for (QtToWorkerMessage &message : acknowledgements) { + auto *action = std::get_if(&message); + if (!action || action->kind != NodeActionKind::UiDetached) continue; - const auto *agent = - std::get_if(&card->data().payload); - if (agent && agent->text == message) - return true; + static_cast(worker.acknowledgeUiDetached(std::move(action->target))); + ++released; } - return false; + spin(60); // exercises every deferred binding/render after releaseRetired() + + auto read = graph.tryRead(); + require(released != 0 && read && read->retiredNodes().empty() && + !threadItem(list, "removed-selected") && + !agentMessageCard(shell, "remove this card"), + "deferred Qt work retains no released selected-thread NodeRef"); } -bool hasPresentedText(QWidget &root, const QString &marker) { - return std::ranges::any_of( - root.findChildren(), [&marker](QLabel *label) { - return label && - (label->text().contains(marker) || - label->property("markdownSource").toString().contains(marker)); - }); +void removedAffectedOptimisticRetryDoesNotReadReleasedNode( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + spin(30); + + auto *newThread = + shell.findChild(QStringLiteral("threadNewButton")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + if (newThread) + newThread->click(); + spin(30); + auto *list = shell.findChild(QStringLiteral("threadList")); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + require(newThread && threadItem(list, "draft:new-thread") && editor && + submit(editor, QStringLiteral("retain optimistic selection")), + "same-transaction removal fixture starts an optimistic creation"); + static_cast(takeQtMessages(channels)); + + GraphChange affectedAndRemoved; + NodeRef removedPrompt; + { + auto write = graph.write(); + NodeRef thread = write.upsert({NodeKind::Thread, "transient-thread"}); + NodeRef turn = write.upsert({NodeKind::Turn, "transient-turn"}); + NodeState state; + state.status = NodeStatus::Pending; + state.fields = {{"type", Value("localPrompt")}, + {"local", Value(true)}, + {"createsThread", Value(true)}, + {"submissionId", Value(std::uint64_t{99})}, + {"dispatchState", Value("queued")}}; + removedPrompt = write.upsert({NodeKind::Item, "transient-local-prompt"}, + std::move(state)); + write.setParent(thread, turn); + write.setParent(turn, removedPrompt); + write.remove(removedPrompt); + affectedAndRemoved = write.finish(); + } + require(std::ranges::find(affectedAndRemoved.affected, removedPrompt) != + affectedAndRemoved.affected.end() && + std::ranges::find(affectedAndRemoved.removed, removedPrompt) != + affectedAndRemoved.removed.end() && + messageAdmitted( + channels.sendGraphChanged(std::move(affectedAndRemoved))), + "one notification may carry the same stable ref as affected and " + "removed"); + + // Force every graph-reading handler onto its non-blocking Qt retry path. + // FrontendSession can still collect the stable removed ref and defer graph + // membership release until its typed acknowledgement reaches the worker. + auto contended = graph.write(); + FrontendSessionTestPeer::drainWorkerMessages(session); + static_cast(contended.finish()); + + std::vector acknowledgements = takeQtMessages(channels); + bool released = false; + for (QtToWorkerMessage &message : acknowledgements) { + auto *action = std::get_if(&message); + if (!action || action->kind != NodeActionKind::UiDetached || + action->target != removedPrompt) + continue; + static_cast(worker.acknowledgeUiDetached(std::move(action->target))); + released = true; + } + spin(80); // executes the delayed reconciliation after releaseRetired() + + auto read = graph.tryRead(); + require(released && read && read->retiredNodes().empty() && + !read->find(removedPrompt->id()) && + threadItem(list, "draft:new-thread") == list->currentItem(), + "delayed optimistic reconciliation validates graph membership " + "before reading a released affected-and-removed ref"); } -struct ShellFlow { - PresentationPeer &peer; - ShellWidget shell; - QListWidget *list; - codexui::ExpandingPromptEditor *editor; - std::uint64_t sequence = 1; - std::uint64_t generation = 1; - std::string startBCorrelation; - - ShellFlow(FrontendSession &session, PresentationPeer &peer) - : peer(peer), shell(session) { - shell.resize(1500, 850); - shell.show(); - spin(10); - list = shell.findChild(QStringLiteral("threadList")); - editor = shell.findChild( - QStringLiteral("upcomingPromptEditor")); +void typedActionsAreExactOnceAndBounded(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + applyThread(worker, "typed-action-target"); + spin(); + + NodeRef target; + { + auto read = session.nodeGraph().tryRead(); + target = read ? read->find({NodeKind::Thread, "typed-action-target"}) + : NodeRef{}; } - bool completeSettingsRefresh(const std::string &threadId) { - const auto request = peer.waitFor("thread.resume", threadId); - const std::string message = - threadId + " receives its metadata-only settings refresh"; - if (!expect(request.has_value(), message.c_str()) || !request) - return false; - return peer.send( - presentation::result(sequence++, generation, "thread.resume", - request->value("correlationId", std::string{}), - true, {{"thread", {{"id", threadId}}}}, - Authority::Merge, {{"threadId", threadId}})); + NodeAction authored; + authored.target = target; + authored.kind = NodeActionKind::SubmitPrompt; + authored.promptText = " exact authored prompt "; + authored.attachments.push_back( + {"/tmp/exact.bin", "exact.bin", "application/octet-stream", + std::vector{0, 1, 2, 127, 254, 255}}); + authored.payload.emplace("model", Value("current-model")); + authored.correlation = "typed-once"; + const char *promptStorage = authored.promptText.data(); + const std::uint8_t *attachmentStorage = + authored.attachments.front().bytes->data(); + require(session.sendNodeAction(authored) == ChannelSendStatus::Accepted && + !authored.target && authored.promptText.empty() && + authored.attachments.empty(), + "typed admission moves newly-authored data out of Qt exactly once"); + + const EventFd::DrainResult exactWake = channels.drainQtToWorkerWake(); + QtToWorkerMessage received; + const bool gotOne = channels.tryReceiveForWorker(received); + const NodeAction *receivedAction = + gotOne ? std::get_if(&received) : nullptr; + require(exactWake.status == EventFd::DrainStatus::Drained && + exactWake.count == 1 && receivedAction && + receivedAction->target == target && + receivedAction->kind == NodeActionKind::SubmitPrompt && + receivedAction->promptText == " exact authored prompt " && + receivedAction->promptText.data() == promptStorage && + receivedAction->attachments.front().bytes->data() == + attachmentStorage && + receivedAction->correlation == "typed-once" && + !channels.tryReceiveForWorker(received), + "one Qt action produces one FIFO payload and one wake"); + + RuntimeAction naturallyAuthored{RuntimeActionKind::ConfigureConnection}; + naturallyAuthored.payload = {{"transport", Value("invalid-for-fixture")}}; + require(session.sendRuntimeAction(naturallyAuthored) == + ChannelSendStatus::Accepted, + "the frontend admits a naturally authored action without requiring " + "widgets to manufacture protocol correlation"); + const EventFd::DrainResult correlatedWake = channels.drainQtToWorkerWake(); + const bool gotCorrelated = channels.tryReceiveForWorker(received); + const RuntimeAction *correlatedAction = + gotCorrelated ? std::get_if(&received) : nullptr; + require(correlatedWake.status == EventFd::DrainStatus::Drained && + correlatedWake.count == 1 && correlatedAction && + correlatedAction->correlation.starts_with("ui-action-") && + !channels.tryReceiveForWorker(received), + "FrontendSession assigns one bounded correlation before enqueueing " + "a real UI action"); + + std::size_t admissions = 0; + for (;;) { + RuntimeAction filler; + filler.kind = RuntimeActionKind::RefreshThreads; + filler.correlation = "filler-" + std::to_string(admissions); + const ChannelSendStatus status = session.sendRuntimeAction(filler); + if (status == ChannelSendStatus::QueueFull) + break; + require(status == ChannelSendStatus::Accepted, + "ordinary typed filler is admitted normally"); + ++admissions; } + require(admissions + 1 == ThreadChannels::QtToWorkerCapacity, + "Qt mailbox reserves one bounded slot for shutdown"); + + NodeAction rejected; + rejected.target = target; + rejected.kind = NodeActionKind::SubmitPrompt; + rejected.promptText = "retain this input"; + rejected.attachments.push_back( + {"/tmp/retained.txt", "retained.txt", "text/plain", std::nullopt}); + const NodeAction unchanged = rejected; + require(session.sendNodeAction(rejected) == ChannelSendStatus::QueueFull && + rejected == unchanged, + "queue saturation rejects visibly without consuming user input"); + + const EventFd::DrainResult saturatedWake = channels.drainQtToWorkerWake(); + std::size_t drained = 0; + bool onlyFillers = true; + while (channels.tryReceiveForWorker(received)) { + const RuntimeAction *filler = std::get_if(&received); + onlyFillers = onlyFillers && filler && + filler->correlation == "filler-" + std::to_string(drained); + ++drained; + } + require(saturatedWake.status == EventFd::DrainStatus::Drained && + saturatedWake.count == admissions && drained == admissions && + onlyFillers, + "a rejected non-idempotent action adds no payload and no wake"); +} - bool verifyHydrationAndNavigation(); - bool verifyPromptLifecycle(); - bool verifyReconnectHydration(); - bool verifyTerminalCallback(); - bool verifyBoundedChildHydration(); - bool verifyNotFoundRecovery(); - bool verifyFailedHydration(); - bool verifyOptimisticNewThread(); - bool verifyPendingResolutionBoundary(); - - bool run() { - return verifyHydrationAndNavigation() && verifyPromptLifecycle() && - verifyReconnectHydration() && verifyTerminalCallback() && - verifyBoundedChildHydration() && verifyNotFoundRecovery() && - verifyFailedHydration() && verifyOptimisticNewThread() && - verifyPendingResolutionBoundary(); +void qtHeartbeatSurvivesLargeInboundTraffic(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "traffic-thread", "Traffic thread"); + markThreadReady(session, worker, "traffic-thread"); + auto *threadList = + shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { + return threadItem(threadList, "traffic-thread") != nullptr; + }), + "large-traffic fixture materializes the existing thread list"); + require(selectThread(threadList, "traffic-thread"), + "large-traffic fixture binds the real conversation view"); + static_cast(takeQtMessages(channels)); // discard the hydration action + + std::atomic_bool producerFinished = false; + std::atomic_bool midpointReady = false; + std::atomic_bool abortWait = false; + std::atomic_bool heartbeatObservedAtMidpoint = false; + std::atomic_size_t coalescedNotifications = 0; + std::atomic_uint64_t heartbeatCount = 0; + std::uint64_t heartbeatsWithBacklog = 0; + + QTimer heartbeat; + heartbeat.setInterval(0); + QObject::connect(&heartbeat, &QTimer::timeout, &heartbeat, [&] { + heartbeatCount.fetch_add(1, std::memory_order_relaxed); + if (channels.workerToQtSizeApprox() != 0 || channels.rescanPending()) + ++heartbeatsWithBacklog; + }); + heartbeat.start(); + + constexpr std::size_t DeltaCount = 4096; + std::thread producer([&] { + const auto publish = [&](DecodedMessage message) { + const ChannelSendStatus status = worker.apply(std::move(message)); + if (status == ChannelSendStatus::CoalescedRescan || + status == ChannelSendStatus::CoalescedRescanWakeFailed) + coalescedNotifications.fetch_add(1, std::memory_order_relaxed); + }; + publish( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value("traffic-thread")}, + {"turn", Value(Value::Object{{"id", Value("traffic-turn")}, + {"status", Value("inProgress")}})}}}); + publish({DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("traffic-thread")}, + {"turnId", Value("traffic-turn")}, + {"item", Value(Value::Object{{"id", Value("traffic-item")}, + {"type", Value("agentMessage")}, + {"text", Value("")}})}}}); + + for (std::size_t index = 0; index < DeltaCount; ++index) { + const bool lastItem = index + 1 == DeltaCount; + publish({DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("traffic-thread")}, + {"turnId", Value("traffic-turn")}, + {"item", + Value(Value::Object{ + {"id", Value("background-item-" + std::to_string(index))}, + {"type", Value(lastItem ? "agentMessage" : "reasoning")}, + {"text", Value(lastItem ? "latest visible item" : "")}, + {"status", Value("running")}})}}, + {}, + static_cast(index + 1)}); + publish({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", + std::nullopt, + {{"threadId", Value("traffic-thread")}, + {"turnId", Value("traffic-turn")}, + {"itemId", Value("traffic-item")}, + {"delta", Value("x")}}}); + if (index == DeltaCount / 2) { + midpointReady.store(true, std::memory_order_release); + const std::uint64_t before = + heartbeatCount.load(std::memory_order_acquire); + while (!abortWait.load(std::memory_order_acquire) && + heartbeatCount.load(std::memory_order_acquire) == before) + std::this_thread::yield(); + heartbeatObservedAtMidpoint.store( + heartbeatCount.load(std::memory_order_acquire) != before, + std::memory_order_release); + } + } + producerFinished.store(true, std::memory_order_release); + }); + + QElapsedTimer deadline; + deadline.start(); + // Let the worker establish a real saturated backlog before Qt begins + // pumping events. The worker then pauses at the midpoint until the Qt timer + // proves it can run while that backlog is being drained. + while (!midpointReady.load(std::memory_order_acquire) && + deadline.elapsed() < 5000) + std::this_thread::yield(); + while (!producerFinished.load(std::memory_order_acquire) && + deadline.elapsed() < 5000) + QCoreApplication::processEvents(QEventLoop::AllEvents, 10); + abortWait.store(true, std::memory_order_release); + producer.join(); + const bool drained = spinUntil( + [&] { + return channels.workerToQtSizeApprox() == 0 && + !channels.rescanPending(); + }, + 2000); + const bool renderedLatest = spinUntil( + [&] { return agentMessageCard(shell, "latest visible item") != nullptr; }, + 2000); + heartbeat.stop(); + + std::string streamedText; + std::uint64_t loadedItems = 0; + std::int64_t latestActivity = 0; + { + auto read = session.nodeGraph().tryRead(); + const NodeRef item = + read ? read->find(scopedItemNodeId( + scopedTurnNodeId("traffic-thread", "traffic-turn"), + "traffic-item")) + : NodeRef{}; + const Value *textValue = item ? field(read->state(item), "text") : nullptr; + if (textValue && textValue->asString()) + streamedText = *textValue->asString(); + const NodeRef thread = + read ? read->find({NodeKind::Thread, "traffic-thread"}) : NodeRef{}; + const Value *count = + thread ? field(read->state(thread), "historyLoadedItemCount") : nullptr; + const Value *activity = + thread ? field(read->state(thread), "localActivityAt") : nullptr; + if (count && count->asUInt64()) + loadedItems = *count->asUInt64(); + if (activity && activity->asInt64()) + latestActivity = *activity->asInt64(); } -}; + require(producerFinished.load(std::memory_order_acquire), + "the distinct-item producer completes within the responsiveness " + "budget"); + require(drained, "Qt drains the bounded graph notification backlog"); + require(heartbeatObservedAtMidpoint.load(std::memory_order_acquire) && + heartbeatCount.load(std::memory_order_acquire) > 1 && + heartbeatsWithBacklog > 0, + "Qt heartbeat runs while the distinct-item backlog is nonempty"); + require(renderedLatest, + "the visible existing widget reaches the latest streamed state"); + require(streamedText.size() == DeltaCount && loadedItems == DeltaCount + 1 && + latestActivity == static_cast(DeltaCount), + "large inbound traffic leaves complete distinct-item, stream, and " + "thread-activity state"); + require(coalescedNotifications.load(std::memory_order_acquire) != 0, + "large inbound traffic uses explicit notification coalescing"); +} -bool ShellFlow::verifyHydrationAndNavigation() { - bool result = true; - - result &= peer.send(presentation::event(sequence++, 1, "connection.lifecycle", - {{"state", "connected"}}, - Authority::Merge)); - result &= peer.send(presentation::event(sequence++, 1, "connection.bridge", - {{"state", "opened"}, - {"connectionId", "test-controller"}, - {"role", "controller"}}, - Authority::Merge)); - result &= peer.send(presentation::event( - sequence++, 1, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "ready"}}, - Authority::Replace)); - result &= peer.send(presentation::event( - sequence++, 1, "thread.upsert", {{"thread", thread("thread-a", "A")}}, - Authority::Merge, {{"threadId", "thread-a"}})); - result &= peer.send(presentation::event( - sequence++, 1, "thread.upsert", {{"thread", thread("thread-b", "B")}}, - Authority::Merge, {{"threadId", "thread-b"}})); - spin(10); - peer.discard(); // bridge bootstrap operations are outside this scenario. - - result &= expect(selectThread(list, "thread-a"), - "the visible A row becomes the prompt destination"); - const auto readA = peer.waitFor("thread.read", "thread-a"); - result &= expect(readA.has_value(), "selecting A requests hydration"); - if (!readA) - return false; - result &= expect(selectThread(list, "thread-b"), - "B can be selected while A is still hydrating"); - const auto readB = peer.waitFor("thread.read", "thread-b"); - result &= expect(readB.has_value(), "selecting B requests its own hydration"); - if (!readB) - return false; - result &= peer.send( - presentation::event(sequence++, 1, "thread.upsert", - {{"thread", thread("thread-a", "A", "notLoaded")}}, - Authority::Merge, {{"threadId", "thread-a"}})); - result &= peer.send(presentation::result( - sequence++, 1, "thread.read", - readA->value("correlationId", std::string{}), true, - {{"thread", threadWithPlanAndAgent("thread-a", "A")}}, Authority::Replace, - {{"threadId", "thread-a"}})); - result &= completeSettingsRefresh("thread-a"); - result &= peer.send( - presentation::result(sequence++, 1, "thread.read", - readB->value("correlationId", std::string{}), true, - {{"thread", thread("thread-b", "B")}}, - Authority::Replace, {{"threadId", "thread-b"}})); - result &= completeSettingsRefresh("thread-b"); - spin(10); - result &= expect(selectThread(list, "thread-a"), - "A can be selected again after background hydration"); - spin(10); - auto *inspector = shell.findChild(QStringLiteral("inspector")); - auto *inspectorTabs = inspector ? inspector->findChild( - QString{}, Qt::FindDirectChildrenOnly) - : nullptr; - result &= expect( - inspector && inspectorTabs && - hasPresentedText(*inspector, QStringLiteral("retained plan marker")), - "A's retained plan survives background hydration and navigation"); - if (inspectorTabs) { - inspectorTabs->setCurrentIndex(1); - spin(); +void graphBackedShellPreservesDraftsAndPrompts(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "shell-thread", "Shared graph thread"); + markThreadReady(session, worker, "shell-thread"); + require(spinUntil([&] { + auto *list = + shell.findChild(QStringLiteral("threadList")); + return threadItem(list, "shell-thread") != nullptr; + }), + "the existing thread widget materializes from shared graph nodes"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(selectThread(list, "shell-thread"), + "selecting the graph-backed row binds the existing conversation"); + static_cast(takeQtMessages(channels)); // Hydrate is tested elsewhere. + + channels.failNextWorkerToQtWakeForTest(); + const ChannelSendStatus wakeFailure = worker.apply( + {DecodedMessageKind::ServerNotification, "thread/name/updated", + std::nullopt, + Value::Object{{"threadId", Value("shell-thread")}, + {"threadName", Value("Wake-recovered thread")}}}); + const bool wakeRecovered = spinUntil([&] { + QListWidgetItem *item = threadItem(list, "shell-thread"); + QWidget *row = item ? list->itemWidget(item) : nullptr; + QLabel *title = + row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; + return channels.workerToQtSizeApprox() == 0 && title && + title->text() == QStringLiteral("Wake-recovered thread"); + }); + require(wakeFailure == ChannelSendStatus::AcceptedWakeFailed && + deliveryGuaranteed(wakeFailure) && wakeFailed(wakeFailure) && + wakeRecovered, + "Qt's bounded recovery drain renders a graph update after a failed " + "worker wake"); + + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + const QString exact = QStringLiteral(" graph prompt stays exact "); + const QString trimmed = exact.trimmed(); + require(submit(editor, exact), "the real composer emits its submit action"); + std::vector actions = takeQtMessages(channels); + NodeAction prompt; + std::size_t promptCount = 0; + for (QtToWorkerMessage &message : actions) { + if (auto *action = std::get_if(&message); + action && action->kind == NodeActionKind::SubmitPrompt) { + prompt = std::move(*action); + ++promptCount; + } } - result &= expect( - inspector && - hasPresentedText(*inspector, QStringLiteral("retained agent marker")), - "A's retained agent detail survives background hydration and navigation"); - return result; + require(promptCount == 1 && prompt.target && + prompt.target->id() == NodeId{NodeKind::Thread, "shell-thread"} && + prompt.promptText == trimmed.toStdString() && editor && + editor->toPlainText().isEmpty(), + "the composer emits one typed prompt with legacy whitespace " + "normalization"); + + PromptTransition transition = worker.admitPrompt(std::move(prompt)); + const NodeRef localPrompt = + transition.command ? transition.command->localPrompt : NodeRef{}; + require( + localPrompt != nullptr, + "the worker turns an admitted action into the one shared prompt node"); + require(spinUntil([&] { + return localPromptCard(shell, trimmed.toStdString()) != nullptr; + }), + "the visible existing card renders directly from the prompt node"); + middle::ConversationCard *card = + localPromptCard(shell, trimmed.toStdString()); + QTimer *pendingAnimation = + card ? card->findChild(QStringLiteral("pendingAnimationTimer")) + : nullptr; + require(card && card->property("pendingFeedbackVisible").toBool() && + pendingAnimation && pendingAnimation->isActive(), + "the optimistically inserted Turn/You card starts its pending " + "animation immediately"); + + editor->setPlainText(QStringLiteral("unsent editor draft")); + static_cast(worker.apply({DecodedMessageKind::ClientResult, + "model/list", + ProtocolRequestId("catalog-refresh"), + {{"models", Value(Value::Array{})}}})); + spin(40); + require( + editor->toPlainText() == QStringLiteral("unsent editor draft") && + localPromptCard(shell, trimmed.toStdString()) == card, + "unrelated graph updates preserve local editor text and card identity"); } -bool ShellFlow::verifyPromptLifecycle() { - bool result = true; +void initialHydrationUsesTheEstablishedBoundedWindow( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "bounded-history", "Bounded history"); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value("bounded-history")}, + {"turn", Value(Value::Object{{"id", Value("bounded-turn")}, + {"status", Value("completed")}})}}})); + for (std::size_t index = 0; index < 100; ++index) { + const std::string id = "bounded-item-" + std::to_string(index); + const std::string type = index == 0 ? "userMessage" : "agentMessage"; + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value("bounded-history")}, + {"turnId", Value("bounded-turn")}, + {"item", Value(Value::Object{{"id", Value(id)}, + {"type", Value(type)}, + {"text", Value(id)}})}}})); + } - result &= expect(submit(editor, QStringLiteral("prompt A1")), - "A1 is admitted through the real composer"); - const auto startA = peer.waitFor("turn.start", "thread-a"); - result &= expect(startA.has_value() && !peer.has("thread.create"), - "A1 starts on selected A and never creates a new thread"); - if (!startA) - return false; - const nlohmann::json startAData = - startA->value("data", nlohmann::json::object()); - const std::string clientId = - startAData.value("clientUserMessageId", std::string{}); - result &= expect(!clientId.empty(), - "turn.start carries the prompt correlation identity"); - - result &= expect(submit(editor, QStringLiteral("prompt A2")), - "A2 remains independently editable while A1 awaits ack"); - spin(20); - result &= expect(!peer.has("turn.steer"), - "A2 waits behind the one in-flight operation for A"); - - result &= peer.send(presentation::event( - sequence++, 1, "conversation.item.upsert", - {{"item", - {{"id", "user-a1"}, - {"type", "userMessage"}, - {"clientId", clientId}, - {"content", {{{"type", "text"}, {"text", "prompt A1"}}}}}}}, - Authority::Merge, - {{"threadId", "thread-a"}, - {"turnId", "turn-a-live"}, - {"itemId", "user-a1"}})); - spin(10); - const middle::LocalPromptData *beforeAck = - localPrompt(shell, QStringLiteral("prompt A1")); - result &= - expect(beforeAck && beforeAck->state == middle::PromptState::InFlight, - "materialization alone cannot acknowledge A1"); - - editor->setPlainText(QStringLiteral("unsent shared draft")); - result &= expect(selectThread(list, "thread-b"), - "B can be selected while A remains active"); - result &= - expect(editor->toPlainText() == QStringLiteral("unsent shared draft"), - "thread navigation retains the shared composer draft"); - result &= expect(!peer.waitFor("thread.read", "thread-b", 100).has_value(), - "returning to hydrated B does not reread its history"); - result &= expect(submit(editor, QStringLiteral("prompt B1")), - "B1 is admitted while A1 is in flight"); - spin(20); - result &= - expect(list && list->item(0) && - list->item(0)->data(Qt::UserRole).toString().toStdString() == - "thread-b" && - list->currentItem() == list->item(0), - "real prompt admission immediately promotes B under Recent"); - const auto startB = peer.waitFor("turn.start", "thread-b"); - result &= - expect(startB.has_value(), "different threads dispatch independently"); - if (startB) - startBCorrelation = startB->value("correlationId", std::string{}); - - result &= peer.send(presentation::result( - sequence++, 1, "turn.start", - startA->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-a-live"}}}}, Authority::Merge, - {{"threadId", "thread-a"}, {"turnId", "turn-a-live"}})); - const auto steerA = peer.waitFor("turn.steer", "thread-a"); - result &= - expect(steerA.has_value(), "A1's real background ack releases queued A2"); - result &= expect( - list && list->currentItem() && - list->currentItem()->data(Qt::UserRole).toString().toStdString() == - "thread-b", - "a background acknowledgment does not change selection"); - - peer.discard(); - result &= expect(selectThread(list, "thread-a"), - "switching back restores A's retained prompt state"); - spin(10); - result &= expect( - !peer.waitFor("thread.read", "thread-a", 100).has_value(), - "switching back to hydrated A does not issue a destructive reread"); - middle::ConversationCard *promoted = userMessage(shell, "prompt A1"); - result &= expect( - promoted && promoted->property("authoritativeTurnActive").toBool(), - "the correlated result promotes A1 immediately and keeps its emphasized " - "border while the separate " - "active-turn event is delayed"); - result &= peer.send(presentation::event( - sequence++, 1, "turn.upsert", - {{"turn", {{"id", "turn-a-live"}, {"status", "inProgress"}}}}, - Authority::Merge, {{"threadId", "thread-a"}, {"turnId", "turn-a-live"}})); - spin(10); - result &= expect( - promoted && promoted->property("authoritativeTurnActive").toBool(), - "authoritative active-turn ownership replaces the provisional handoff " - "without a neutral border state"); - return result; + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { return threadItem(list, "bounded-history"); }), + "bounded history appears in the established thread pane"); + require(selectThread(list, "bounded-history"), + "bounded history can be selected"); + static_cast(takeQtMessages(channels)); + require(shell.findChildren().empty(), + "partial pre-hydration history creates no conversation QWidget"); + + markThreadReady(session, worker, "bounded-history"); + auto *conversation = dynamic_cast( + shell.findChild( + QStringLiteral("conversationScroll"))); + require(conversation && spinUntil([&] { + return conversation->structuralStagingActive(); + }), + "large initial history enters bounded hidden Qt staging"); + require(shell.findChildren().empty(), + "hidden preparation exposes no partial card tree"); + const qulonglong stageStarts = + conversation->property("structuralStageStarts").toULongLong(); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", + std::nullopt, + {{"threadId", Value("bounded-history")}, + {"turnId", Value("bounded-turn")}, + {"itemId", Value("bounded-item-99")}, + {"delta", Value(" latest")}}})); + int responsiveHeartbeats = 0; + QTimer heartbeat; + QObject::connect(&heartbeat, &QTimer::timeout, + [&responsiveHeartbeats] { ++responsiveHeartbeats; }); + heartbeat.start(0); + require(spinUntil( + [&] { + return shell.findChildren().size() == + middle::AuthoritativeHistoryPageSize + 1; + }, + 2000), + "the first atomic frame contains the retained 80 activities and " + "their pinned owning prompt"); + heartbeat.stop(); + require(responsiveHeartbeats > 2 && conversation && + conversation->property("structuralStageCardPasses") + .toULongLong() >= + middle::AuthoritativeHistoryPageSize, + "initial rich-card construction yields repeatedly to the Qt event " + "loop before its single visible commit"); + require(conversation->property("structuralStageStarts").toULongLong() == + stageStarts && + agentMessageCard(shell, "bounded-item-99 latest"), + "a live canonical update patches the hidden target without " + "restarting or starving structural staging"); + std::cout << "atomic structural commit ms: " + << conversation->property("structuralStageCommitMillis") + .toLongLong() + << '\n'; + require(conversation && + conversation->property("structuralStageCommitMillis").toLongLong() < + 100, + "the atomic reveal does not move bulk widget construction back into " + "one perceptible final-frame stall"); + + QPushButton *loadMore = nullptr; + for (QPushButton *button : shell.findChildren()) { + if (button && button->text().startsWith(QStringLiteral("Load "))) { + loadMore = button; + break; + } + } + require(loadMore && loadMore->isVisible() && + loadMore->text() == QStringLiteral("Load 19 more activities"), + "the old Load More surface reports only unrepresented retained " + "activities after pinning the structural root"); + if (loadMore) + loadMore->click(); + require(conversation && spinUntil([&] { + return conversation->structuralStagingActive(); + }), + "Load More prepares missing retained cards off-surface"); + require(shell.findChildren().size() == + middle::AuthoritativeHistoryPageSize + 1, + "Load More keeps the complete old surface visible until the new " + "surface is ready"); + require(spinUntil( + [&] { + return shell.findChildren().size() == + 100; + }, + 2000), + "Load More materializes the retained graph page in one old-UI " + "reconcile"); + const std::vector messages = takeQtMessages(channels); + require(std::ranges::none_of(messages, [](const QtToWorkerMessage &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::LoadHistory; + }), + "Load More does not request the provider while retained graph " + "history remains"); } -bool ShellFlow::verifyReconnectHydration() { - bool result = true; - - result &= peer.send(presentation::event( - sequence++, 1, "thread.upsert", {{"thread", thread("thread-c", "C")}}, - Authority::Merge, {{"threadId", "thread-c"}})); - spin(5); - result &= expect(selectThread(list, "thread-c"), - "C is selected for hydration supersession coverage"); - const auto readC1 = peer.waitFor("thread.read", "thread-c"); - result &= expect(readC1.has_value(), "C issues its first hydration read"); - if (!readC1) - return false; - result &= - expect(submit(editor, QStringLiteral("prompt C queued across restart")), - "a prompt can queue behind C's in-flight hydration"); - result &= expect(!peer.waitFor("turn.start", "thread-c", 100).has_value(), - "the queued prompt waits for authoritative hydration"); - - result &= peer.send(presentation::event( - sequence++, 1, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, - Authority::Replace)); - spin(10); - const auto *status = - shell.findChild(QStringLiteral("globalStatusLabel")); - result &= - expect(status && status->text() == QStringLiteral("Provider unavailable"), - "provider loss cannot leave the shell visibly Ready"); - peer.discard(); - result &= - expect(submit(editor, QStringLiteral("provider unavailable")), - "the editable composer reaches the guarded admission boundary"); - spin(5); - result &= - expect(editor->toPlainText() == QStringLiteral("provider unavailable") && - !peer.has("turn.start") && !peer.has("turn.steer"), - "provider loss rejects a stale hidden-thread destination without " - "clearing the draft"); - - generation = 2; - result &= peer.send(presentation::event(sequence++, 2, "connection.lifecycle", - {{"state", "disconnected"}}, - Authority::Merge)); - result &= peer.send(presentation::event(sequence++, 2, "connection.lifecycle", - {{"state", "connected"}}, - Authority::Merge)); - result &= - peer.send(presentation::event(sequence++, 2, "connection.bridge", - {{"state", "opened"}, - {"connectionId", "test-controller-2"}, - {"role", "controller"}}, - Authority::Merge)); - result &= peer.send(presentation::event( - sequence++, 2, "connection.provider", - {{"generation", std::uint64_t{2}}, {"state", "ready"}}, - Authority::Replace)); - const auto reconnectedList = peer.waitFor("threads.list"); - result &= expect(reconnectedList.has_value(), - "the ready provider requests a fresh authoritative list"); - if (!reconnectedList) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "threads.list", - reconnectedList->value("correlationId", std::string{}), true, - {{"threads", - nlohmann::json::array({thread("thread-a", "A"), thread("thread-b", "B"), - thread("thread-c", "C")})}}, - Authority::Merge)); - const auto readC2 = peer.waitFor("thread.read", "thread-c"); - result &= expect(readC2.has_value(), - "the new connection owns a fresh hydration read"); - if (!readC2) - return false; - const auto readB2 = peer.waitFor("thread.read", "thread-b"); - result &= expect(readB2.has_value(), - "the restart also rehydrates B's interrupted prompt queue"); - if (!readB2) - return false; - result &= peer.send( - presentation::result(sequence++, 2, "thread.read", - readB2->value("correlationId", std::string{}), true, - {{"thread", thread("thread-b", "B")}}, - Authority::Replace, {{"threadId", "thread-b"}})); - const auto resumedStartB = peer.waitFor("turn.start", "thread-b"); - result &= - expect(resumedStartB.has_value(), - "B's interrupted in-flight prompt is reissued after hydration"); - if (!resumedStartB) - return false; - startBCorrelation = resumedStartB->value("correlationId", std::string{}); - result &= peer.send(presentation::result( - sequence++, 2, "thread.read", - readC2->value("correlationId", std::string{}), true, - {{"thread", threadWithAgentMessage("thread-c", "C", "current C marker")}}, - Authority::Replace, {{"threadId", "thread-c"}})); - result &= peer.send( - presentation::result(sequence++, 2, "thread.read", - readC1->value("correlationId", std::string{}), true, - {{"thread", thread("thread-c", "stale C")}}, - Authority::Replace, {{"threadId", "thread-c"}})); - result &= completeSettingsRefresh("thread-c"); - const auto resumedStartC = peer.waitFor("turn.start", "thread-c"); - result &= expect(resumedStartC.has_value(), - "a transient provider restart preserves and dispatches C's " - "queued prompt"); - if (!resumedStartC) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "turn.start", - resumedStartC->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-c-reconnected"}, {"status", "completed"}}}}, - Authority::Replace, - {{"threadId", "thread-c"}, {"turnId", "turn-c-reconnected"}})); - spin(10); - const middle::LocalPromptData *preserved = - localPrompt(shell, QStringLiteral("prompt C queued across restart")); - result &= - expect(preserved && preserved->state == middle::PromptState::Accepted, - "the reconnected turn result acknowledges the preserved prompt"); - result &= expect(hasAgentMessage(shell, QStringLiteral("current C marker")), - "a late successful stale read cannot replace newer cards"); - peer.discard(); - result &= expect(submit(editor, QStringLiteral("prompt C1")), - "C remains hydrated after the stale read callback"); - const auto startC = peer.waitFor("turn.start", "thread-c"); - result &= expect(startC.has_value() && !peer.has("thread.read"), - "a stale read cannot overwrite newer hydration state"); - return result; +void completedLiveAgentAppearsWithoutThreadReselection( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "live-final", "Live final"); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + {{"threadId", Value("live-final")}, + {"turn", Value(Value::Object{ + {"id", Value("live-turn")}, + {"items", Value(Value::Array{Value(Value::Object{ + {"id", Value("live-prompt")}, + {"type", Value("userMessage")}, + {"text", Value("Prompt")}})})}})}}})); + markThreadReady(session, worker, "live-final"); + + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { return threadItem(list, "live-final"); }) && + selectThread(list, "live-final"), + "the live completion fixture selects its hydrated thread"); + auto *conversation = dynamic_cast( + shell.findChild( + QStringLiteral("conversationScroll"))); + require(conversation, "the live completion fixture owns a conversation"); + if (!conversation) + return; + auto options = conversation->presentationOptions(); + options.showCodexUpdates = false; + conversation->setPresentationOptions(options); + + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + {{"threadId", Value("live-final")}, + {"turnId", Value("live-turn")}, + {"item", Value(Value::Object{{"id", Value("live-response")}, + {"type", Value("agentMessage")}, + {"phase", Value("final_answer")}})}}})); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, "item/agentMessage/delta", + std::nullopt, + {{"threadId", Value("live-final")}, + {"turnId", Value("live-turn")}, + {"itemId", Value("live-response")}, + {"delta", Value("Visible immediately")}}})); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, "item/completed", std::nullopt, + {{"threadId", Value("live-final")}, + {"turnId", Value("live-turn")}, + {"item", Value(Value::Object{{"id", Value("live-response")}, + {"type", Value("agentMessage")}, + {"phase", Value("final_answer")}, + {"text", Value("Visible immediately")}})}}})); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, "turn/completed", std::nullopt, + {{"threadId", Value("live-final")}, + {"turn", Value(Value::Object{{"id", Value("live-turn")}})}}})); + + require(spinUntil( + [&] { + middle::ConversationCard *card = + agentMessageCard(shell, "Visible immediately"); + return card && !card->isHidden(); + }, + 1000), + "a completed live response becomes visible without thread " + "reselection"); } -bool ShellFlow::verifyTerminalCallback() { - bool result = true; - result &= peer.send(presentation::result( - sequence++, 2, "turn.start", startBCorrelation, false, - {{"code", -32001}, {"message", "transport cancelled"}}, Authority::None, - {{"threadId", "thread-b"}})); - spin(10); - result &= expect(selectThread(list, "thread-b"), - "B remains selectable after reconnection"); - const middle::LocalPromptData *cancelled = - localPrompt(shell, QStringLiteral("prompt B1")); - result &= expect(cancelled && cancelled->state == middle::PromptState::Failed, - "a real failure of the reissued request remains terminal"); - return result; +void threadSwitchStagesTheCompleteReplacement(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + const auto addConversation = [&worker](std::string threadId, + std::string title, + std::string itemId, + std::string text) { + applyThread(worker, threadId, title); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value(threadId)}, + {"turn", Value(Value::Object{{"id", Value(threadId + "-turn")}, + {"status", Value("completed")}})}}})); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value(threadId)}, + {"turnId", Value(threadId + "-turn")}, + {"item", Value(Value::Object{{"id", Value(std::move(itemId))}, + {"type", Value("agentMessage")}, + {"text", Value(std::move(text))}})}}})); + }; + addConversation("staged-a", "Complete A", "a-item", "complete A card"); + addConversation("staged-b", "Hydrating B", "b-item", "partial B card"); + markThreadReady(session, worker, "staged-a"); + + auto *list = shell.findChild(QStringLiteral("threadList")); + auto *heading = + shell.findChild(QStringLiteral("conversationTitle")); + require(spinUntil([&] { + return threadItem(list, "staged-a") && + threadItem(list, "staged-b"); + }), + "staged-switch fixture exposes both canonical rows"); + require(selectThread(list, "staged-a"), + "staged-switch fixture selects the complete source"); + static_cast(takeQtMessages(channels)); + middle::ConversationCard *source = nullptr; + require(spinUntil([&] { + source = agentMessageCard(shell, "complete A card"); + return source && heading && heading->text() == "Complete A"; + }), + "the source conversation is complete before switching"); + + require(selectThread(list, "staged-b"), + "the hydrating replacement becomes the visible row selection"); + static_cast(takeQtMessages(channels)); + spin(80); + require(agentMessageCard(shell, "complete A card") == source && + !agentMessageCard(shell, "partial B card") && heading && + heading->text() == "Complete A", + "a hydrating replacement leaves the complete outgoing surface " + "unchanged and exposes no partial provider cards"); + + markThreadReady(session, worker, "staged-b"); + require(spinUntil([&] { + return agentMessageCard(shell, "partial B card") && + !agentMessageCard(shell, "complete A card") && heading && + heading->text() == "Hydrating B"; + }), + "readiness replaces the staged surface once with the complete " + "incoming conversation and matching heading"); } -bool ShellFlow::verifyBoundedChildHydration() { - bool result = true; - - peer.discard(); - result &= - peer.send(presentation::event(sequence++, 2, "agents.activity.upsert", - {{"activity", - {{"id", "child-failure"}, - {"type", "subAgentActivity"}, - {"status", "started"}, - {"agentThreadId", "child-failure"}}}}, - Authority::Merge, - {{"threadId", "thread-b"}, - {"turnId", "turn-b"}, - {"itemId", "child-failure"}})); - const auto childRead = peer.waitFor("thread.read", "child-failure"); - result &= expect(childRead.has_value(), - "a started historical child is hydrated once"); - if (!childRead) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "thread.read", - childRead->value("correlationId", std::string{}), false, - {{"code", -32002}, {"message", "child hydration failed"}}, - Authority::None, {{"threadId", "child-failure"}})); - spin(10); - result &= - expect(!peer.waitFor("thread.read", "child-failure", 100).has_value(), - "a failed child hydration does not enter an automatic retry loop"); - - result &= - expect(selectThread(list, "thread-a") && selectThread(list, "thread-b"), - "explicit navigation returns to the failed child's parent"); - const auto retriedChildRead = peer.waitFor("thread.read", "child-failure"); - result &= expect(retriedChildRead.has_value(), - "explicit parent navigation retries one failed child read"); - if (!retriedChildRead) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "thread.read", - retriedChildRead->value("correlationId", std::string{}), true, - {{"thread", threadWithAgentMessage("child-failure", "Child", - "completed child result")}}, - Authority::Replace, {{"threadId", "child-failure"}})); - spin(10); - - auto *inspector = shell.findChild(QStringLiteral("inspector")); - auto *tabs = inspector ? inspector->findChild( - QString{}, Qt::FindDirectChildrenOnly) - : nullptr; - if (tabs) { - tabs->setCurrentIndex(1); - spin(); +void inactiveThreadNeverReactivatesAStaleTurn(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "inactive-thread", "Inactive lifecycle"); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value("inactive-thread")}, + {"turn", Value(Value::Object{{"id", Value("stale-turn")}, + {"status", Value("inProgress")}})}}})); + auto *threadList = + shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { + return threadItem(threadList, "inactive-thread") != nullptr; + }) && + selectThread(threadList, "inactive-thread"), + "inactive lifecycle fixture selects its graph-backed thread"); + static_cast(takeQtMessages(channels)); + + QPushButton *stopButton = nullptr; + for (QPushButton *button : shell.findChildren()) { + if (button && button->text() == QStringLiteral("Stop")) { + stopButton = button; + break; + } + } + require(stopButton && spinUntil([&] { return stopButton->isVisible(); }), + "the maintained active-turn relation exposes the existing Stop " + "control"); + + NodeRef selectedThread; + { + auto read = FrontendSessionTestPeer::graph(session).tryRead(); + selectedThread = + read ? read->find({NodeKind::Thread, "inactive-thread"}) : NodeRef{}; } - result &= - expect(inspector && tabs && - hasPresentedText(*inspector, QStringLiteral("completed")) && - !hasPresentedText(*inspector, QStringLiteral("running")), - "retried child completion replaces the stale running badge"); - return result; + static_cast(worker.threadHydration(selectedThread, "loading")); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + auto *send = + shell.findChild(QStringLiteral("composerSendButton")); + if (editor) + editor->setPlainText(QStringLiteral("steer while history is loading")); + require(spinUntil([&] { + return send && send->text() == QStringLiteral("Steer") && + send->isEnabled(); + }), + "a controller can steer a known active turn with non-empty text " + "while unrelated history hydration is still loading"); + if (send) + send->click(); + const auto steeringActions = takeQtMessages(channels); + const auto steering = std::ranges::find_if( + steeringActions, [](const QtToWorkerMessage &entry) { + const auto *action = std::get_if(&entry); + return action && action->kind == NodeActionKind::SubmitPrompt; + }); + require(steering != steeringActions.end() && + std::get(*steering).target == selectedThread && + std::get(*steering).promptText == + "steer while history is loading" && + editor && editor->toPlainText().isEmpty(), + "clicking the enabled Steer control admits exactly one targeted " + "prompt while hydration is loading"); + + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "thread/status/changed", + std::nullopt, + {{"threadId", Value("inactive-thread")}, + {"status", Value(Value::Object{{"type", Value("idle")}})}}})); + require(stopButton && spinUntil([&] { return !stopButton->isVisible(); }), + "object-shaped idle status hides Stop without rediscovering the " + "stale Running child"); + if (stopButton) + stopButton->click(); + const auto idleActions = takeQtMessages(channels); + require(std::ranges::none_of( + idleActions, + [](const QtToWorkerMessage &entry) { + const auto *action = std::get_if(&entry); + return action && action->kind == NodeActionKind::InterruptTurn; + }), + "idle thread state cannot emit an interrupt for a stale turn"); + + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value("inactive-thread")}, + {"turn", Value(Value::Object{{"id", Value("closing-turn")}, + {"status", Value("inProgress")}})}}})); + require(stopButton && spinUntil([&] { return stopButton->isVisible(); }), + "a later authoritative active turn restores Stop"); + static_cast(worker.apply({DecodedMessageKind::ServerNotification, + "thread/closed", + std::nullopt, + {{"threadId", Value("inactive-thread")}}})); + require(stopButton && spinUntil([&] { return !stopButton->isVisible(); }), + "closed lifecycle clears Stop despite retained Running history"); } -bool ShellFlow::verifyNotFoundRecovery() { - bool result = true; - - peer.discard(); - result &= peer.send(presentation::event( - sequence++, 2, "thread.upsert", {{"thread", thread("thread-d", "D")}}, - Authority::Merge, {{"threadId", "thread-d"}})); - spin(5); - result &= expect(selectThread(list, "thread-d"), - "D is selected for thread-not-found recovery coverage"); - const auto readD = peer.waitFor("thread.read", "thread-d"); - result &= expect(readD.has_value(), "D is hydrated before its first prompt"); - if (!readD) - return false; - result &= peer.send( - presentation::result(sequence++, 2, "thread.read", - readD->value("correlationId", std::string{}), true, - {{"thread", thread("thread-d", "D")}}, - Authority::Replace, {{"threadId", "thread-d"}})); - result &= completeSettingsRefresh("thread-d"); - spin(5); - result &= expect(submit(editor, QStringLiteral("prompt D1")), - "D1 is admitted before recovery"); - const auto firstStartD = peer.waitFor("turn.start", "thread-d"); - result &= expect(firstStartD.has_value(), "D1 begins with turn.start"); - if (!firstStartD) - return false; - const std::string firstDClientId = - firstStartD->value("data", nlohmann::json::object()) - .value("clientUserMessageId", std::string{}); - result &= peer.send(presentation::result( - sequence++, 2, "turn.start", - firstStartD->value("correlationId", std::string{}), false, - {{"code", -32004}, {"message", "thread thread-d not found"}}, - Authority::None, {{"threadId", "thread-d"}})); - const auto firstResumeD = peer.waitFor("thread.resume", "thread-d"); - result &= expect(firstResumeD.has_value(), - "thread-not-found triggers one explicit resume"); - if (!firstResumeD) - return false; - result &= peer.send( - presentation::result(sequence++, 2, "thread.resume", - firstResumeD->value("correlationId", std::string{}), - true, {{"thread", thread("thread-d", "D")}}, - Authority::Merge, {{"threadId", "thread-d"}})); - const auto retriedStartD = peer.waitFor("turn.start", "thread-d"); - result &= expect(retriedStartD.has_value() && - retriedStartD->value("data", nlohmann::json::object()) - .value("clientUserMessageId", std::string{}) == - firstDClientId, - "D1 retries once with the same client message identity"); - if (!retriedStartD) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "turn.start", - retriedStartD->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-d"}, {"status", "inProgress"}}}}, - Authority::Merge, {{"threadId", "thread-d"}, {"turnId", "turn-d"}})); - spin(5); - - result &= expect(submit(editor, QStringLiteral("prompt D2")), - "the prompt after recovery remains dispatchable"); - const auto firstSteerD = peer.waitFor("turn.steer", "thread-d"); - result &= expect(firstSteerD.has_value(), - "the next prompt steers the recovered active turn"); - if (!firstSteerD) - return false; - const std::string secondDClientId = - firstSteerD->value("data", nlohmann::json::object()) - .value("clientUserMessageId", std::string{}); - result &= peer.send(presentation::result( - sequence++, 2, "turn.steer", - firstSteerD->value("correlationId", std::string{}), false, - {{"code", -32004}, {"message", "thread thread-d not found"}}, - Authority::None, {{"threadId", "thread-d"}})); - const auto secondResumeD = peer.waitFor("thread.resume", "thread-d"); - result &= expect(secondResumeD.has_value(), - "D2 receives its single bounded recovery attempt"); - if (!secondResumeD) - return false; - result &= expect(submit(editor, QStringLiteral("prompt D during recovery")), - "another prompt remains admissible during recovery"); - spin(10); - result &= expect(!peer.waitFor("turn.steer", "thread-d", 100).has_value(), - "an in-flight resume gates dispatch"); - result &= peer.send(presentation::result( - sequence++, 2, "thread.resume", - secondResumeD->value("correlationId", std::string{}), true, - {{"thread", thread("thread-d", "D", "active", "turn-d")}}, - Authority::Merge, {{"threadId", "thread-d"}})); - const auto retriedSteerD = peer.waitFor("turn.steer", "thread-d"); - result &= expect(retriedSteerD.has_value() && - retriedSteerD->value("data", nlohmann::json::object()) - .value("clientUserMessageId", std::string{}) == - secondDClientId, - "D2 retry also preserves its exact identity"); - if (!retriedSteerD) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "turn.steer", - retriedSteerD->value("correlationId", std::string{}), false, - {{"code", -32004}, {"message", "thread thread-d not found again"}}, - Authority::None, {{"threadId", "thread-d"}})); - spin(10); - const middle::LocalPromptData *failedD2 = - localPrompt(shell, QStringLiteral("prompt D2")); - result &= expect( - failedD2 && failedD2->state == middle::PromptState::Failed && - !peer.waitFor("thread.resume", "thread-d", 100).has_value(), - "a repeated not-found is terminal and cannot start a second recovery"); - const auto postRecoverySteerD = peer.waitFor("turn.steer", "thread-d"); - result &= expect(postRecoverySteerD.has_value(), - "the queued prompt dispatches after recovery finishes"); - if (!postRecoverySteerD) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "turn.steer", - postRecoverySteerD->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-d"}, {"status", "inProgress"}}}}, - Authority::Merge, {{"threadId", "thread-d"}, {"turnId", "turn-d"}})); - return result; +void reloadAndReconnectHydrationStayExplicit(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + makeReady(worker); + applyThread(worker, "rehydrate-thread", "Rehydrate me"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil([&] { return threadItem(list, "rehydrate-thread"); }), + "rehydration fixture appears in the real thread list"); + require(selectThread(list, "rehydrate-thread"), + "rehydration fixture can be selected"); + std::vector selection = takeQtMessages(channels); + const auto selectedHydrates = + std::ranges::count_if(selection, [](const QtToWorkerMessage &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::Hydrate; + }); + require(selectedHydrates == 1, + "ordinary selection emits one non-forced hydration action"); + + NodeRef original; + { + auto read = session.nodeGraph().tryRead(); + original = + read ? read->find({NodeKind::Thread, "rehydrate-thread"}) : NodeRef{}; + } + static_cast(worker.threadHydration(original, "ready")); + spin(30); + static_cast(takeQtMessages(channels)); + + const QPoint menuPoint = + list->visualItemRect(threadItem(list, "rehydrate-thread")).center(); + QMetaObject::invokeMethod(list, "customContextMenuRequested", + Qt::DirectConnection, Q_ARG(QPoint, menuPoint)); + spin(20); + QAction *reload = nullptr; + for (QMenu *menu : shell.findChildren()) { + for (QAction *action : menu->actions()) { + if (action && action->text() == QStringLiteral("Reload")) { + reload = action; + break; + } + } + if (reload) + break; + } + if (reload) + reload->trigger(); + const std::vector reloadMessages = + takeQtMessages(channels); + require(reload && + std::ranges::count_if( + reloadMessages, + [](const QtToWorkerMessage &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::Reload; + }) == 1, + "visible Reload emits one distinct forced-read action"); + + static_cast(worker.bridgeState("test-controller", "controller", + "test-controller", 2, "ready", + "provider replaced")); + spin(40); + applyThread(worker, "rehydrate-thread", "Recreated thread"); + require(spinUntil([&] { + auto read = session.nodeGraph().tryRead(); + const NodeRef recreated = + read ? read->find({NodeKind::Thread, "rehydrate-thread"}) + : NodeRef{}; + return recreated && recreated != original; + }), + "provider reset recreates selected canonical id with a fresh node"); + spin(50); + const std::vector rebound = takeQtMessages(channels); + std::size_t automaticHydrates = 0; + NodeRef automaticTarget; + for (const QtToWorkerMessage &message : rebound) { + const auto *action = std::get_if(&message); + if (!action || action->kind != NodeActionKind::Hydrate) + continue; + ++automaticHydrates; + automaticTarget = action->target; + } + require(automaticHydrates == 1 && automaticTarget && + automaticTarget != original && + automaticTarget->id() == + NodeId{NodeKind::Thread, "rehydrate-thread"}, + "a recreated selected thread is read once without resending prompts"); } -bool ShellFlow::verifyFailedHydration() { - bool result = true; - - peer.discard(); - result &= peer.send(presentation::event( - sequence++, 2, "thread.upsert", {{"thread", thread("thread-e", "E")}}, - Authority::Merge, {{"threadId", "thread-e"}})); - spin(5); - result &= expect(selectThread(list, "thread-e"), - "E is selected for failed-hydration admission coverage"); - const auto readE = peer.waitFor("thread.read", "thread-e"); - result &= expect(readE.has_value(), "E requests its first hydration read"); - if (!readE) - return false; - result &= peer.send(presentation::result( - sequence++, 2, "thread.read", - readE->value("correlationId", std::string{}), false, - {{"code", -32005}, {"message", "thread hydration failed"}}, - Authority::None, {{"threadId", "thread-e"}})); - spin(10); - result &= expect(submit(editor, QStringLiteral("prompt E1")), - "the composer delivers E1 to the admission boundary"); - spin(10); - result &= - expect(editor && editor->toPlainText() == QStringLiteral("prompt E1"), - "failed hydration rejects admission without clearing the draft"); - result &= - expect(!peer.waitFor("turn.start", "thread-e", 100).has_value() && - !peer.waitFor("thread.read", "thread-e", 100).has_value(), - "failed hydration cannot send or enter an automatic read loop"); - return result; +void backgroundGraphChangesDoNotRefreshSelectedConversation( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + + const auto applyConversation = [&](std::string threadId, std::string turnId, + std::string itemId, std::string message) { + applyThread(worker, threadId, threadId); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + {{"threadId", Value(threadId)}, + {"turn", Value(Value::Object{{"id", Value(turnId)}, + {"status", Value("inProgress")}})}}})); + static_cast(worker.apply( + {DecodedMessageKind::ServerNotification, + "item/started", + std::nullopt, + {{"threadId", Value(threadId)}, + {"turnId", Value(turnId)}, + {"item", Value(Value::Object{{"id", Value(itemId)}, + {"type", Value("agentMessage")}, + {"text", Value(message)}})}}})); + markThreadReady(session, worker, threadId); + }; + + makeReady(worker); + applyConversation("selected-thread", "selected-turn", "selected-item", + "Selected original"); + applyConversation("background-thread", "background-turn", "background-item", + "Background original"); + require(spinUntil([&] { + auto *list = + shell.findChild(QStringLiteral("threadList")); + return threadItem(list, "selected-thread") && + threadItem(list, "background-thread"); + }), + "both selected and background graph threads reach the real shell"); + + auto *list = shell.findChild(QStringLiteral("threadList")); + require(selectThread(list, "selected-thread"), + "the selected conversation is bound before filtering deltas"); + static_cast(takeQtMessages(channels)); + require(spinUntil([&] { + return agentMessageCard(shell, "Selected original") != nullptr; + }), + "the selected conversation materializes its visible agent card"); + middle::ConversationCard *selectedCard = + agentMessageCard(shell, "Selected original"); + auto *conversation = dynamic_cast( + shell.findChild(QStringLiteral("conversationScroll"))); + auto *threadPane = dynamic_cast( + shell.findChild(QStringLiteral("sidebar"))); + auto *inspector = dynamic_cast( + shell.findChild(QStringLiteral("inspector"))); + + NodeRef selectedItem; + NodeRef backgroundItem; + NodeRef selectedThread; + { + auto read = graph.tryRead(); + selectedThread = + read ? read->find({NodeKind::Thread, "selected-thread"}) : NodeRef{}; + selectedItem = + read ? read->find(scopedItemNodeId( + scopedTurnNodeId("selected-thread", "selected-turn"), + "selected-item")) + : NodeRef{}; + backgroundItem = + read ? read->find(scopedItemNodeId( + scopedTurnNodeId("background-thread", "background-turn"), + "background-item")) + : NodeRef{}; + } + require(selectedThread && selectedItem && backgroundItem, + "the test resolves the selected thread and both scoped items"); + if (!selectedThread || !selectedItem || !backgroundItem || !selectedCard) + return; + + const qulonglong threadRoutesBefore = + shell.property("threadPaneRoutes").toULongLong(); + const qulonglong conversationRoutesBefore = + shell.property("conversationRoutes").toULongLong(); + const qulonglong targetedConversationRoutesBefore = + shell.property("targetedConversationRoutes").toULongLong(); + const qulonglong inspectorRoutesBefore = + shell.property("inspectorRoutes").toULongLong(); + const qulonglong shellCommitsBefore = + shell.property("shellRenderCommits").toULongLong(); + const qulonglong topologyBefore = + threadPane + ? threadPane->property("graphTopologyScansStarted").toULongLong() + : 0; + const qulonglong rowUpdatesBefore = + threadPane ? threadPane->property("rowPresentationUpdates").toULongLong() + : 0; + const qulonglong inspectorScansBefore = + inspector ? inspector->property("inspectorScanPasses").toULongLong() : 0; + const qulonglong inspectorRowsBefore = + inspector + ? inspector->property("inspectorRowConstructions").toULongLong() + : 0; + const qulonglong conversationPassesBefore = + conversation ? conversation->property("graphRefreshPasses").toULongLong() + : 0; + const qulonglong conversationGeometryBefore = + conversation + ? conversation->property("conversationGeometryPasses").toULongLong() + : 0; + const qulonglong conversationLocalGeometryBefore = + conversation + ? conversation->property("conversationLocalGeometryPasses") + .toULongLong() + : 0; + + GraphChange withheldSelectedChange; + { + auto write = graph.write(); + write.setField(selectedItem, "text", Value("Selected current")); + withheldSelectedChange = write.finish(); + } + + static_cast(worker.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", + std::nullopt, + {{"threadId", Value("background-thread")}, + {"turnId", Value("background-turn")}, + {"itemId", Value("background-item")}, + {"delta", Value(" delta")}}})); + spin(60); + const auto *afterBackground = + std::get_if(&selectedCard->data().payload); + require(afterBackground && afterBackground->text == "Selected original" && + agentMessageCard(shell, "Selected current") == nullptr && + shell.property("threadPaneRoutes").toULongLong() == + threadRoutesBefore && + shell.property("conversationRoutes").toULongLong() == + conversationRoutesBefore && + shell.property("inspectorRoutes").toULongLong() == + inspectorRoutesBefore && + shell.property("shellRenderCommits").toULongLong() == + shellCommitsBefore && + (!threadPane || + (threadPane->property("graphTopologyScansStarted") + .toULongLong() == topologyBefore && + threadPane->property("rowPresentationUpdates").toULongLong() == + rowUpdatesBefore)) && + (!inspector || + (inspector->property("inspectorScanPasses").toULongLong() == + inspectorScansBefore && + inspector->property("inspectorRowConstructions") + .toULongLong() == inspectorRowsBefore)) && + (!conversation || + conversation->property("graphRefreshPasses").toULongLong() == + conversationPassesBefore), + "a background-only graph delta performs no selected conversation " + "refresh, pane route, scan, row update, or shell render"); + + require(messageAdmitted( + channels.sendGraphChanged(std::move(withheldSelectedChange))), + "the withheld selected change is admitted for comparison"); + require(spinUntil([&] { + const auto *agent = std::get_if( + &selectedCard->data().payload); + return agent && agent->text == "Selected current"; + }), + "a selected-item graph delta refreshes the existing card"); + require( + shell.property("conversationRoutes").toULongLong() == + conversationRoutesBefore + 1 && + shell.property("targetedConversationRoutes").toULongLong() == + targetedConversationRoutesBefore + 1 && + conversation && + conversation->property("graphRefreshPasses").toULongLong() > + conversationPassesBefore && + conversation->property("conversationGeometryPasses").toULongLong() == + conversationGeometryBefore && + conversation->property("conversationLocalGeometryPasses") + .toULongLong() == + conversationLocalGeometryBefore + 1 && + shell.property("threadPaneRoutes").toULongLong() == + threadRoutesBefore && + shell.property("inspectorRoutes").toULongLong() == + inspectorRoutesBefore && + shell.property("shellRenderCommits").toULongLong() == + shellCommitsBefore, + "a selected message routes only to ConversationView and leaves thread, " + "Inspector, and shell-chrome boundaries untouched"); + + const qulonglong targetedThreadRoutesBefore = + shell.property("targetedThreadPaneRoutes").toULongLong(); + const qulonglong targetedRowUpdatesBefore = + threadPane + ? threadPane->property("targetedRowPresentationUpdates").toULongLong() + : 0; + GraphChange rowChange; + { + auto write = graph.write(); + write.setField(selectedThread, "pendingInteractionCount", + Value(std::uint64_t{2})); + rowChange = write.finish(); + } + require(messageAdmitted(channels.sendGraphChanged(std::move(rowChange))), + "the exact thread-row change is admitted"); + require(spinUntil([&] { + QListWidgetItem *item = threadItem(list, "selected-thread"); + QWidget *row = item ? list->itemWidget(item) : nullptr; + QLabel *title = + row ? row->findChild(QStringLiteral("threadTitle")) + : nullptr; + return title && title->text().startsWith(QStringLiteral("! ")); + }), + "the exact displayed thread row receives its pending badge"); + require( + shell.property("targetedThreadPaneRoutes").toULongLong() == + targetedThreadRoutesBefore + 1 && + threadPane && + threadPane->property("targetedRowPresentationUpdates").toULongLong() == + targetedRowUpdatesBefore + 1 && + threadPane->property("graphTopologyScansStarted").toULongLong() == + topologyBefore && + conversation->property("conversationGeometryPasses").toULongLong() == + conversationGeometryBefore, + "a non-sort thread field patches only its row without topology or " + "conversation geometry work"); + + QPointer removedWidget = selectedCard; + GraphChange removal; + { + auto write = graph.write(); + NodeState state = *write.state(selectedItem); + state.fields.erase("protocolThreadId"); + state.fields.erase("threadId"); + write.replaceState(selectedItem, std::move(state)); + write.remove(selectedItem); + removal = write.finish(); + } + GraphChange backgroundWithRemoval{ + removal.revision, {backgroundItem}, std::move(removal.removed)}; + require(messageAdmitted( + channels.sendGraphChanged(std::move(backgroundWithRemoval))), + "the background notification carrying a removed ref is admitted"); + require(spinUntil([&] { + return selectedItem->uiAttachment() == nullptr && + removedWidget.isNull(); + }), + "removed refs always detach matching selected widgets even when " + "the change needs no structural refresh"); +} + +void optimisticDraftUsesOneTypedCreateAction(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + spin(40); + + auto *newThread = + shell.findChild(QStringLiteral("threadNewButton")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + require(newThread != nullptr, "the existing New thread control is available"); + if (!newThread) + return; + newThread->click(); + spin(40); + + auto *list = shell.findChild(QStringLiteral("threadList")); + QListWidgetItem *draft = threadItem(list, "draft:new-thread"); + QListWidgetItem *const stableDraft = draft; + require(draft && list->currentItem() == draft, + "the local optimistic draft is selected without a mirror model"); + + static_cast(worker.connectionSettings( + {{"selected", Value("unix")}, {"endpoint", Value("local")}})); + spin(40); + require(draft && list->currentItem() == draft, + "an unrelated shared-graph change preserves draft selection"); + + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + const QString promptText = QStringLiteral("first exact draft prompt"); + require(submit(editor, promptText), + "the optimistic draft submits through the real composer"); + std::vector messages = takeQtMessages(channels); + RuntimeAction create; + std::size_t creates = 0; + for (QtToWorkerMessage &message : messages) { + if (auto *action = std::get_if(&message); + action && action->kind == RuntimeActionKind::CreateThread) { + create = std::move(*action); + ++creates; + } + } + const auto threadStart = create.payload.find("threadStart"); + const auto turnStart = create.payload.find("turnStart"); + require( + creates == 1 && create.promptText == promptText.toStdString() && + threadStart != create.payload.end() && + threadStart->second.asObject() && turnStart != create.payload.end() && + turnStart->second.asObject(), + "the draft emits one typed CreateThread with owned prompt and options"); + + PromptTransition transition = worker.admitFirstPrompt(std::move(create)); + require( + transition.command && + transition.command->kind == PromptCommandKind::CreateThread, + "worker admission creates the local thread/turn/prompt graph atomically"); + spin(60); + const NodeRef graphDraft = + transition.command ? transition.command->thread : NodeRef{}; + require( + graphDraft && list && list->currentItem() == stableDraft && + list->currentItem()->data(Qt::UserRole).toString().toStdString() == + graphDraft->id().canonical && + localPromptCard(shell, promptText.toStdString()), + "the same optimistic row hands off to the selected shared graph draft"); + + if (!transition.command) + return; + const NodeRef localPrompt = transition.command->localPrompt; + require(worker.attachCreatedThread(*transition.command, "created-thread") == + ChannelSendStatus::Accepted, + "the worker attaches the accepted canonical thread exactly once"); + spin(60); + middle::ThreadPane *threadPane = nullptr; + for (QWidget *ancestor = list; ancestor && !threadPane; + ancestor = ancestor->parentWidget()) + threadPane = dynamic_cast(ancestor); + require(threadItem(list, "created-thread") == stableDraft && + list->currentItem() == stableDraft && threadPane && + threadPane->isOptimisticThread("created-thread"), + "the same row is promoted from local to canonical identity"); + + static_cast( + worker.completePrompt(localPrompt, true, {}, "created-turn")); + spin(60); + middle::ConversationCard *acceptedCard = + localPromptCard(shell, promptText.toStdString()); + QTimer *acceptedAnimation = acceptedCard + ? acceptedCard->findChild( + QStringLiteral("pendingAnimationTimer")) + : nullptr; + require(threadItem(list, "created-thread") == stableDraft && threadPane && + !threadPane->isOptimisticThread("created-thread") && + acceptedCard && + !acceptedCard->property("pendingFeedbackVisible").toBool() && + acceptedAnimation && !acceptedAnimation->isActive(), + "the exact prompt result confirms the canonical row without " + "replacing its widget item and stops optimistic feedback"); +} + +void emptyOptimisticDraftIsAbandonedOnThreadSelection( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "existing-after-empty-draft", "Existing thread"); + spin(40); + + auto *newThread = + shell.findChild(QStringLiteral("threadNewButton")); + auto *list = shell.findChild(QStringLiteral("threadList")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + require(newThread && list, + "empty-draft abandonment fixture exposes the thread controls"); + if (!newThread || !list) + return; + newThread->click(); + require(spinUntil([&] { + return threadItem(list, "draft:new-thread") == list->currentItem(); + }), + "an empty local New Thread draft starts as the selected row"); + + require(selectThread(list, "existing-after-empty-draft") && + spinUntil([&] { return !threadItem(list, "draft:new-thread"); }), + "selecting a real thread abandons and removes an unsubmitted local " + "draft"); + + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + newThread->click(); + require(spinUntil([&] { + return threadItem(list, "draft:new-thread") == list->currentItem(); + }), + "abandonment clears creation state so a later New Thread can start"); } -bool ShellFlow::verifyOptimisticNewThread() { - bool result = true; - peer.discard(); +void secondNewThreadIsGuardedWhileCreationIsInFlight( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + spin(40); auto *newThread = shell.findChild(QStringLiteral("threadNewButton")); - bool dialogOpened = false; - QTimer::singleShot(0, &shell, [&dialogOpened] { + auto *list = shell.findChild(QStringLiteral("threadList")); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + require(newThread && list && editor, + "in-flight creation fixture exposes New Thread and the composer"); + if (!newThread || !list || !editor) + return; + newThread->click(); + require(spinUntil([&] { + QListWidgetItem *draft = threadItem(list, "draft:new-thread"); + return draft && draft == list->currentItem() && draft->isSelected(); + }) && + submit(editor, QStringLiteral("one creation in flight")), + "the first new-thread action is admitted from its optimistic row"); + + std::optional create; + for (QtToWorkerMessage &message : takeQtMessages(channels)) { + if (auto *action = std::get_if(&message); + action && action->kind == RuntimeActionKind::CreateThread) + create = std::move(*action); + } + require(create.has_value(), + "the first submission emits one worker-owned creation action"); + if (!create) + return; + + bool secondDialogOpened = false; + QTimer::singleShot(0, &shell, [&secondDialogOpened] { if (auto *dialog = qobject_cast(QApplication::activeModalWidget())) { - dialogOpened = true; + secondDialogOpened = true; dialog->accept(); } }); - result &= expect(newThread, "the real New thread action is available"); + newThread->click(); + spin(30); + require(!secondDialogOpened && + threadItem(list, "draft:new-thread") == list->currentItem(), + "New Thread does not replace the correlation or optimistic row " + "while creation is in flight"); + + PromptTransition transition = worker.admitFirstPrompt(std::move(*create)); + require(transition.command && spinUntil([&] { + return threadItem(list, + transition.command->thread->id().canonical) != + nullptr; + }), + "the original in-flight correlation still promotes its exact " + "optimistic row after a guarded second click"); +} + +void optimisticCreationDoesNotOverrideLaterNavigation( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "navigation-thread", "Chosen after creation"); + spin(40); + + auto *newThread = + shell.findChild(QStringLiteral("threadNewButton")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); if (!newThread) - return false; + return; newThread->click(); - spin(10); - - auto findThreadItem = [this](std::string_view id) -> QListWidgetItem * { - if (!list) - return nullptr; - for (int row = 0; row < list->count(); ++row) { - QListWidgetItem *item = list->item(row); - if (item && item->data(Qt::UserRole).toString().toStdString() == id) - return item; - } + spin(30); + + auto *list = shell.findChild(QStringLiteral("threadList")); + QListWidgetItem *const draft = threadItem(list, "draft:new-thread"); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + require(draft && submit(editor, QStringLiteral("create in background")), + "navigation race admits the selected optimistic draft"); + std::vector messages = takeQtMessages(channels); + std::optional create; + for (QtToWorkerMessage &message : messages) { + if (auto *action = std::get_if(&message); + action && action->kind == RuntimeActionKind::CreateThread) + create = std::move(*action); + } + require(create.has_value() && selectThread(list, "navigation-thread"), + "the user navigates away before worker creation effects arrive"); + if (!create) + return; + + PromptTransition transition = worker.admitFirstPrompt(std::move(*create)); + spin(50); + require(transition.command && + list->currentItem() == threadItem(list, "navigation-thread") && + threadItem(list, transition.command->thread->id().canonical) == + draft, + "the delayed local handoff promotes its row without stealing the " + "newer selection"); + if (!transition.command) + return; + + const NodeRef localPrompt = transition.command->localPrompt; + static_cast( + worker.attachCreatedThread(*transition.command, "background-created")); + spin(50); + require(list->currentItem() == threadItem(list, "navigation-thread") && + threadItem(list, "background-created") == draft, + "canonical creation completion preserves both row identity and " + "later navigation"); + static_cast( + worker.completePrompt(localPrompt, true, {}, "background-turn")); + spin(50); + require(list->currentItem() == threadItem(list, "navigation-thread") && + threadItem(list, "background-created") == draft, + "prompt acknowledgement confirms the background row without a " + "selection override"); +} + +void optimisticCreationIgnoresAnotherCreationCorrelation( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + spin(40); + + auto *newThread = + shell.findChild(QStringLiteral("threadNewButton")); + QTimer::singleShot(0, &shell, [] { + if (auto *dialog = + qobject_cast(QApplication::activeModalWidget())) + dialog->accept(); + }); + require(newThread != nullptr, + "the creation-correlation fixture exposes New thread"); + if (!newThread) + return; + newThread->click(); + spin(30); + + auto *list = shell.findChild(QStringLiteral("threadList")); + QListWidgetItem *const foregroundDraft = threadItem(list, "draft:new-thread"); + require(foregroundDraft && list->currentItem() == foregroundDraft, + "the foreground optimistic draft begins selected"); + if (!foregroundDraft) + return; + + RuntimeAction background; + background.kind = RuntimeActionKind::CreateThread; + background.correlation = "different-creation-correlation"; + background.promptText = "background creation prompt"; + background.payload.emplace( + "threadStart", + Value(Value::Object{{"cwd", Value("/tmp")}, + {"name", Value("Different creation")}})); + background.payload.emplace("turnStart", Value(Value::Object{})); + PromptTransition transition = worker.admitFirstPrompt(std::move(background)); + require(transition.command.has_value(), + "the worker admits a distinct correlated background creation"); + if (!transition.command) + return; + + const std::string backgroundThreadId = + transition.command->thread->id().canonical; + require(spinUntil( + [&] { return threadItem(list, backgroundThreadId) != nullptr; }), + "the differently correlated worker draft is visible in the list"); + require(list->currentItem() == foregroundDraft && + list->currentItem()->data(Qt::UserRole).toString() == + QStringLiteral("draft:new-thread") && + threadItem(list, backgroundThreadId) != foregroundDraft, + "a worker SelectThread for another creation correlation cannot " + "hijack the foreground optimistic selection"); +} + +void saturatedShellKeepsTheEditorDraft(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "saturated-shell", "Saturated shell"); + require(spinUntil([&] { + return threadItem(shell.findChild( + QStringLiteral("threadList")), + "saturated-shell") != nullptr; + }), + "saturation fixture renders its shared thread"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(selectThread(list, "saturated-shell"), + "saturation fixture selects its graph thread"); + static_cast(takeQtMessages(channels)); + + std::size_t admissions = 0; + for (;;) { + RuntimeAction filler; + filler.kind = RuntimeActionKind::RefreshCatalogs; + filler.correlation = "shell-fill-" + std::to_string(admissions); + const ChannelSendStatus status = session.sendRuntimeAction(filler); + if (status == ChannelSendStatus::QueueFull) + break; + ++admissions; + } + + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + const QString retained = QStringLiteral("retain after visible rejection"); + require(submit(editor, retained), + "the enabled composer reaches the saturated typed boundary"); + spin(20); + require(editor && editor->toPlainText() == retained, + "a rejected prompt remains editable in the existing composer"); + + const std::vector messages = takeQtMessages(channels); + const bool hasPrompt = std::ranges::any_of(messages, [](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::SubmitPrompt; + }); + require( + messages.size() == admissions && !hasPrompt, + "visible rejection neither admits nor retries the non-idempotent prompt"); +} + +void saturatedRenameRetainsAuthoredName(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "rename-retention", "Original name"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil( + [&] { return threadItem(list, "rename-retention") != nullptr; }), + "rename-retention fixture renders its shared thread"); + static_cast(takeQtMessages(channels)); + + std::size_t fillers = 0; + for (;;) { + RuntimeAction filler; + filler.kind = RuntimeActionKind::RefreshCatalogs; + filler.correlation = "rename-fill-" + std::to_string(fillers); + const ChannelSendStatus status = session.sendRuntimeAction(filler); + if (status == ChannelSendStatus::QueueFull) + break; + ++fillers; + } + + const auto openRename = [&]() -> QAction * { + const QPoint point = + list->visualItemRect(threadItem(list, "rename-retention")).center(); + QMetaObject::invokeMethod(list, "customContextMenuRequested", + Qt::DirectConnection, Q_ARG(QPoint, point)); + auto *menu = qobject_cast(QApplication::activePopupWidget()); + if (menu) + for (QAction *action : menu->actions()) + if (action && action->text() == QStringLiteral("Rename")) + return action; return nullptr; }; - QListWidgetItem *draft = findThreadItem("draft:new-thread"); - result &= - expect(dialogOpened && draft && list->currentItem() == draft && - draft->data(Qt::UserRole + 6).toBool(), - "accepting the dialog immediately selects one animated draft row"); - if (!draft) - return false; - result &= expect(submit(editor, QStringLiteral("first new-thread prompt")), - "the selected optimistic draft admits its first prompt"); - const auto create = peer.waitFor("thread.create"); - result &= expect(create.has_value(), - "the optimistic draft dispatches thread.create"); - if (!create) - return false; - result &= peer.send( - presentation::result(sequence++, generation, "thread.create", - create->value("correlationId", std::string{}), true, - {{"thread", - {{"id", "thread-new"}, - {"name", "New thread"}, - {"cwd", "/workspace/new"}, - {"status", "idle"}}}}, - Authority::Merge, {{"threadId", "thread-new"}})); - - const auto start = peer.waitFor("turn.start", "thread-new"); - result &= expect(start.has_value(), - "thread.create promotion dispatches the retained prompt"); - if (!start) - return false; - QListWidgetItem *promoted = findThreadItem("thread-new"); - result &= - expect(promoted == draft && promoted->data(Qt::UserRole + 6).toBool(), - "thread.create rekeys the same visible item while acknowledgment " - "is pending"); - - result &= peer.send(presentation::result( - sequence++, generation, "turn.start", - start->value("correlationId", std::string{}), true, - {{"turn", {{"id", "turn-new"}, {"status", "inProgress"}}}}, - Authority::Merge, {{"threadId", "thread-new"}, {"turnId", "turn-new"}})); - spin(10); - result &= expect(findThreadItem("thread-new") == draft && - !draft->data(Qt::UserRole + 6).toBool(), - "turn acknowledgment canonicalizes the same thread item"); - return result; + const QString exact = QStringLiteral("Exact queued rename"); + QAction *rename = openRename(); + QTimer::singleShot(0, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + auto *edit = dialog ? dialog->findChild() : nullptr; + if (edit) { + edit->setText(exact); + dialog->accept(); + } + }); + if (rename) + rename->trigger(); + const std::vector rejected = takeQtMessages(channels); + require(rename && rejected.size() == fillers && + std::ranges::none_of( + rejected, + [](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::Rename; + }), + "a saturated rename is visibly rejected without being queued"); + + bool prefilled = false; + rename = openRename(); + QTimer::singleShot(0, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + auto *edit = dialog ? dialog->findChild() : nullptr; + prefilled = edit && edit->text() == exact; + if (dialog) + dialog->accept(); + }); + if (rename) + rename->trigger(); + const std::vector admitted = takeQtMessages(channels); + const auto action = std::ranges::find_if(admitted, [](const auto &message) { + const auto *candidate = std::get_if(&message); + return candidate && candidate->kind == NodeActionKind::Rename; + }); + bool exactPayload = false; + if (action != admitted.end()) { + const auto &renameAction = std::get(*action); + const auto name = renameAction.payload.find("name"); + exactPayload = name != renameAction.payload.end() && + name->second.asString() && + *name->second.asString() == exact.toStdString(); + } + require(rename && prefilled && admitted.size() == 1 && exactPayload, + "reopening Rename recovers the authored name and deliberately " + "admits it exactly once"); +} + +void saturatedWorkerEffectsKeepNewestUiState(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "effect-first", "First selection"); + applyThread(worker, "effect-second", "Newest selection"); + require(spinUntil([&] { + auto *list = + shell.findChild(QStringLiteral("threadList")); + return threadItem(list, "effect-first") && + threadItem(list, "effect-second") && + channels.workerToQtSizeApprox() == 0; + }), + "effect-saturation fixture renders both shared threads"); + + NodeRef first; + NodeRef second; + { + auto read = graph.tryRead(); + first = read->find({NodeKind::Thread, "effect-first"}); + second = read->find({NodeKind::Thread, "effect-second"}); + } + std::size_t oldNotices = 0; + while (channels.workerToQtSizeApprox() < + ThreadChannels::WorkerToQtCapacity - + ThreadChannels::WorkerToQtReservedSlots) { + require(worker.showNotice("old notice " + std::to_string(oldNotices)) == + ChannelSendStatus::Accepted, + "ordinary sequenced notice fills only ordinary queue capacity"); + ++oldNotices; + } + require(worker.showNotice("newest visible notice") == + ChannelSendStatus::CoalescedRescan && + worker.selectThread(first) == ChannelSendStatus::Accepted && + worker.selectThread(second) == ChannelSendStatus::CoalescedRescan, + "saturated effects retain a graph fallback after the critical " + "selection slot is used"); + + require( + spinUntil( + [&] { + auto *list = + shell.findChild(QStringLiteral("threadList")); + auto *bar = shell.findChild( + QStringLiteral("conversationNoticeBar")); + const auto labels = + bar ? bar->findChildren() : QList{}; + return channels.workerToQtSizeApprox() == 0 && + !channels.rescanPending() && list && + list->currentItem() == threadItem(list, "effect-second") && + !labels.empty() && + labels.front()->text() == + QStringLiteral("newest visible notice"); + }, + 3000), + "Qt ignores older queued effects after reconstructing the newest " + "sequenced graph fallback"); +} + +void saturatedInteractionRetainsAuthoredInput(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "retained-input", "Retained input"); + static_cast(worker.applyDetailed( + {DecodedMessageKind::ServerRequest, + "item/tool/requestUserInput", + ProtocolRequestId("retained-question"), + {{"threadId", Value("retained-input")}, + {"turnId", Value("retained-turn")}, + {"itemId", Value("retained-item")}, + {"questions", Value(Value::Array{Value(Value::Object{ + {"id", Value("answer")}, + {"question", Value("What should be retained?")}, + {"options", Value(Value::Array{})}})})}}})); + + auto *review = shell.findChild( + QStringLiteral("pendingRequestReviewButton")); + require(spinUntil([&] { + return review && review->isVisible() && review->isEnabled(); + }), + "user-input request exposes the existing Review action"); + if (!review) + return; + + std::size_t fillers = 0; + for (;;) { + RuntimeAction filler; + filler.kind = RuntimeActionKind::RefreshCatalogs; + filler.correlation = "input-fill-" + std::to_string(fillers); + const ChannelSendStatus status = session.sendRuntimeAction(filler); + if (status == ChannelSendStatus::QueueFull) + break; + ++fillers; + } + + const QString exact = QStringLiteral("exact answer retained at saturation"); + QTimer::singleShot(0, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog) + return; + const auto edits = dialog->findChildren(); + auto *buttons = dialog->findChild(); + if (edits.size() == 1 && buttons) { + edits.front()->setText(exact); + buttons->button(QDialogButtonBox::Ok)->click(); + } + }); + review->click(); + spin(); + const std::vector rejected = takeQtMessages(channels); + require(rejected.size() == fillers && + std::ranges::none_of( + rejected, + [](const auto &message) { + const auto *action = std::get_if(&message); + return action && + action->kind == NodeActionKind::ResolveInteraction; + }), + "a saturated response is not queued or retried automatically"); + + bool prefilled = false; + QTimer::singleShot(0, [&] { + auto *dialog = qobject_cast(QApplication::activeModalWidget()); + if (!dialog) + return; + const auto edits = dialog->findChildren(); + auto *buttons = dialog->findChild(); + prefilled = edits.size() == 1 && edits.front()->text() == exact; + if (buttons) + buttons->button(QDialogButtonBox::Ok)->click(); + }); + review->click(); + const std::vector admitted = takeQtMessages(channels); + const auto response = std::ranges::find_if(admitted, [](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::ResolveInteraction; + }); + bool exactPayload = false; + if (response != admitted.end()) { + const auto &action = std::get(*response); + const auto answers = action.payload.find("answers"); + if (answers != action.payload.end() && answers->second.asObject()) { + const auto answer = answers->second.asObject()->find("answer"); + if (answer != answers->second.asObject()->end() && + answer->second.asObject()) { + const auto values = answer->second.asObject()->find("answers"); + exactPayload = values != answer->second.asObject()->end() && + values->second.asArray() && + values->second.asArray()->size() == 1 && + values->second.asArray()->front().asString() && + *values->second.asArray()->front().asString() == + exact.toStdString(); + } + } + } + require(prefilled && admitted.size() == 1 && exactPayload, + "reopening Review recovers the exact authored input and a deliberate " + "submit admits it once"); +} + +void recoveryOnlyPromptRestoresToComposer(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "recovery-source", "Recovery source"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(spinUntil( + [&] { return threadItem(list, "recovery-source") != nullptr; }) && + selectThread(list, "recovery-source"), + "recovery fixture selects its original provider thread"); + static_cast(takeQtMessages(channels)); + + const QString exact = QStringLiteral("preserve this exact unsent prompt"); + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + require(submit(editor, exact), + "recovery fixture admits the prompt into the typed mailbox"); + std::vector authored = takeQtMessages(channels); + auto promptAction = std::ranges::find_if(authored, [](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::SubmitPrompt; + }); + if (promptAction == authored.end()) { + require(false, "recovery fixture receives the admitted prompt action"); + return; + } + PromptTransition transition = + worker.admitPrompt(std::move(std::get(*promptAction))); + require(transition.command.has_value(), + "worker starts one provider-bound prompt command"); + + static_cast(worker.apply({DecodedMessageKind::ServerNotification, + "thread/deleted", + std::nullopt, + {{"threadId", Value("recovery-source")}}})); + std::string recoveryId; + require(spinUntil([&] { + for (int row = 0; list && row < list->count(); ++row) { + const std::string id = + list->item(row)->data(Qt::UserRole).toString().toStdString(); + if (id.starts_with("local-recovery-thread:")) { + recoveryId = id; + return true; + } + } + return false; + }), + "thread deletion exposes a recovery-only local thread"); + static_cast(takeQtMessages(channels)); + require(!recoveryId.empty() && selectThread(list, recoveryId), + "the recovery-only thread can be inspected without hydration"); + const std::vector inspectionActions = + takeQtMessages(channels); + const bool hydratedRecovery = + std::ranges::any_of(inspectionActions, [](const auto &message) { + const auto *action = std::get_if(&message); + return action && action->kind == NodeActionKind::Hydrate; + }); + auto *send = + shell.findChild(QStringLiteral("composerSendButton")); + QPushButton *restore = nullptr; + require(spinUntil([&] { + restore = shell.findChild( + QStringLiteral("promptRecoveryButton")); + return restore && restore->isVisible(); + }) && + send && !send->isEnabled() && !hydratedRecovery, + "recovery-only selection disables normal send and shows one explicit " + "restore action"); + if (!restore) + return; + + middle::ComposerPane *composer = nullptr; + for (QWidget *widget : shell.findChildren()) { + composer = dynamic_cast(widget); + if (composer) + break; + } + const QString existingDraft = + QStringLiteral("keep this newer composer draft unchanged"); + const std::vector existingAttachments{ + {"/tmp/newer-draft.txt", "newer-draft.txt", "text/plain", 23}}; + if (editor) + editor->setPlainText(existingDraft); + if (composer) + composer->setAttachments(existingAttachments); + restore->click(); + spin(40); + const std::vector guardedRestore = + takeQtMessages(channels); + require(composer && editor && editor->toPlainText() == existingDraft && + composer->attachments() == existingAttachments && + list->currentItem() == threadItem(list, recoveryId) && + restore->isVisible() && guardedRestore.empty(), + "Restore leaves a newer composer draft and its attachments intact"); + if (!composer || !editor) + return; + editor->clear(); + composer->setAttachments({}); + + restore->click(); + spin(40); + const std::vector afterRestore = takeQtMessages(channels); + const bool sentAutomatically = + std::ranges::any_of(afterRestore, [](const auto &message) { + if (const auto *action = std::get_if(&message)) + return action->kind == NodeActionKind::SubmitPrompt; + if (const auto *action = std::get_if(&message)) + return action->kind == RuntimeActionKind::CreateThread; + return false; + }); + require(editor && editor->toPlainText() == exact && + threadItem(list, "draft:new-thread") == list->currentItem() && + send && send->isEnabled() && !sentAutomatically, + "Restore moves the exact text into a new-thread composer draft " + "without sending it"); } -bool ShellFlow::verifyPendingResolutionBoundary() { - bool result = true; - peer.discard(); - auto pending = [this](int id, const char *command) { - return peer.send(presentation::event( - sequence++, generation, "pending-request.upsert", - {{"requestId", id}, - {"category", "command-approval"}, - {"request", {{"command", command}, {"cwd", "/tmp"}}}}, - Authority::Merge, {{"threadId", "thread-new"}, {"requestId", id}})); +void providerNoticesReachTheTransientSurface(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "notice-thread", "Notice thread"); + spin(40); + + auto *bar = + shell.findChild(QStringLiteral("conversationNoticeBar")); + auto noticeText = [bar] { + const auto labels = bar ? bar->findChildren() : QList{}; + return labels.empty() ? QString{} : labels.front()->text(); }; - auto *accept = shell.findChild( - QStringLiteral("pendingRequestAcceptButton")); - auto *reject = shell.findChild( - QStringLiteral("pendingRequestRejectButton")); - result &= expect(accept && reject, - "the selected request exposes typed response actions"); - if (!accept || !reject) - return false; - result &= pending(91, "first approval"); - spin(30); - result &= expect(accept->isEnabled(), - "the current controller can answer a current request"); - accept->click(); - accept->click(); - const auto accepted = peer.waitFor("pending-request.resolve"); - result &= expect( - accepted && - accepted->value("data", nlohmann::json::object()) - .value("requestId", 0) == 91 && - accepted->value("data", nlohmann::json::object()) - .value("result", nlohmann::json::object()) - .value("decision", std::string{}) == "accept", - "the first response preserves the native request identity and decision"); - result &= - expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), - "a repeated click cannot resolve the same request twice"); - spin(30); - result &= expect(!accept->isEnabled() && !reject->isEnabled(), - "a resolving request disables all response actions"); - result &= peer.send( - presentation::event(sequence++, generation, "pending-request.removed", - nlohmann::json::object(), Authority::Remove, - {{"threadId", "thread-new"}, {"requestId", 91}})); - - result &= pending(92, "observer approval"); - result &= peer.send( - presentation::event(sequence++, generation, "connection.controller", - {{"controllerConnectionId", "different-controller"}}, - Authority::Replace)); - spin(30); - result &= expect(!accept->isEnabled() && !reject->isEnabled(), - "an observer can inspect but cannot answer a request"); - accept->click(); - result &= - expect(!peer.waitFor("pending-request.resolve", {}, 100).has_value(), - "disabled observer actions emit no response"); + static_cast( + worker.apply({DecodedMessageKind::ServerNotification, + "warning", + std::nullopt, + {{"message", Value("Provider warning is visible")}}})); + require(spinUntil([&] { + return bar && bar->isVisible() && + noticeText() == + QStringLiteral("Provider warning is visible"); + }) && + bar->property("tone").toString() == QStringLiteral("warning"), + "provider warnings use the canonical non-error notice surface"); + + static_cast( + worker.apply({DecodedMessageKind::ServerNotification, + "error", + std::nullopt, + {{"threadId", Value("notice-thread")}, + {"message", Value("Provider error is visible")}}})); + require(spinUntil([&] { + return noticeText() == QStringLiteral("Provider error is visible"); + }) && + bar->property("tone").toString() == QStringLiteral("danger"), + "provider errors use the canonical error notice surface"); + + auto read = graph.tryRead(); + const NodeRef notice = + read ? read->find({NodeKind::Notice, "provider-notice"}) : NodeRef{}; + const NodeRef thread = + read ? read->find({NodeKind::Thread, "notice-thread"}) : NodeRef{}; + require(notice && thread && + stringFieldEquals(read->state(notice), "method", "error") && + !field(read->state(thread), "message"), + "provider notice state stays isolated from addressed thread facts"); +} - result &= peer.send(presentation::event( - sequence++, generation, "connection.controller", - {{"controllerConnectionId", "test-controller-2"}}, Authority::Replace)); - spin(30); - result &= expect(accept->isEnabled() && reject->isEnabled(), - "current controller ownership restores request actions"); - reject->click(); - const auto rejected = peer.waitFor("pending-request.resolve"); - result &= expect(rejected && rejected->value("data", nlohmann::json::object()) - .value("requestId", 0) == 92, - "the restored controller can resolve the retained request"); - return result; +void failedHydrationKeepsTheEditorDraft(Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + NodeGraph &graph = FrontendSessionTestPeer::graph(session); + WorkerLogic worker(graph, channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "failed-hydration", "Needs reload"); + require(spinUntil([&] { + return threadItem(shell.findChild( + QStringLiteral("threadList")), + "failed-hydration") != nullptr; + }), + "failed hydration fixture renders its graph thread"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(selectThread(list, "failed-hydration"), + "failed hydration fixture selects its destination"); + static_cast(takeQtMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "failed-hydration"}); + } + static_cast( + worker.threadHydration(thread, "failed", "Hydration failed")); + spin(40); + + auto *editor = shell.findChild( + QStringLiteral("upcomingPromptEditor")); + auto *send = + shell.findChild(QStringLiteral("composerSendButton")); + const QString retained = QStringLiteral("retain until Reload succeeds"); + editor->setPlainText(retained); + spin(); + const bool invoked = QMetaObject::invokeMethod(editor, "submitRequested", + Qt::DirectConnection); + const std::vector messages = takeQtMessages(channels); + require(invoked && send && !send->isEnabled() && + editor->toPlainText() == retained && messages.empty(), + "failed hydration disables admission without clearing or queueing " + "the user-owned draft"); } -bool runShellFlow(FrontendSession &session, PresentationPeer &peer) { - return ShellFlow(session, peer).run(); +void reverseInteractionCarriesOnlyAuthoredResponse( + Configuration &configuration) { + FrontendSession session(configuration); + ThreadChannels &channels = FrontendSessionTestPeer::channels(session); + WorkerLogic worker(FrontendSessionTestPeer::graph(session), channels); + ShellWidget shell(session); + shell.resize(1500, 850); + shell.show(); + makeReady(worker); + applyThread(worker, "approval-thread", "Approval thread"); + require(spinUntil([&] { + return threadItem(shell.findChild( + QStringLiteral("threadList")), + "approval-thread") != nullptr; + }), + "reverse-interaction fixture renders its target thread"); + auto *list = shell.findChild(QStringLiteral("threadList")); + require(selectThread(list, "approval-thread"), + "pending interaction is scoped to the selected graph thread"); + static_cast(takeQtMessages(channels)); + + const WorkerApplyResult request = + worker.applyDetailed({DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", + ProtocolRequestId("approval-typed"), + {{"threadId", Value("approval-thread")}, + {"turnId", Value("approval-turn")}, + {"itemId", Value("approval-item")}, + {"command", Value("echo secret protocol fact")}, + {"cwd", Value("/provider/cwd")}}}); + require(request.primary != nullptr, + "decoded server request creates one pending interaction node"); + auto *accept = shell.findChild( + QStringLiteral("pendingRequestAcceptButton")); + require(spinUntil([&] { + return accept && accept->isVisible() && accept->isEnabled(); + }), + "controller sees the existing actionable approval UI"); + if (!accept) + return; + accept->click(); + + const std::vector responses = takeQtMessages(channels); + const NodeAction *response = nullptr; + std::size_t responseCount = 0; + for (const QtToWorkerMessage &message : responses) { + const auto *candidate = std::get_if(&message); + if (candidate && candidate->kind == NodeActionKind::ResolveInteraction) { + response = candidate; + ++responseCount; + } + } + const auto decision = response ? response->payload.find("decision") + : Value::Object::const_iterator{}; + require( + responseCount == 1 && response && response->target == request.primary && + response->payload.size() == 1 && + decision != response->payload.end() && decision->second.asString() && + *decision->second.asString() == "accept" && + !response->payload.contains("command") && + !response->payload.contains("cwd") && response->promptText.empty() && + response->attachments.empty(), + "approval sends one typed action containing only authored decision data"); + + static_cast(worker.resolveInteraction( + request.primary, false, "CodexBridge rejected the response")); + spin(40); + const std::vector automaticRetry = + takeQtMessages(channels); + require(automaticRetry.empty() && accept->isVisible() && accept->isEnabled(), + "a rejected bridge response stays visibly actionable without an " + "automatic retry"); + accept->click(); + const std::vector deliberateRetry = + takeQtMessages(channels); + const std::size_t deliberateCount = + std::ranges::count_if(deliberateRetry, [&](const auto &message) { + const auto *candidate = std::get_if(&message); + return candidate && + candidate->kind == NodeActionKind::ResolveInteraction && + candidate->target == request.primary; + }); + require(deliberateCount == 1, + "the user can deliberately re-author one response after bridge " + "rejection"); } -bool verifyPendingRequestTextBoundaries() { +void pendingRequestTextBoundaries() { bool inspected = false; bool plainText = false; QTimer::singleShot(0, [&] { @@ -1129,25 +2661,25 @@ bool verifyPendingRequestTextBoundaries() { command != labels.end() && (*command)->textFormat() == Qt::PlainText; dialog->reject(); }); - const PendingRequestDescriptor request{ + const PendingRequestDescriptor command{ "unsafe-command", "command-approval", "thread-a", 1, {{"command", "untrusted command"}}}; - static_cast(PendingRequestDialog::present(request, nullptr)); + static_cast(PendingRequestDialog::present(command, nullptr)); bool escapedLink = false; QTimer::singleShot(0, [&] { auto *dialog = qobject_cast(QApplication::activeModalWidget()); if (!dialog) return; - const auto labels = dialog->findChildren(); - escapedLink = std::ranges::any_of(labels, [](QLabel *label) { - return label && label->textFormat() == Qt::RichText && - label->text().contains(QStringLiteral("<img")) && - !label->text().contains(QStringLiteral("findChildren(), [](QLabel *label) { + return label && label->textFormat() == Qt::RichText && + label->text().contains(QStringLiteral("<img")) && + !label->text().contains(QStringLiteral("reject(); }); const PendingRequestDescriptor elicitation{ @@ -1158,12 +2690,12 @@ bool verifyPendingRequestTextBoundaries() { {{"url", "https://example.invalid/\">"}}}; static_cast(PendingRequestDialog::present(elicitation, nullptr)); - return expect(inspected && plainText, - "request text is always rendered literally") && - expect(escapedLink, "the explicit MCP link escapes untrusted markup"); + require(inspected && plainText, + "server-provided request text is rendered literally"); + require(escapedLink, "the explicit MCP link escapes untrusted markup"); } -bool verifyPendingRequestValidationRetainsInput() { +void pendingRequestValidationRetainsInput() { bool incompleteWarning = false; bool questionDialogRetained = false; QTimer::singleShot(0, [&] { @@ -1172,8 +2704,9 @@ bool verifyPendingRequestValidationRetainsInput() { return; const auto edits = dialog->findChildren(); auto *buttons = dialog->findChild(); - auto *submit = buttons ? buttons->button(QDialogButtonBox::Ok) : nullptr; - if (edits.size() != 2 || !submit) + auto *submitButton = + buttons ? buttons->button(QDialogButtonBox::Ok) : nullptr; + if (edits.size() != 2 || !submitButton) return; edits.front()->setText(QStringLiteral("Retained first answer")); QTimer::singleShot(0, [&] { @@ -1184,12 +2717,12 @@ bool verifyPendingRequestValidationRetainsInput() { if (warning) warning->done(QMessageBox::Ok); }); - submit->click(); + submitButton->click(); questionDialogRetained = dialog->isVisible() && edits.front()->text() == QStringLiteral("Retained first answer"); edits.back()->setText(QStringLiteral("Second answer")); - submit->click(); + submitButton->click(); }); const PendingRequestDescriptor questions{ "questions", @@ -1220,8 +2753,9 @@ bool verifyPendingRequestValidationRetainsInput() { return; auto *editor = dialog->findChild(); auto *buttons = dialog->findChild(); - auto *submit = buttons ? buttons->button(QDialogButtonBox::Ok) : nullptr; - if (!editor || !submit) + auto *submitButton = + buttons ? buttons->button(QDialogButtonBox::Ok) : nullptr; + if (!editor || !submitButton) return; editor->setPlainText(QStringLiteral("[")); QTimer::singleShot(0, [&] { @@ -1232,11 +2766,11 @@ bool verifyPendingRequestValidationRetainsInput() { if (warning) warning->done(QMessageBox::Ok); }); - submit->click(); + submitButton->click(); mcpDialogRetained = dialog->isVisible() && editor->toPlainText() == QStringLiteral("["); editor->setPlainText(QStringLiteral("{\"accepted\":true}")); - submit->click(); + submitButton->click(); }); const PendingRequestDescriptor elicitation{ "elicitation", @@ -1251,14 +2785,14 @@ bool verifyPendingRequestValidationRetainsInput() { mcpResponse->result.value("action", std::string{}) == "accept" && mcpResponse->result["content"] == nlohmann::json({{"accepted", true}}); - return expect(incompleteWarning && questionDialogRetained && answersPreserved, - "incomplete questions keep the modal and prior answers open") && - expect(invalidJsonWarning && mcpDialogRetained && validJsonReturned, - "invalid MCP JSON remains editable until a valid object is " - "submitted"); + require(incompleteWarning && questionDialogRetained && answersPreserved, + "incomplete questions retain the modal and prior authored answers"); + require( + invalidJsonWarning && mcpDialogRetained && validJsonReturned, + "invalid MCP JSON remains editable until valid authored input exists"); } -bool verifyPermissionRequestDisclosure() { +void permissionRequestDisclosure() { const nlohmann::json permissions = { {"fileSystem", {{"write", nlohmann::json::array({"/tmp/"})}, @@ -1274,10 +2808,9 @@ bool verifyPermissionRequestDisclosure() { if (!dialog) return; QStringList displayed; - for (QLabel *label : dialog->findChildren()) { + for (QLabel *label : dialog->findChildren()) if (label) displayed.push_back(label->text()); - } const QString all = displayed.join(QLatin1Char('\n')); completeDisclosure = all.contains(QStringLiteral("File system / write / 1: " @@ -1293,13 +2826,13 @@ bool verifyPermissionRequestDisclosure() { 1, {{"permissions", permissions}, {"reason", "test disclosure"}}}; const auto response = PendingRequestDialog::present(request, nullptr); - return expect(completeDisclosure, - "permission approval discloses known and future fields") && - expect(response && response->error.is_null() && - response->result.value("permissions", nlohmann::json{}) == - permissions && - response->result.value("scope", std::string{}) == "turn", - "permission approval returns the exact disclosed object"); + require(completeDisclosure, + "permission approval discloses known and future request fields"); + require(response && response->error.is_null() && + response->result.value("permissions", nlohmann::json{}) == + permissions && + response->result.value("scope", std::string{}) == "turn", + "permission approval returns the exact disclosed permissions object"); } } // namespace @@ -1311,19 +2844,41 @@ int main(int argc, char **argv) { QApplication application(argc, argv); core::SNodeC::init(argc, argv); - const bool frontendBoundary = - codexui::codex::verifyFrontendBoundaryOrdering(*configuration); - codexui::codex::FrontendSession session(*configuration); - codexui::codex::PresentationPeer peer( - codexui::codex::FrontendSessionTestPeer::takeClientDescriptor(session)); - const bool validationRetainsInput = - codexui::codex::verifyPendingRequestValidationRetainsInput(); - const bool result = frontendBoundary && - codexui::codex::verifyPendingRequestTextBoundaries() && - validationRetainsInput && - codexui::codex::verifyPermissionRequestDisclosure() && - codexui::codex::runShellFlow(session, peer); - if (result) - std::cout << "Shell integration test passed\n"; - return result ? 0 : 1; + using namespace codexui::codex; + graphNotificationsDetachBeforeRetirement(*configuration); + massRetirementIsSliced(*configuration); + selectedRemovalUnbindsBeforeWorkerRetirement(*configuration); + removedAffectedOptimisticRetryDoesNotReadReleasedNode(*configuration); + typedActionsAreExactOnceAndBounded(*configuration); + qtHeartbeatSurvivesLargeInboundTraffic(*configuration); + graphBackedShellPreservesDraftsAndPrompts(*configuration); + initialHydrationUsesTheEstablishedBoundedWindow(*configuration); + completedLiveAgentAppearsWithoutThreadReselection(*configuration); + threadSwitchStagesTheCompleteReplacement(*configuration); + inactiveThreadNeverReactivatesAStaleTurn(*configuration); + reloadAndReconnectHydrationStayExplicit(*configuration); + backgroundGraphChangesDoNotRefreshSelectedConversation(*configuration); + optimisticDraftUsesOneTypedCreateAction(*configuration); + emptyOptimisticDraftIsAbandonedOnThreadSelection(*configuration); + secondNewThreadIsGuardedWhileCreationIsInFlight(*configuration); + optimisticCreationDoesNotOverrideLaterNavigation(*configuration); + optimisticCreationIgnoresAnotherCreationCorrelation(*configuration); + saturatedShellKeepsTheEditorDraft(*configuration); + saturatedRenameRetainsAuthoredName(*configuration); + saturatedWorkerEffectsKeepNewestUiState(*configuration); + saturatedInteractionRetainsAuthoredInput(*configuration); + recoveryOnlyPromptRestoresToComposer(*configuration); + providerNoticesReachTheTransientSurface(*configuration); + failedHydrationKeepsTheEditorDraft(*configuration); + reverseInteractionCarriesOnlyAuthoredResponse(*configuration); + pendingRequestTextBoundaries(); + pendingRequestValidationRetainsInput(); + permissionRequestDisclosure(); + + if (failures != 0) { + std::cerr << failures << " shell integration assertion(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "Shell typed nodegraph integration test passed\n"; + return EXIT_SUCCESS; } diff --git a/tests/codex/SocketPairContractTest.cpp b/tests/codex/SocketPairContractTest.cpp deleted file mode 100644 index 90b0ac0..0000000 --- a/tests/codex/SocketPairContractTest.cpp +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/ipc/SNodeSocketPairEndpoint.h" -#include "codex/ipc/SocketPair.h" - -#include -#include - -#include "codex/ipc/QtSocketPairEndpoint.h" - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -constexpr std::size_t MaximumQueuedBytes = 64; -constexpr std::size_t MaximumReadBytesPerEvent = 64U * 1024U; -constexpr std::string_view FromQt = "qt-frame-1\nqt-frame-2\n"; -constexpr std::string_view FromSNode = "snode-frame-1\nsnode-frame-2\n"; -constexpr std::string_view Acknowledgement = "snode-ack\n"; - -bool expect(bool condition, const char *message) { - std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; - return condition; -} - -bool qtPartialWritesRetainOnlyQueuedBytes() { - constexpr std::size_t QueueLimit = 32U * 1024U; - codexui::codex::ipc::SocketPair pair; - if (!pair.isValid()) - return false; - const int qtDescriptor = pair.releaseFirstEndpoint(); - const int peerDescriptor = pair.releaseSecondEndpoint(); - int socketBytes = 4096; - static_cast(::setsockopt(qtDescriptor, SOL_SOCKET, SO_SNDBUF, - &socketBytes, sizeof(socketBytes))); - codexui::codex::ipc::QtSocketPairEndpoint endpoint( - qtDescriptor, QueueLimit, 64U * 1024U, 257); - const std::string chunk(2048, 'q'); - std::array drain{}; - bool bounded = true; - for (int round = 0; round < 512; ++round) { - if (!endpoint.send(chunk)) { - while (::recv(peerDescriptor, drain.data(), drain.size(), MSG_DONTWAIT) > - 0) { - } - QCoreApplication::processEvents(); - static_cast(endpoint.send(chunk)); - } - bounded &= endpoint.retainedWriteBytes() <= QueueLimit + chunk.size(); - if (round % 4 == 0) { - static_cast( - ::recv(peerDescriptor, drain.data(), drain.size(), MSG_DONTWAIT)); - QCoreApplication::processEvents(); - } - } - endpoint.close(); - ::close(peerDescriptor); - return bounded; -} - -} // namespace - -int main(int argc, char *argv[]) { - QCoreApplication application(argc, argv); - core::SNodeC::init(argc, argv); - - bool passed = expect(qtPartialWritesRetainOnlyQueuedBytes(), - "Qt partial writes retain only bounded queued bytes"); - - codexui::codex::ipc::SocketPair pair; - if (!expect(pair.isValid(), "nonblocking Unix socketpair is created")) - return 1; - - codexui::codex::ipc::QtSocketPairEndpoint qtEndpoint( - pair.releaseFirstEndpoint(), MaximumQueuedBytes); - const int snodeDescriptor = pair.releaseSecondEndpoint(); - - std::atomic_bool snodeCreated = false; - std::atomic_bool snodeBounded = false; - std::atomic_bool snodeReceived = false; - std::atomic_bool snodeClosed = false; - std::atomic_bool snodeError = false; - std::atomic_int eventLoopResult = -1; - std::string receivedBySNode; - std::promise snodeReady; - std::future ready = snodeReady.get_future(); - - std::thread snodeThread([&] { - auto *endpoint = codexui::codex::ipc::SNodeSocketPairEndpoint::create( - snodeDescriptor, MaximumQueuedBytes, MaximumReadBytesPerEvent); - snodeCreated = endpoint != nullptr; - if (!endpoint) { - snodeReady.set_value(); - QMetaObject::invokeMethod(&application, &QCoreApplication::quit, - Qt::QueuedConnection); - return; - } - - endpoint->setOnData([&, endpoint](const char *data, std::size_t size) { - receivedBySNode.append(data, size); - if (!snodeReceived && receivedBySNode == FromQt) { - snodeReceived = true; - static_cast( - endpoint->send(Acknowledgement.data(), Acknowledgement.size())); - } - }); - endpoint->setOnError([&](int) { snodeError = true; }); - endpoint->setOnClosed([&] { - snodeClosed = true; - core::SNodeC::stop(); - QMetaObject::invokeMethod(&application, &QCoreApplication::quit, - Qt::QueuedConnection); - }); - - const std::string oversized(MaximumQueuedBytes + 1, 'x'); - snodeBounded = !endpoint->send(oversized); - static_cast(endpoint->send(FromSNode.substr(0, 14).data(), 14)); - static_cast( - endpoint->send(FromSNode.substr(14).data(), FromSNode.size() - 14)); - snodeReady.set_value(); - eventLoopResult = core::SNodeC::start(utils::Timeval({5, 0})); - }); - - if (ready.wait_for(std::chrono::seconds(2)) != std::future_status::ready) { - core::SNodeC::stop(); - snodeThread.join(); - expect(false, "SNode.C socketpair endpoint becomes ready"); - return 1; - } - - std::string receivedByQt; - bool qtSent = false; - bool qtReceived = false; - bool qtClosed = false; - bool qtError = false; - qtEndpoint.setOnData([&](const char *data, std::size_t size) { - receivedByQt.append(data, size); - if (!qtSent && receivedByQt.starts_with(FromSNode)) { - qtSent = qtEndpoint.send(FromQt.substr(0, 11).data(), 11) && - qtEndpoint.send(FromQt.substr(11).data(), FromQt.size() - 11); - } - if (receivedByQt == std::string(FromSNode) + std::string(Acknowledgement)) { - qtReceived = true; - qtEndpoint.close(); - } - }); - qtEndpoint.setOnError([&](int) { qtError = true; }); - qtEndpoint.setOnClosed([&] { qtClosed = true; }); - - const std::string oversized(MaximumQueuedBytes + 1, 'x'); - const bool qtBounded = !qtEndpoint.send(oversized); - - QTimer::singleShot(5000, &application, [&] { - core::SNodeC::stop(); - application.quit(); - }); - application.exec(); - core::SNodeC::stop(); - if (qtEndpoint.isOpen()) - qtEndpoint.close(); - snodeThread.join(); - - passed &= expect(snodeCreated, "SNode.C endpoint is created"); - passed &= expect(qtBounded && snodeBounded, - "both endpoints reject writes beyond their queue bound"); - passed &= expect(qtSent && snodeReceived && receivedBySNode == FromQt, - "Qt-to-SNode.C frames preserve byte order"); - passed &= - expect(qtReceived && receivedByQt == std::string(FromSNode) + - std::string(Acknowledgement), - "SNode.C-to-Qt frames preserve byte order"); - passed &= expect(qtClosed && snodeClosed, - "closing one endpoint cleanly closes both sides"); - passed &= expect(!qtError && !snodeError, - "normal exchange and shutdown report no transport error"); - passed &= expect(eventLoopResult == 0, "SNode.C event loop exits cleanly"); - return passed ? 0 : 1; -} diff --git a/tests/codex/UiSessionTest.cpp b/tests/codex/UiSessionTest.cpp deleted file mode 100644 index 258abc2..0000000 --- a/tests/codex/UiSessionTest.cpp +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT - -#include "codex/PendingRequestPolicy.h" -#include "codex/PresentationProtocol.h" -#include "codex/UiSession.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -using codexui::codex::PresentationClient; -using codexui::codex::UiEffect; -using codexui::codex::UiNewThreadDraft; -using codexui::codex::UiPendingRequestView; -using codexui::codex::UiPromptDraft; -using codexui::codex::UiSession; -using codexui::codex::UiConversationMode; -using codexui::codex::presentation::Authority; - -struct Request { - std::string id; - std::string action; - nlohmann::json data; - PresentationClient::Completion completion; -}; - -struct Response { - nlohmann::json id; - nlohmann::json result; - nlohmann::json error; -}; - -class FakeBoundary final { -public: - PresentationClient client() { - return PresentationClient{ - [this](std::string action, nlohmann::json data, - PresentationClient::Completion completion) { - const std::string id = "request-" + std::to_string(nextId++); - requests.push_back( - {id, std::move(action), std::move(data), std::move(completion)}); - return id; - }, - [this](std::string action, nlohmann::json data) { - commands.emplace_back(std::move(action), std::move(data)); - return true; - }, - [this](nlohmann::json id, nlohmann::json result, - nlohmann::json error) { - responses.push_back( - {std::move(id), std::move(result), std::move(error)}); - return true; - }}; - } - - Request *latest(std::string_view action) { - const auto found = std::find_if( - requests.rbegin(), requests.rend(), [action](const Request &request) { - return request.action == action; - }); - return found == requests.rend() ? nullptr : &*found; - } - - std::size_t count(std::string_view action) const { - return static_cast(std::count_if( - requests.begin(), requests.end(), [action](const Request &request) { - return request.action == action; - })); - } - - std::uint64_t nextId = 1; - std::vector requests; - std::vector> commands; - std::vector responses; -}; - -bool expect(bool condition, std::string_view message) { - std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; - return condition; -} - -nlohmann::json thread(std::string id, std::string title) { - return {{"id", std::move(id)}, - {"preview", std::move(title)}, - {"cwd", "/workspace"}, - {"status", {{"type", "idle"}}}, - {"turns", nlohmann::json::array()}}; -} - -void complete(UiSession &session, Request &request, std::uint64_t sequence, - nlohmann::json data, Authority authority = Authority::None, - nlohmann::json scope = nlohmann::json::object()) { - const nlohmann::json result = codexui::codex::presentation::result( - sequence, 1, request.action, request.id, true, std::move(data), - authority, std::move(scope)); - if (request.completion) - request.completion(result); - session.onPresentationFrame(result); -} - -} // namespace - -int main() { - bool passed = true; - std::int64_t now = 1'000'000; - FakeBoundary boundary; - UiSession session(boundary.client(), "/workspace", [&now] { return now; }); - std::size_t changeCount = 0; - std::optional wakeup; - session.setChangedHandler([&changeCount] { ++changeCount; }); - session.setWakeupHandler( - [&wakeup](std::int64_t atMilliseconds) { wakeup = atMilliseconds; }); - - std::uint64_t sequence = 1; - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "connection.lifecycle", {{"state", "connected"}}, - Authority::Merge)); - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "connection.bridge", - {{"state", "opened"}, - {"connectionId", "ui-controller"}, - {"role", "controller"}}, - Authority::Merge)); - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "connection.provider", - {{"generation", std::uint64_t{1}}, {"state", "ready"}}, - Authority::Replace)); - - passed &= expect(boundary.count("threads.list") == 1 && - boundary.count("models.list") == 1 && - boundary.count("permission-profiles.list") == 1, - "provider readiness hydrates through the generic boundary"); - - nlohmann::json listedThread = thread("thread-a", "Boundary thread"); - listedThread["updatedAt"] = 20; - listedThread["recencyAt"] = 30; - session.onPresentationFrame(codexui::codex::presentation::result( - sequence++, 1, "threads.list", "catalog", true, - {{"threads", nlohmann::json::array({listedThread})}}, Authority::Merge)); - session.selectThread("thread-a"); - Request *read = boundary.latest("thread.read"); - passed &= expect(read && read->data.value("threadId", std::string{}) == - "thread-a" && - read->data.value("includeTurns", false), - "selection requests authoritative thread hydration"); - if (!read) - return 1; - complete(session, *read, sequence++, - {{"thread", thread("thread-a", "Boundary thread")}}, - Authority::Replace, {{"threadId", "thread-a"}}); - - Request *resume = boundary.latest("thread.resume"); - passed &= expect(resume && resume->data.value("excludeTurns", false), - "settings hydration remains a logic-layer operation"); - if (!resume) - return 1; - complete(session, *resume, sequence++, - {{"thread", {{"id", "thread-a"}}}, {"model", "gpt-test"}}, - Authority::Merge, {{"threadId", "thread-a"}}); - - const auto &selected = session.refreshView(true, "/workspace"); - passed &= expect(selected.selectedThreadId == "thread-a" && - selected.conversation.mode == UiConversationMode::Thread && - selected.conversation.title == "Boundary thread" && - selected.status.canSubmit && - selected.threads.canControl, - "one neutral snapshot projects the selected UI state"); - passed &= expect(selected.conversation.lastActivityAt == 30, - "selection hydration preserves authoritative thread activity"); - - UiPromptDraft prompt; - prompt.text = " inspect the boundary "; - prompt.turnStartOptions = {{"model", "gpt-test"}}; - prompt.threadStartOptions = {{"ephemeral", false}}; - prompt.workspace = "/workspace"; - prompt.visiblySelectedThreadId = "thread-a"; - passed &= expect(session.submitPrompt(std::move(prompt)), - "prompt admission is accepted by UiSession"); - passed &= expect(wakeup == now, - "transport dispatch is deferred without a new scheduler"); - session.tick(); - Request *turnStart = boundary.latest("turn.start"); - const nlohmann::json input = - turnStart ? turnStart->data.value("input", nlohmann::json::array()) - : nlohmann::json::array(); - passed &= expect(turnStart && - turnStart->data.value("threadId", std::string{}) == - "thread-a" && - input.is_array() && input.size() == 1 && - input[0].value("text", std::string{}) == - "inspect the boundary", - "queued prompt becomes the exact protocol turn operation"); - - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "pending-request.upsert", - {{"requestId", 77}, - {"category", "command-approval"}, - {"request", {{"command", "make test"}}}}, - Authority::Merge, {{"threadId", "thread-a"}, {"requestId", 77}})); - const auto &pendingView = session.refreshView(true, "/workspace"); - passed &= expect(pendingView.selectedPendingRequest && - pendingView.selectedPendingRequest->id == "77" && - pendingView.selectedPendingRequest->actionable && - pendingView.selectedPendingRequest->supportsDirectAccept, - "pending capability and eligibility cross the neutral API"); - if (!pendingView.selectedPendingRequest) - return 1; - const UiPendingRequestView pending = *pendingView.selectedPendingRequest; - passed &= expect(session.resolvePending( - pending, - codexui::codex::PendingRequestPolicy::positiveResponse( - pending.kind, pending.raw)) && - boundary.responses.size() == 1 && - boundary.responses.front().id == 77, - "typed pending response returns through the same boundary"); - - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "pending-request.upsert", - {{"requestId", 78}, - {"category", "command-approval"}, - {"request", {{"command", "stale"}}}}, - Authority::Merge, {{"threadId", "thread-a"}, {"requestId", 78}})); - const auto &staleView = session.refreshView(true, "/workspace"); - const auto staleFound = std::find_if( - staleView.pendingRequests.begin(), staleView.pendingRequests.end(), - [](const UiPendingRequestView &request) { return request.id == "78"; }); - if (staleFound == staleView.pendingRequests.end()) - return 1; - const UiPendingRequestView stale = *staleFound; - session.onPresentationFrame(codexui::codex::presentation::event( - sequence++, 1, "pending-request.removed", nlohmann::json::object(), - Authority::Remove, {{"threadId", "thread-a"}, {"requestId", 78}})); - passed &= expect( - !session.resolvePending( - stale, codexui::codex::PendingRequestPolicy::positiveResponse( - stale.kind, stale.raw)), - "stale dialog responses are rejected against the current snapshot"); - - session.beginNewThread(UiNewThreadDraft{ - "/workspace/new", "Neutral draft", {}, {}, true}); - const auto effects = session.takeEffects(); - const auto &draft = session.refreshView(true, "/workspace/new"); - passed &= expect( - draft.newThreadIntent && - draft.conversation.mode == UiConversationMode::NewThread && - draft.conversation.title == "Neutral draft" && - std::find(effects.begin(), effects.end(), - UiEffect::ClearComposerDraft) != effects.end() && - std::find(effects.begin(), effects.end(), UiEffect::FocusComposer) != - effects.end(), - "new-thread intent exposes state plus narrow renderer effects"); - passed &= expect(changeCount != 0, - "state changes notify the existing GUI-thread adapter"); - - return passed ? 0 : 1; -} diff --git a/tests/codex/nodegraph/CMakeLists.txt b/tests/codex/nodegraph/CMakeLists.txt new file mode 100644 index 0000000..0268358 --- /dev/null +++ b/tests/codex/nodegraph/CMakeLists.txt @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +add_executable(codexui-nodegraph-test NodeGraphTest.cpp) +target_link_libraries(codexui-nodegraph-test PRIVATE codexui-nodegraph) +add_test(NAME codexui-nodegraph COMMAND codexui-nodegraph-test) +set_tests_properties(codexui-nodegraph PROPERTIES TIMEOUT 10) + +add_executable(codexui-protocol-updater-test ProtocolUpdaterTest.cpp) +target_link_libraries(codexui-protocol-updater-test PRIVATE codexui-nodegraph) +add_test(NAME codexui-protocol-updater COMMAND codexui-protocol-updater-test) +set_tests_properties(codexui-protocol-updater PROPERTIES TIMEOUT 10) + +add_executable(codexui-thread-channels-test ThreadChannelsTest.cpp) +target_link_libraries(codexui-thread-channels-test PRIVATE codexui-nodegraph) +add_test(NAME codexui-thread-channels COMMAND codexui-thread-channels-test) +set_tests_properties(codexui-thread-channels PROPERTIES TIMEOUT 10) + +add_executable(codexui-worker-logic-test WorkerLogicTest.cpp) +target_link_libraries(codexui-worker-logic-test PRIVATE codexui-nodegraph) +add_test(NAME codexui-worker-logic COMMAND codexui-worker-logic-test) +set_tests_properties(codexui-worker-logic PROPERTIES TIMEOUT 10) + +add_executable(codexui-graph-concurrency-test GraphConcurrencyTest.cpp) +target_link_libraries( + codexui-graph-concurrency-test PRIVATE codexui-nodegraph Threads::Threads +) +add_test(NAME codexui-graph-concurrency COMMAND codexui-graph-concurrency-test) +set_tests_properties(codexui-graph-concurrency PROPERTIES TIMEOUT 20) diff --git a/tests/codex/nodegraph/CurrentProtocolAdaptersTest.cpp b/tests/codex/nodegraph/CurrentProtocolAdaptersTest.cpp new file mode 100644 index 0000000..c805d3a --- /dev/null +++ b/tests/codex/nodegraph/CurrentProtocolAdaptersTest.cpp @@ -0,0 +1,535 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/CurrentProtocolAdapters.h" +#include "codex/nodegraph/ProtocolCatalog.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +namespace adapters = codexui::codex::current_protocol; +namespace clientRequests = adapters::client_requests; +namespace requests = adapters::server_requests; +namespace notifications = adapters::server_notifications; +namespace generated = ai::openai::codex::generated; +namespace nodegraph = codexui::nodegraph; + +using Bridge = ai::openai::codex::frontend::CodexBridge; +using GeneratedValue = generated::Value; + +#define CODEXUI_CAPTURE_CLIENT_REQUEST(Operation, accessor) \ + std::string_view(generated::client_requests::Operation::method), +constexpr std::array GeneratedClientRequests{ + AI_OPENAI_CODEX_CLIENT_REQUESTS(CODEXUI_CAPTURE_CLIENT_REQUEST)}; +#undef CODEXUI_CAPTURE_CLIENT_REQUEST + +#define CODEXUI_CAPTURE_SERVER_REQUEST(Operation, accessor) \ + std::string_view(generated::server_requests::Operation::method), +constexpr std::array GeneratedServerRequests{ + AI_OPENAI_CODEX_SERVER_REQUESTS(CODEXUI_CAPTURE_SERVER_REQUEST)}; +#undef CODEXUI_CAPTURE_SERVER_REQUEST + +#define CODEXUI_CAPTURE_CLIENT_NOTIFICATION(Operation, accessor) \ + std::string_view(generated::client_notifications::Operation::method), +constexpr std::array GeneratedClientNotifications{ + AI_OPENAI_CODEX_CLIENT_NOTIFICATIONS(CODEXUI_CAPTURE_CLIENT_NOTIFICATION)}; +#undef CODEXUI_CAPTURE_CLIENT_NOTIFICATION + +#define CODEXUI_CAPTURE_SERVER_NOTIFICATION(Operation, accessor) \ + std::string_view(generated::server_notifications::Operation::method), +constexpr std::array GeneratedServerNotifications{ + AI_OPENAI_CODEX_SERVER_NOTIFICATIONS(CODEXUI_CAPTURE_SERVER_NOTIFICATION)}; +#undef CODEXUI_CAPTURE_SERVER_NOTIFICATION + +constexpr std::array CompatibilityClientRequests{ + clientRequests::ThreadTurnsList::method, +}; +constexpr std::array CompatibilityServerRequests{ + requests::CurrentTimeRead::method, +}; +constexpr std::array CompatibilityServerNotifications{ + notifications::ModelProviderAuthRecoveryStarted::method, + notifications::ModelProviderAuthRecoveryCompleted::method, + notifications::RawResponseItemCompleted::method, + notifications::RawResponseCompleted::method, + notifications::ThreadRealtimeItemStarted::method, + notifications::ThreadRealtimeItemTranscriptDelta::method, + notifications::ThreadRealtimeItemCompleted::method, +}; + +// The installed generated header is older than the verified app-server schema +// recorded for this migration. Keep its client-request delta explicit so the +// catalog is checked by method name rather than only by a self-reported count. +constexpr std::array VerifiedNewerClientRequests{ + "account/bedrock/discover", + "account/bedrock/setup", + "collaborationMode/list", + "environment/add", + "environment/info", + "environment/status", + "fuzzyFileSearch/sessionStart", + "fuzzyFileSearch/sessionStop", + "fuzzyFileSearch/sessionUpdate", + "getAuthStatus", + "getConversationSummary", + "gitDiffToRemote", + "mcpServer/event/stream/start", + "mcpServer/event/stream/stop", + "memory/reset", + "mock/experimentalMethod", + "plugin/search", + "process/kill", + "process/resizePty", + "process/spawn", + "process/writeStdin", + "project/create", + "project/delete", + "project/import", + "project/list", + "project/move", + "project/read", + "project/update", + "remoteControl/client/list", + "remoteControl/client/revoke", + "remoteControl/disable", + "remoteControl/enable", + "remoteControl/pairing/start", + "remoteControl/pairing/status", + "remoteControl/status/read", + "server/diagnostics", + "thread/backgroundTerminals/clean", + "thread/backgroundTerminals/list", + "thread/backgroundTerminals/terminate", + "thread/decrement_elicitation", + "thread/increment_elicitation", + "thread/items/list", + "thread/memoryMode/set", + "thread/queue/add", + "thread/queue/delete", + "thread/queue/list", + "thread/queue/reorder", + "thread/queue/start", + "thread/queue/update", + "thread/realtime/appendAudio", + "thread/realtime/appendSpeech", + "thread/realtime/appendText", + "thread/realtime/listVoices", + "thread/realtime/start", + "thread/realtime/stop", + "thread/revert", + "thread/search", + "thread/searchOccurrences", + "thread/settings/update", + "thread/timeline/list", + "thread/turns/list", + "turn/settings/update", +}; + +// These assertions deliberately couple this integration test to the installed +// generated schema. An AISuite protocol update must therefore be reconciled +// with the explicit CodexUI compatibility surface and the graph catalog. +static_assert(GeneratedClientRequests.size() == 95); +static_assert(GeneratedServerRequests.size() == 10); +static_assert(GeneratedServerNotifications.size() == 76); +static_assert(GeneratedClientNotifications.size() == 1); +static_assert(CompatibilityClientRequests.size() == 1); +static_assert(CompatibilityServerRequests.size() == 1); +static_assert(CompatibilityServerNotifications.size() == 7); +static_assert(VerifiedNewerClientRequests.size() == 62); + +template +concept BridgeServerRequest = + requires(Bridge &bridge, Bridge::EventHandler handler, + const typename Operation::Params &request, + const typename Operation::Response &response) { + bridge.template onServerRequest(std::move(handler)); + { + bridge.template respond(request, response) + } -> std::same_as; + }; + +template +concept BridgeClientRequest = + requires(Bridge &bridge, Bridge::ResponseHandler handler, + const typename Operation::Params ¶ms) { + { + bridge.template request(params, std::move(handler)) + } -> std::same_as; + }; + +template +concept BridgeServerNotification = + requires(Bridge &bridge, Bridge::EventHandler handler) { + bridge.template onServerNotification(std::move(handler)); + }; + +template +concept RequiredValueParams = + Operation::paramsRequired && + std::same_as && + std::constructible_from; + +static_assert(BridgeServerRequest); +static_assert(RequiredValueParams); +static_assert( + std::same_as); + +static_assert(BridgeClientRequest); +static_assert(RequiredValueParams); +static_assert( + std::same_as); + +static_assert( + BridgeServerNotification); +static_assert(BridgeServerNotification< + notifications::ModelProviderAuthRecoveryCompleted>); +static_assert( + BridgeServerNotification); +static_assert(BridgeServerNotification); +static_assert( + BridgeServerNotification); +static_assert( + BridgeServerNotification); +static_assert( + BridgeServerNotification); + +static_assert( + RequiredValueParams); +static_assert( + RequiredValueParams); +static_assert(RequiredValueParams); +static_assert(RequiredValueParams); +static_assert(RequiredValueParams); +static_assert( + RequiredValueParams); +static_assert(RequiredValueParams); + +bool expect(bool condition, std::string_view message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +bool contains(std::span methods, + std::string_view method) { + return std::ranges::find(methods, method) != methods.end(); +} + +bool hasUniqueMethods(std::span methods) { + for (std::size_t left = 0; left < methods.size(); ++left) { + if (std::ranges::find(methods.subspan(left + 1), methods[left]) != + methods.end()) + return false; + } + return true; +} + +bool hasDisjointMethods( + std::span generatedMethods, + std::span compatibilityMethods) { + return std::ranges::none_of(generatedMethods, + [compatibilityMethods](std::string_view method) { + return contains(compatibilityMethods, method); + }); +} + +bool catalogContainsAll(std::span sourceMethods, + nodegraph::ProtocolDirection direction, + std::string_view sourceName) { + for (const std::string_view method : sourceMethods) { + if (!nodegraph::findProtocolMethod(direction, method)) { + std::cerr << "Catalog is missing " << sourceName << " method " << method + << '\n'; + return false; + } + } + return true; +} + +bool catalogDirectionEqualsUnion( + nodegraph::ProtocolDirection direction, + std::span generatedMethods, + std::span compatibilityMethods) { + if (nodegraph::protocolMethodCount(direction) != + generatedMethods.size() + compatibilityMethods.size()) + return false; + + return std::ranges::all_of( + nodegraph::protocolMethods(), + [direction, generatedMethods, + compatibilityMethods](const nodegraph::MethodDescriptor &descriptor) { + return descriptor.direction != direction || + contains(generatedMethods, descriptor.method) || + contains(compatibilityMethods, descriptor.method); + }); +} + +bool testGeneratedSchemaCatalogCoverage() { + using enum nodegraph::ProtocolDirection; + + bool passed = true; + passed &= expect(hasUniqueMethods(GeneratedClientRequests) && + hasUniqueMethods(GeneratedServerRequests) && + hasUniqueMethods(GeneratedServerNotifications) && + hasUniqueMethods(GeneratedClientNotifications), + "generated ProtocolTypes macros contain unique methods"); + passed &= expect( + hasUniqueMethods(CompatibilityClientRequests) && + hasUniqueMethods(CompatibilityServerRequests) && + hasUniqueMethods(CompatibilityServerNotifications) && + hasUniqueMethods(VerifiedNewerClientRequests) && + hasDisjointMethods(GeneratedClientRequests, + VerifiedNewerClientRequests) && + hasDisjointMethods(GeneratedServerRequests, + CompatibilityServerRequests) && + hasDisjointMethods(GeneratedServerNotifications, + CompatibilityServerNotifications), + "compatibility adapters are unique additions to generated ProtocolTypes"); + + passed &= + expect(std::ranges::all_of(CompatibilityClientRequests, + [](std::string_view method) { + return contains(VerifiedNewerClientRequests, + method); + }), + "typed client compatibility adapters belong to the verified " + "schema delta"); + + passed &= expect( + catalogContainsAll(GeneratedClientRequests, ClientRequest, + "generated client request") && + catalogContainsAll(GeneratedServerRequests, ServerRequest, + "generated server request") && + catalogContainsAll(GeneratedServerNotifications, ServerNotification, + "generated server notification") && + catalogContainsAll(GeneratedClientNotifications, ClientNotification, + "generated client notification"), + "catalog classifies every method in generated ProtocolTypes"); + passed &= + expect(catalogContainsAll(VerifiedNewerClientRequests, ClientRequest, + "verified newer client request"), + "catalog classifies every newer-schema client request"); + passed &= + expect(catalogContainsAll(CompatibilityClientRequests, ClientRequest, + "compatibility client request") && + catalogContainsAll(CompatibilityServerRequests, ServerRequest, + "compatibility server request") && + catalogContainsAll(CompatibilityServerNotifications, + ServerNotification, + "compatibility server notification"), + "catalog classifies every explicit CodexUI compatibility adapter"); + + passed &= + expect(catalogDirectionEqualsUnion(ServerRequest, GeneratedServerRequests, + CompatibilityServerRequests), + "11 server requests exactly match generated types plus adapters"); + passed &= expect( + catalogDirectionEqualsUnion(ClientRequest, GeneratedClientRequests, + VerifiedNewerClientRequests), + "157 client requests exactly match generated types plus verified delta"); + passed &= expect( + catalogDirectionEqualsUnion(ServerNotification, + GeneratedServerNotifications, + CompatibilityServerNotifications), + "83 server notifications exactly match generated types plus adapters"); + passed &= + expect(catalogDirectionEqualsUnion(ClientNotification, + GeneratedClientNotifications, {}), + "one client notification exactly matches generated ProtocolTypes"); + + return passed; +} + +template +bool notificationPayloadRoundTrips(nlohmann::json payload) { + const nlohmann::json envelope{ + {"jsonrpc", "2.0"}, {"method", Operation::method}, {"params", payload}}; + const typename Operation::Params params(envelope); + return params.jsonRpcMethod() == Operation::method && + params.jsonRpcId().is_null() && params.getPayload() == payload; +} + +bool testExactMethods() { + constexpr std::array methods{ + clientRequests::ThreadTurnsList::method, + requests::CurrentTimeRead::method, + notifications::ModelProviderAuthRecoveryStarted::method, + notifications::ModelProviderAuthRecoveryCompleted::method, + notifications::RawResponseItemCompleted::method, + notifications::RawResponseCompleted::method, + notifications::ThreadRealtimeItemStarted::method, + notifications::ThreadRealtimeItemTranscriptDelta::method, + notifications::ThreadRealtimeItemCompleted::method, + }; + constexpr std::array expected{ + std::string_view("thread/turns/list"), + std::string_view("currentTime/read"), + std::string_view("modelProvider/authRecoveryStarted"), + std::string_view("modelProvider/authRecoveryCompleted"), + std::string_view("rawResponseItem/completed"), + std::string_view("rawResponse/completed"), + std::string_view("thread/realtime/item/started"), + std::string_view("thread/realtime/item/transcript/delta"), + std::string_view("thread/realtime/item/completed"), + }; + return expect(methods == expected, + "adapter methods exactly match the current wire protocol"); +} + +bool testCurrentTimeRequestAndResponse() { + const nlohmann::json payload{{"threadId", "thread-clock"}}; + const nlohmann::json envelope{{"jsonrpc", "2.0"}, + {"id", "clock-request-7"}, + {"method", requests::CurrentTimeRead::method}, + {"params", payload}}; + const requests::CurrentTimeRead::Params request(envelope); + const requests::CurrentTimeRead::Response response( + nlohmann::json{{"currentTimeAt", 1'725'210'123}}); + + bool passed = true; + passed &= + expect(request.jsonRpcMethod() == requests::CurrentTimeRead::method && + request.jsonRpcId() == "clock-request-7" && + request.getPayload() == payload, + "current-time Params retain the request id and payload"); + passed &= expect(response.getPayload() == + nlohmann::json{{"currentTimeAt", 1'725'210'123}}, + "current-time Response exposes the exact result payload"); + return passed; +} + +bool testNotificationPayloads() { + bool passed = true; + passed &= expect(notificationPayloadRoundTrips< + notifications::ModelProviderAuthRecoveryStarted>( + {{"provider", "openai"}, {"attempt", 2}}), + "auth-recovery-started Params retain their payload"); + passed &= expect(notificationPayloadRoundTrips< + notifications::ModelProviderAuthRecoveryCompleted>( + {{"provider", "openai"}, {"recovered", true}}), + "auth-recovery-completed Params retain their payload"); + passed &= expect( + notificationPayloadRoundTrips( + {{"threadId", "thread-1"}, + {"turnId", "turn-1"}, + {"item", {{"type", "message"}, {"id", "response-item-1"}}}}), + "raw-response-item Params retain nested payloads"); + passed &= + expect(notificationPayloadRoundTrips( + {{"threadId", "thread-1"}, + {"turnId", "turn-1"}, + {"responseId", "response-1"}, + {"usage", {{"inputTokens", 12}, {"outputTokens", 4}}}}), + "raw-response Params retain nested payloads"); + passed &= expect( + notificationPayloadRoundTrips( + {{"threadId", "thread-rt"}, {"itemId", "item-rt"}}), + "realtime-item-started Params retain their payload"); + passed &= + expect(notificationPayloadRoundTrips< + notifications::ThreadRealtimeItemTranscriptDelta>( + {{"threadId", "thread-rt"}, + {"itemId", "item-rt"}, + {"delta", "hello"}}), + "realtime-item-transcript-delta Params retain their payload"); + passed &= expect( + notificationPayloadRoundTrips( + {{"threadId", "thread-rt"}, {"itemId", "item-rt"}}), + "realtime-item-completed Params retain their payload"); + return passed; +} + +bool testCodexBridgeDispatchesCompatibilityOperations() { + nlohmann::json sent; + Bridge bridge([&sent](const nlohmann::json &message) { + sent = message; + return true; + }); + bool currentTimeHandled = false; + std::size_t notificationCount = 0; + bridge.onServerRequest( + [&](requests::CurrentTimeRead::Params &request) { + currentTimeHandled = request.jsonRpcId() == "clock-bridge" && + request.getPayload().value( + "threadId", std::string{}) == "thread-bridge"; + const requests::CurrentTimeRead::Response response( + nlohmann::json{{"currentTimeAt", 1'725'210'123}}); + static_cast( + bridge.respond(request, response)); + }); +#define CODEXUI_TEST_REGISTER_NOTIFICATION(OperationName) \ + bridge.onServerNotification( \ + [¬ificationCount](notifications::OperationName::Params &) { \ + ++notificationCount; \ + }); + CODEXUI_TEST_REGISTER_NOTIFICATION(ModelProviderAuthRecoveryStarted) + CODEXUI_TEST_REGISTER_NOTIFICATION(ModelProviderAuthRecoveryCompleted) + CODEXUI_TEST_REGISTER_NOTIFICATION(RawResponseItemCompleted) + CODEXUI_TEST_REGISTER_NOTIFICATION(RawResponseCompleted) + CODEXUI_TEST_REGISTER_NOTIFICATION(ThreadRealtimeItemStarted) + CODEXUI_TEST_REGISTER_NOTIFICATION(ThreadRealtimeItemTranscriptDelta) + CODEXUI_TEST_REGISTER_NOTIFICATION(ThreadRealtimeItemCompleted) +#undef CODEXUI_TEST_REGISTER_NOTIFICATION + + bool accepted = bridge.receive({{"kind", "bridge.connection"}, + {"event", "opened"}, + {"connectionId", "bridge-test"}, + {"role", "controller"}}); + accepted &= bridge.receive({{"kind", "appserver"}, + {"payload", + {{"jsonrpc", "2.0"}, + {"id", "clock-bridge"}, + {"method", requests::CurrentTimeRead::method}, + {"params", {{"threadId", "thread-bridge"}}}}}}); + + constexpr std::array notificationMethods{ + notifications::ModelProviderAuthRecoveryStarted::method, + notifications::ModelProviderAuthRecoveryCompleted::method, + notifications::RawResponseItemCompleted::method, + notifications::RawResponseCompleted::method, + notifications::ThreadRealtimeItemStarted::method, + notifications::ThreadRealtimeItemTranscriptDelta::method, + notifications::ThreadRealtimeItemCompleted::method, + }; + for (const std::string_view method : notificationMethods) { + accepted &= bridge.receive({{"kind", "appserver"}, + {"payload", + {{"jsonrpc", "2.0"}, + {"method", method}, + {"params", {{"marker", method}}}}}}); + } + + const nlohmann::json response = + sent.value("payload", nlohmann::json::object()); + return expect(accepted && currentTimeHandled && notificationCount == 7, + "CodexBridge dispatches every compatibility operation") && + expect(response.value("id", std::string{}) == "clock-bridge" && + response.value("result", nlohmann::json::object()) + .value("currentTimeAt", std::int64_t{}) == + 1'725'210'123, + "CodexBridge emits the typed current-time response"); +} + +} // namespace + +int main() { + bool passed = true; + passed &= testGeneratedSchemaCatalogCoverage(); + passed &= testExactMethods(); + passed &= testCurrentTimeRequestAndResponse(); + passed &= testNotificationPayloads(); + passed &= testCodexBridgeDispatchesCompatibilityOperations(); + return passed ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/codex/nodegraph/GraphConcurrencyTest.cpp b/tests/codex/nodegraph/GraphConcurrencyTest.cpp new file mode 100644 index 0000000..aa1dc17 --- /dev/null +++ b/tests/codex/nodegraph/GraphConcurrencyTest.cpp @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/NodeGraph.h" +#include "codex/nodegraph/SpscQueue.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using codexui::nodegraph::GraphChange; +using codexui::nodegraph::NodeGraph; +using codexui::nodegraph::NodeId; +using codexui::nodegraph::NodeKind; +using codexui::nodegraph::NodeRef; +using codexui::nodegraph::NodeState; +using codexui::nodegraph::SpscQueue; +using codexui::nodegraph::Value; + +int failures = 0; + +void expect(bool condition, std::string_view message) { + if (condition) + return; + ++failures; + std::cerr << "FAILED: " << message << '\n'; +} + +const std::uint64_t * +unsignedField(const std::shared_ptr &state, + std::string_view key) { + if (!state) + return nullptr; + const auto found = state->fields.find(key); + if (found == state->fields.end()) + return nullptr; + return found->second.asUInt64(); +} + +void readNeverWaitsForWriter() { + NodeGraph graph; + std::barrier phase(2); + std::atomic readerWasRejected{false}; + + std::thread writer([&] { + auto write = graph.write(); + static_cast( + write.upsert(NodeId{NodeKind::Runtime, "runtime-under-write"})); + phase.arrive_and_wait(); + phase.arrive_and_wait(); + static_cast(write.finish()); + }); + + phase.arrive_and_wait(); + { + auto read = graph.tryRead(); + readerWasRejected.store(!read.has_value(), std::memory_order_release); + } + phase.arrive_and_wait(); + writer.join(); + + expect(readerWasRejected.load(std::memory_order_acquire), + "tryRead rejects immediately while WriteAccess owns the graph"); +} + +void correlatedStateIsPublishedAtomically() { + constexpr std::uint64_t Iterations = 5'000; + + NodeGraph graph; + NodeRef node; + std::uint64_t initialRevision = 0; + { + auto write = graph.write(); + node = write.upsert(NodeId{NodeKind::Thread, "atomic-thread"}, + NodeState{.fields = {{"left", std::uint64_t{0}}, + {"right", std::uint64_t{0}}}}); + initialRevision = write.finish().revision; + } + + std::barrier phase(2); + std::atomic valid{true}; + + std::thread writer([&] { + for (std::uint64_t sequence = 1; sequence <= Iterations; ++sequence) { + auto write = graph.write(); + write.setField(node, "left", sequence); + + // Let the Qt-like reader attempt a non-blocking read while the update is + // deliberately incomplete and the write lock is still held. + phase.arrive_and_wait(); + phase.arrive_and_wait(); + + write.setField(node, "right", sequence); + const GraphChange change = write.finish(); + if (change.revision != initialRevision + sequence || + change.affected.size() != 1 || change.affected.front() != node) + valid.store(false, std::memory_order_relaxed); + + // Keep the next write transaction from starting until the reader has + // inspected this complete revision. + phase.arrive_and_wait(); + phase.arrive_and_wait(); + } + }); + + std::thread reader([&] { + for (std::uint64_t sequence = 1; sequence <= Iterations; ++sequence) { + phase.arrive_and_wait(); + { + auto unavailable = graph.tryRead(); + if (unavailable) + valid.store(false, std::memory_order_relaxed); + } + phase.arrive_and_wait(); + + phase.arrive_and_wait(); + { + auto read = graph.tryRead(); + if (!read) { + valid.store(false, std::memory_order_relaxed); + } else { + const auto state = read->state(node); + const std::uint64_t *left = unsignedField(state, "left"); + const std::uint64_t *right = unsignedField(state, "right"); + if (!left || !right || *left != sequence || *right != sequence || + read->revision() != initialRevision + sequence || + read->changedRevision(node) != read->revision()) + valid.store(false, std::memory_order_relaxed); + } + } + phase.arrive_and_wait(); + } + }); + + writer.join(); + reader.join(); + + expect(valid.load(std::memory_order_relaxed), + "readers see either no lock or one complete correlated revision"); + expect(graph.publishedRevision() == initialRevision + Iterations, + "each correlated transaction publishes exactly one revision"); +} + +void pinsAndNodeRefsOutliveReplacementAndRemoval() { + NodeGraph graph; + NodeRef node; + { + auto write = graph.write(); + node = + write.upsert(NodeId{NodeKind::Item, "pinned-item"}, + NodeState{.fields = {{"generation", std::uint64_t{1}}}}); + static_cast(write.finish()); + } + + std::shared_ptr pinnedState; + { + auto read = graph.tryRead(); + if (read) + pinnedState = read->state(node); + } + std::weak_ptr weakState = pinnedState; + std::weak_ptr weakNode = node; + + std::barrier phase(2); + std::atomic writerValid{true}; + std::thread writer([&, workerNode = node] { + phase.arrive_and_wait(); + { + auto write = graph.write(); + write.replaceState( + workerNode, NodeState{.fields = {{"generation", std::uint64_t{2}}}}); + const GraphChange replaced = write.finish(); + if (replaced.affected.size() != 1 || + replaced.affected.front() != workerNode) + writerValid.store(false, std::memory_order_relaxed); + } + phase.arrive_and_wait(); + phase.arrive_and_wait(); + + GraphChange removed; + { + auto write = graph.write(); + write.remove(workerNode); + removed = write.finish(); + } + if (removed.removed.size() != 1 || removed.removed.front() != workerNode) + writerValid.store(false, std::memory_order_relaxed); + { + auto write = graph.write(); + write.releaseRetired(removed.removed); + static_cast(write.finish()); + } + phase.arrive_and_wait(); + }); + + phase.arrive_and_wait(); + phase.arrive_and_wait(); + const std::uint64_t *initialGeneration = + unsignedField(pinnedState, "generation"); + expect(initialGeneration && *initialGeneration == 1, + "an immutable state pin survives concurrent state replacement"); + expect(node && node->id().canonical == "pinned-item" && !weakNode.expired() && + !weakState.expired(), + "a stable NodeRef and state pin remain alive after replacement"); + phase.arrive_and_wait(); + phase.arrive_and_wait(); + writer.join(); + + expect(writerValid.load(std::memory_order_relaxed), + "the worker replaced, removed, and retired the same NodeRef"); + { + auto read = graph.tryRead(); + expect(read && !read->find(NodeId{NodeKind::Item, "pinned-item"}) && + read->retiredNodes().empty(), + "removal unlinks the node and releases graph ownership"); + } + initialGeneration = unsignedField(pinnedState, "generation"); + expect(initialGeneration && *initialGeneration == 1 && node && + node->id().canonical == "pinned-item", + "external pins remain valid after removal and retirement release"); + + pinnedState.reset(); + expect(weakState.expired(), + "replaced immutable storage is destroyed after its pin is released"); + node.reset(); + expect(weakNode.expired(), + "a removed node is destroyed after the final NodeRef is released"); +} + +struct MoveOnlyPayload final { + std::uint64_t sequence = 0; + bool owned = false; + + MoveOnlyPayload() = default; + explicit MoveOnlyPayload(std::uint64_t value) + : sequence(value), owned(true) {} + + MoveOnlyPayload(const MoveOnlyPayload &) = delete; + MoveOnlyPayload &operator=(const MoveOnlyPayload &) = delete; + + MoveOnlyPayload(MoveOnlyPayload &&other) noexcept + : sequence(other.sequence), owned(std::exchange(other.owned, false)) {} + + MoveOnlyPayload &operator=(MoveOnlyPayload &&other) noexcept { + if (this == &other) + return *this; + sequence = other.sequence; + owned = std::exchange(other.owned, false); + return *this; + } +}; + +void queuePreservesRejectedAndOrderedMoveOnlyPayloads() { + { + SpscQueue queue; + for (std::uint64_t sequence = 0; sequence < queue.capacity(); ++sequence) { + MoveOnlyPayload payload(sequence); + expect(queue.tryPush(std::move(payload)), + "an available queue slot admits a move-only payload"); + expect(!payload.owned, "an admitted payload transfers ownership once"); + } + expect(queue.full() && queue.sizeApprox() == queue.capacity(), + "all declared queue slots are usable"); + + MoveOnlyPayload rejected(99); + expect(!queue.tryPush(std::move(rejected)), + "a full queue explicitly rejects a payload"); + expect(rejected.owned && rejected.sequence == 99, + "full rejection leaves the producer-owned payload intact"); + + for (std::uint64_t sequence = 0; sequence < queue.capacity(); ++sequence) { + MoveOnlyPayload received; + expect(queue.tryPop(received) && received.owned && + received.sequence == sequence, + "the bounded queue retains FIFO order"); + } + expect(queue.empty(), "the bounded queue is empty after every pop"); + } + + constexpr std::uint64_t Transfers = 1'250'000; + SpscQueue queue; + std::barrier launch(2); + std::atomic valid{true}; + + std::thread producer([&] { + launch.arrive_and_wait(); + for (std::uint64_t sequence = 0; sequence < Transfers; ++sequence) { + MoveOnlyPayload payload(sequence); + while (!queue.tryPush(std::move(payload))) { + if (!payload.owned || payload.sequence != sequence) + valid.store(false, std::memory_order_relaxed); + } + if (payload.owned) + valid.store(false, std::memory_order_relaxed); + } + }); + + std::thread consumer([&] { + launch.arrive_and_wait(); + for (std::uint64_t expected = 0; expected < Transfers; ++expected) { + MoveOnlyPayload received; + while (!queue.tryPop(received)) { + } + if (!received.owned || received.sequence != expected) + valid.store(false, std::memory_order_relaxed); + } + }); + + producer.join(); + consumer.join(); + + expect(valid.load(std::memory_order_relaxed), + "at least one million move-only payloads cross exactly once in order"); + expect(queue.empty() && queue.sizeApprox() == 0, + "the stressed queue drains completely"); +} + +} // namespace + +int main() { + readNeverWaitsForWriter(); + correlatedStateIsPublishedAtomically(); + pinsAndNodeRefsOutliveReplacementAndRemoval(); + queuePreservesRejectedAndOrderedMoveOnlyPayloads(); + + if (failures != 0) + std::cerr << failures << " graph concurrency assertion(s) failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/codex/nodegraph/NodeGraphJsonTest.cpp b/tests/codex/nodegraph/NodeGraphJsonTest.cpp new file mode 100644 index 0000000..fa5bf39 --- /dev/null +++ b/tests/codex/nodegraph/NodeGraphJsonTest.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/NodeGraphJson.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using codexui::codex::jsonFromRequestId; +using codexui::codex::jsonFromValue; +using codexui::codex::objectFromJson; +using codexui::codex::requestIdFromJson; +using codexui::codex::valueFromJson; +using codexui::nodegraph::ProtocolRequestId; +using codexui::nodegraph::Value; + +int failures = 0; + +void expect(bool condition, std::string_view message) { + if (condition) + return; + ++failures; + std::cerr << "FAILED: " << message << '\n'; +} + +template +bool throws(Operation &&operation) { + try { + operation(); + } catch (const Exception &) { + return true; + } catch (...) { + } + return false; +} + +void everyDomAlternativeKeepsItsType() { + const nlohmann::json source = nlohmann::json::object( + {{"null", nullptr}, + {"boolean", true}, + {"signed", std::numeric_limits::min()}, + {"unsigned", std::numeric_limits::max()}, + {"float", 0.125}, + {"string", std::string("embedded\0nul", 12)}, + {"array", nlohmann::json::array( + {nullptr, false, std::int64_t{-1}, std::uint64_t{2}, 3.5, + "tail", nlohmann::json::object({{"deep", 7}})})}, + {"object", nlohmann::json::object({{"nested", "value"}})}}); + + const Value converted = valueFromJson(source); + const Value::Object *object = converted.asObject(); + expect(object != nullptr && object->size() == source.size(), + "a JSON object becomes one directly owned Value object"); + if (!object) + return; + + expect(object->at("null").isNull(), "null retains its alternative"); + expect(object->at("boolean").asBool() && *object->at("boolean").asBool(), + "boolean retains its alternative"); + expect(object->at("signed").asInt64() && + *object->at("signed").asInt64() == + std::numeric_limits::min(), + "signed integer retains its full range"); + expect(object->at("unsigned").asUInt64() && + *object->at("unsigned").asUInt64() == + std::numeric_limits::max(), + "unsigned integer retains its full range"); + expect(object->at("float").asDouble() && + *object->at("float").asDouble() == 0.125, + "floating point retains its alternative"); + expect(object->at("string").asString() && + *object->at("string").asString() == + std::string("embedded\0nul", 12), + "strings retain embedded null bytes"); + expect(object->at("array").asArray() && + object->at("array").asArray()->size() == 7 && + object->at("array").asArray()->back().asObject(), + "arrays and nested objects convert recursively"); + + const nlohmann::json roundTrip = jsonFromValue(converted); + expect(roundTrip == source, "the complete nested DOM round-trips by value"); + expect(roundTrip.at("signed").type() == + nlohmann::json::value_t::number_integer && + roundTrip.at("unsigned").type() == + nlohmann::json::value_t::number_unsigned && + roundTrip.at("float").type() == + nlohmann::json::value_t::number_float, + "reverse conversion preserves every numeric DOM alternative"); +} + +void objectPayloadsAreRequiredExplicitly() { + const nlohmann::json source = nlohmann::json::object( + {{"threadId", "thread-1"}, {"attempt", std::uint64_t{4}}}); + const Value::Object object = objectFromJson(source); + expect(object.size() == 2 && object.at("threadId").asString() && + *object.at("threadId").asString() == "thread-1" && + object.at("attempt").asUInt64() && + *object.at("attempt").asUInt64() == 4, + "the object helper returns a typed nodegraph payload"); + expect(throws([] { + static_cast(objectFromJson(nlohmann::json::array())); + }), + "the object helper rejects non-object payloads"); + + const nlohmann::json binary = + nlohmann::json::binary({std::uint8_t{1}, std::uint8_t{2}}); + expect(throws( + [&] { static_cast(valueFromJson(binary)); }), + "unsupported binary DOM values are rejected rather than corrupted"); +} + +void requestIdsAcceptOnlyLosslessStringOrIntegerValues() { + const ProtocolRequestId text = requestIdFromJson("request-7"); + const ProtocolRequestId negative = requestIdFromJson(std::int64_t{-9}); + const ProtocolRequestId unsignedInRange = + requestIdFromJson(std::uint64_t{42}); + + expect(std::get(text.value) == "request-7" && + jsonFromRequestId(text).type() == nlohmann::json::value_t::string, + "string request IDs preserve their value and type"); + expect(std::get(negative.value) == -9 && + jsonFromRequestId(negative).type() == + nlohmann::json::value_t::number_integer, + "signed request IDs preserve their value and type"); + expect(std::get(unsignedInRange.value) == 42, + "representable unsigned DOM integers remain exact request IDs"); + + expect(throws( + [] { static_cast(requestIdFromJson(nullptr)); }) && + throws( + [] { static_cast(requestIdFromJson(true)); }) && + throws( + [] { static_cast(requestIdFromJson(1.25)); }), + "null, boolean, and floating request IDs are rejected"); + expect(throws([] { + static_cast( + requestIdFromJson(std::numeric_limits::max())); + }), + "unsigned request IDs outside ProtocolRequestId range are rejected"); +} + +} // namespace + +int main() { + everyDomAlternativeKeepsItsType(); + objectPayloadsAreRequiredExplicitly(); + requestIdsAcceptOnlyLosslessStringOrIntegerValues(); + + if (failures != 0) + std::cerr << failures << " node graph JSON assertion(s) failed\n"; + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/codex/nodegraph/NodeGraphTest.cpp b/tests/codex/nodegraph/NodeGraphTest.cpp new file mode 100644 index 0000000..cc7d772 --- /dev/null +++ b/tests/codex/nodegraph/NodeGraphTest.cpp @@ -0,0 +1,843 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/NodeGraph.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using codexui::nodegraph::GraphChange; +using codexui::nodegraph::NodeGraph; +using codexui::nodegraph::NodeId; +using codexui::nodegraph::NodeKind; +using codexui::nodegraph::NodeRef; +using codexui::nodegraph::NodeState; +using codexui::nodegraph::NodeStatus; +using codexui::nodegraph::RelationKind; +using codexui::nodegraph::Value; + +bool expect(bool condition, std::string_view message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +template +bool throws(Operation &&operation) { + try { + operation(); + } catch (const Exception &) { + return true; + } catch (...) { + } + return false; +} + +NodeId id(NodeKind kind, std::string canonical) { + return NodeId{kind, std::move(canonical)}; +} + +std::size_t count(const std::vector &nodes, const NodeRef &wanted) { + return static_cast( + std::count(nodes.begin(), nodes.end(), wanted)); +} + +bool sameOrder(const std::vector &actual, + std::initializer_list expected) { + return actual.size() == expected.size() && + std::equal(actual.begin(), actual.end(), expected.begin()); +} + +bool testValue() { + const Value nullValue; + const Value explicitNull = nullptr; + const Value boolean = true; + const Value signedInteger = -7; + const Value unsignedInteger = std::uint32_t{9}; + const Value real = 2.5; + const Value string = "node"; + const Value array = + Value::Array{nullptr, false, -3, std::uint64_t{4}, 1.25, "tail"}; + const Value object = Value::Object{ + {"enabled", true}, + {"nested", Value::Object{{"name", "thread"}}}, + {"values", Value::Array{1, 2, 3}}, + }; + + bool passed = true; + passed &= expect(nullValue.isNull() && explicitNull == nullValue, + "Value represents null by default and explicitly"); + passed &= expect(boolean.isBool() && boolean.asBool() && *boolean.asBool() && + !boolean.asString(), + "Value exposes bool only through its exact accessor"); + passed &= + expect(signedInteger.isSigned() && signedInteger.asInt64() && + *signedInteger.asInt64() == -7 && !signedInteger.asUInt64(), + "Value preserves signed integers without coercion"); + passed &= + expect(unsignedInteger.isUnsigned() && unsignedInteger.asUInt64() && + *unsignedInteger.asUInt64() == 9 && !unsignedInteger.asInt64(), + "Value preserves unsigned integers without coercion"); + passed &= + expect(real.isDouble() && real.asDouble() && *real.asDouble() == 2.5, + "Value preserves floating-point values"); + passed &= expect(string.isString() && string.asString() && + *string.asString() == "node", + "Value owns string values"); + passed &= expect(array.isArray() && array.asArray() && + array.asArray()->size() == 6 && + array.asArray()->back().asString(), + "Value recursively owns ordered arrays"); + + const Value *nested = object.find("nested"); + const Value *name = nested ? nested->find("name") : nullptr; + passed &= expect(object.isObject() && name && name->asString() && + *name->asString() == "thread" && + object.find("missing") == nullptr && + string.find("name") == nullptr, + "Value object lookup is typed, nested, and non-throwing"); + + Value mutableObject = Value::Object{{"name", "before"}}; + Value *mutableName = mutableObject.find("name"); + if (mutableName) + *mutableName = "after"; + passed &= expect(mutableName && mutableName->asString() && + *mutableName->asString() == "after", + "Value mutable lookup addresses the owned member"); + + const Value equalObject = Value::Object{ + {"enabled", true}, + {"nested", Value::Object{{"name", "thread"}}}, + {"values", Value::Array{1, 2, 3}}, + }; + passed &= expect(object == equalObject && + object != Value(Value::Object{{"enabled", false}}) && + Value(std::int64_t{1}) != Value(std::uint64_t{1}), + "Value equality is recursive and alternative-sensitive"); + return passed; +} + +bool testInsertionLookupAndOrder() { + NodeGraph graph; + const NodeId runtimeId = id(NodeKind::Runtime, "runtime"); + const NodeId threadId = id(NodeKind::Thread, "shared-id"); + const NodeId turnId = id(NodeKind::Turn, "shared-id"); + + NodeRef runtime; + NodeRef thread; + NodeRef turn; + GraphChange inserted; + { + auto write = graph.write(); + runtime = write.upsert(runtimeId); + thread = write.upsert(threadId, + NodeState{NodeStatus::Pending, {{"title", "One"}}}); + turn = write.upsert(turnId); + inserted = write.finish(); + } + + bool passed = true; + passed &= expect(inserted.revision == 1 && graph.publishedRevision() == 1 && + sameOrder(inserted.affected, {runtime, thread, turn}) && + inserted.removed.empty(), + "one insertion transaction publishes one ordered change"); + { + auto read = graph.tryRead(); + passed &= expect(read.has_value(), "the graph is readable after publish"); + if (!read) + return false; + passed &= expect( + read->revision() == 1 && read->insertionOrder(runtime) == 1 && + read->insertionOrder(thread) == 2 && + read->insertionOrder(turn) == 3 && + read->find(runtimeId) == runtime && + read->find(threadId) == thread && read->find(turnId) == turn && + !read->find(id(NodeKind::Item, "missing")), + "canonical kind and id lookup returns stable NodeRefs"); + passed &= expect(sameOrder(read->orderedNodes(), {runtime, thread, turn}), + "graph iteration retains insertion order"); + const auto state = read->state(thread); + if (!expect(state != nullptr, "a live node exposes immutable state")) + return false; + const auto title = state->fields.find("title"); + passed &= + expect(state && state->status == NodeStatus::Pending && + title != state->fields.end() && title->second.asString() && + *title->second.asString() == "One", + "inserted nodes retain their initial state"); + } + + { + auto write = graph.write(); + passed &= expect(write.upsert(threadId) == thread, + "upsert preserves an existing node identity"); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 1 && unchanged.empty() && + graph.publishedRevision() == 1, + "existing-node upsert does not publish a revision"); + } + return passed; +} + +bool testAtomicStateRelationsAndNoOp() { + NodeGraph graph; + NodeRef parent; + NodeRef firstChild; + NodeRef secondChild; + NodeRef firstTarget; + NodeRef secondTarget; + { + auto write = graph.write(); + parent = write.upsert(id(NodeKind::Thread, "parent")); + firstChild = write.upsert(id(NodeKind::Turn, "turn-1")); + secondChild = write.upsert(id(NodeKind::Turn, "turn-2")); + firstTarget = write.upsert(id(NodeKind::Operation, "operation-1")); + secondTarget = write.upsert(id(NodeKind::Operation, "operation-2")); + static_cast(write.finish()); + } + + std::shared_ptr pinnedInitial; + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "initial state can be pinned")) + return false; + pinnedInitial = read->state(parent); + } + + GraphChange changed; + { + auto write = graph.write(); + write.setStatus(parent, NodeStatus::Running); + write.setField(parent, "title", "Atomic update"); + write.setField(parent, "sequence", std::uint64_t{12}); + write.setField(parent, "stream", "first second"); + write.setParent(parent, firstChild); + write.setParent(parent, secondChild); + write.setParent(parent, firstChild); + write.relate(parent, RelationKind::OperationTarget, firstTarget); + write.relate(parent, RelationKind::OperationTarget, secondTarget); + write.relate(parent, RelationKind::OperationTarget, firstTarget); + changed = write.finish(); + } + + bool passed = true; + passed &= expect(changed.revision == 2 && graph.publishedRevision() == 2, + "many state and relation changes publish one revision"); + passed &= expect(count(changed.affected, parent) == 1 && + count(changed.affected, firstChild) == 1 && + count(changed.affected, secondChild) == 1 && + count(changed.affected, firstTarget) == 1 && + count(changed.affected, secondTarget) == 1, + "a transaction reports each affected node once"); + passed &= + expect(pinnedInitial && pinnedInitial->status == NodeStatus::Unknown && + pinnedInitial->fields.empty(), + "a pinned immutable state survives later replacement"); + + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "the complete transaction is readable")) + return false; + const auto current = read->state(parent); + if (!expect(current != nullptr, "a live node retains current state")) + return false; + const auto title = current->fields.find("title"); + const auto sequence = current->fields.find("sequence"); + const auto stream = current->fields.find("stream"); + passed &= expect( + current != pinnedInitial && current->status == NodeStatus::Running && + title != current->fields.end() && title->second.asString() && + *title->second.asString() == "Atomic update" && + sequence != current->fields.end() && sequence->second.asUInt64() && + *sequence->second.asUInt64() == 12 && + stream != current->fields.end() && stream->second.asString() && + *stream->second.asString() == "first second", + "all state mutations become visible together"); + passed &= expect( + sameOrder(read->children(parent), {firstChild, secondChild}) && + read->childCount(parent) == 2 && + read->childAt(parent, 0) == firstChild && + read->childAt(parent, 1) == secondChild && + !read->childAt(parent, 2) && read->childCount({}) == 0 && + !read->childAt({}, 0) && read->parent(firstChild) == parent && + read->parent(secondChild) == parent, + "parent and bounded child access preserve order and deduplicate"); + passed &= expect( + sameOrder(read->related(parent, RelationKind::OperationTarget), + {firstTarget, secondTarget}) && + read->relatedCount(parent, RelationKind::OperationTarget) == 2 && + read->relatedAt(parent, RelationKind::OperationTarget, 0) == + firstTarget && + read->relatedAt(parent, RelationKind::OperationTarget, 1) == + secondTarget && + !read->relatedAt(parent, RelationKind::OperationTarget, 2) && + read->relatedCount(parent, RelationKind::ProcessOwner) == 0 && + !read->relatedAt(parent, RelationKind::ProcessOwner, 0) && + read->relatedCount({}, RelationKind::OperationTarget) == 0 && + !read->relatedAt({}, RelationKind::OperationTarget, 0), + "bounded cross-node relation access preserves order and handles " + "missing, out-of-range, and null sources"); + passed &= expect(read->changedRevision(parent) == 2 && + read->changedRevision(firstChild) == 2 && + read->changedRevision(firstTarget) == 2, + "affected nodes receive the transaction revision"); + } + + { + auto write = graph.write(); + write.setStatus(parent, NodeStatus::Running); + write.setField(parent, "title", "Atomic update"); + write.eraseField(parent, "missing"); + write.setField(parent, "stream", "first second"); + write.setParent(parent, firstChild); + write.relate(parent, RelationKind::OperationTarget, firstTarget); + write.unrelate(parent, RelationKind::ProcessOwner, secondTarget); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 2 && unchanged.empty() && + graph.publishedRevision() == 2, + "semantic no-op writes do not increment graph revision"); + } + + return passed; +} + +bool testStructuralChangeRevision() { + NodeGraph graph; + NodeRef source; + NodeRef child; + NodeRef target; + { + auto write = graph.write(); + source = write.upsert(id(NodeKind::Thread, "structure-source")); + child = write.upsert(id(NodeKind::Thread, "structure-child")); + target = write.upsert(id(NodeKind::Thread, "structure-target")); + static_cast(write.finish()); + } + + bool passed = true; + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "new nodes expose structural revisions")) + return false; + passed &= + expect(read->structureChangedRevision(source) == 0 && + read->structureChangedRevision(child) == 0 && + read->structureChangedRevision(target) == 0 && + read->structureChangedRevision({}) == 0 && + read->structureRevision(NodeKind::Thread) == 0 && + graph.publishedStructureRevision(NodeKind::Thread) == 0, + "node insertion alone does not report a relation change"); + } + + { + auto write = graph.write(); + write.setField(source, "stream", "field-only"); + const GraphChange stateOnly = write.finish(); + passed &= expect(stateOnly.revision == 2, + "the state-only control mutation publishes"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "state-only structure stamps are readable")) + return false; + passed &= expect(read->structureChangedRevision(source) == 0, + "ordinary fields do not advance structural revisions"); + } + + { + auto write = graph.write(); + write.setParent(source, child); + write.relate(source, RelationKind::StructuralChildThread, target); + const GraphChange structured = write.finish(); + passed &= expect(structured.revision == 3, + "parent and relation changes publish together"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "changed structure stamps are readable")) + return false; + passed &= expect( + read->structureChangedRevision(source) == 3 && + read->structureChangedRevision(child) == 3 && + read->structureChangedRevision(target) == 0, + "parents, children, and outgoing relation owners receive the exact " + "structural transaction revision"); + passed &= expect( + read->structureRevision(NodeKind::Thread) == 3 && + graph.publishedStructureRevision(NodeKind::Thread) == 3, + "the locked and published per-kind structure stamps advance together"); + } + + { + auto write = graph.write(); + write.setStatus(source, NodeStatus::Running); + const GraphChange stateOnly = write.finish(); + passed &= expect(stateOnly.revision == 4, + "a later status-only mutation publishes independently"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "stable structure stamps are readable")) + return false; + passed &= + expect(read->structureChangedRevision(source) == 3 && + read->structureChangedRevision(child) == 3 && + read->structureRevision(NodeKind::Thread) == 3 && + graph.publishedStructureRevision(NodeKind::Thread) == 3, + "status churn leaves structural revisions stable"); + } + + { + auto write = graph.write(); + write.clearParent(child); + write.unrelate(source, RelationKind::StructuralChildThread, target); + const GraphChange cleared = write.finish(); + passed &= expect(cleared.revision == 5, + "clearing structure publishes one revision"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "cleared structure stamps are readable")) + return false; + passed &= expect(read->structureChangedRevision(source) == 5 && + read->structureChangedRevision(child) == 5 && + read->structureChangedRevision(target) == 0, + "cleared parent and outgoing relations advance only " + "their structural owners"); + } + + { + auto write = graph.write(); + write.relate(source, RelationKind::StructuralChildThread, target); + static_cast(write.finish()); + } + { + auto write = graph.write(); + write.remove(target); + const GraphChange removed = write.finish(); + passed &= expect(removed.revision == 7, + "removing a relation target publishes once"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "removal structure stamps are readable")) + return false; + passed &= expect( + read->structureChangedRevision(source) == 7 && + read->related(source, RelationKind::StructuralChildThread).empty(), + "removal advances owners whose outgoing relations were unlinked"); + } + + NodeRef turn; + NodeRef item; + { + auto write = graph.write(); + turn = write.upsert(id(NodeKind::Turn, "structure-turn")); + item = write.upsert(id(NodeKind::Item, "structure-item")); + write.setParent(turn, item); + const GraphChange itemStructure = write.finish(); + passed &= expect(itemStructure.revision == 8, + "an unrelated item hierarchy publishes once"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "per-kind structure stamps are readable")) + return false; + passed &= expect( + read->structureRevision(NodeKind::Thread) == 7 && + graph.publishedStructureRevision(NodeKind::Thread) == 7 && + read->structureRevision(NodeKind::Turn) == 8 && + read->structureRevision(NodeKind::Item) == 8, + "item hierarchy changes do not advance the thread topology stamp"); + } + return passed; +} + +bool testAuthoritativeOrderingReplacement() { + NodeGraph graph; + NodeRef runtime; + NodeRef firstRoot; + NodeRef secondRoot; + NodeRef thirdRoot; + NodeRef thread; + NodeRef firstTurn; + NodeRef secondTurn; + NodeRef thirdTurn; + { + auto write = graph.write(); + runtime = write.upsert(id(NodeKind::Runtime, "runtime")); + firstRoot = write.upsert(id(NodeKind::Thread, "root-1")); + secondRoot = write.upsert(id(NodeKind::Thread, "root-2")); + thirdRoot = write.upsert(id(NodeKind::Thread, "root-3")); + thread = write.upsert(id(NodeKind::Thread, "ordered-thread")); + firstTurn = write.upsert(id(NodeKind::Turn, "turn-1")); + secondTurn = write.upsert(id(NodeKind::Turn, "turn-2")); + thirdTurn = write.upsert(id(NodeKind::Turn, "turn-3")); + write.relate(runtime, RelationKind::RootThread, firstRoot); + write.relate(runtime, RelationKind::RootThread, secondRoot); + write.setParent(thread, firstTurn); + write.setParent(thread, secondTurn); + static_cast(write.finish()); + } + + GraphChange changed; + { + auto write = graph.write(); + const std::array roots{thirdRoot, firstRoot, thirdRoot}; + write.replaceRelated(runtime, RelationKind::RootThread, roots); + const std::array turns{secondTurn, thirdTurn, secondTurn}; + write.replaceChildren(thread, turns); + changed = write.finish(); + } + + bool passed = expect(changed.revision == 2, + "ordered replacements publish in one transaction"); + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "replacement ordering is readable")) + return false; + passed &= expect( + sameOrder(read->related(runtime, RelationKind::RootThread), + {thirdRoot, firstRoot}) && + sameOrder(read->children(thread), {secondTurn, thirdTurn}), + "provider order replaces stale order and deduplicates identities"); + passed &= + expect(!read->parent(firstTurn) && read->parent(secondTurn) == thread && + read->parent(thirdTurn) == thread, + "omitted children are unlinked while retained children " + "keep stable NodeRefs"); + } + + { + auto write = graph.write(); + const std::array roots{thirdRoot, firstRoot}; + const std::array turns{secondTurn, thirdTurn}; + write.replaceRelated(runtime, RelationKind::RootThread, roots); + write.replaceChildren(thread, turns); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 2 && unchanged.empty(), + "identical authoritative order is a semantic no-op"); + } + return passed; +} + +bool testRemovalLifetimeAndAttachment() { + NodeGraph graph; + NodeRef parent; + NodeRef removedNode; + NodeRef child; + NodeRef relationSource; + NodeRef relationTarget; + { + auto write = graph.write(); + parent = write.upsert(id(NodeKind::Thread, "owner")); + removedNode = write.upsert(id(NodeKind::Turn, "removed-turn")); + child = write.upsert(id(NodeKind::Item, "child-item")); + relationSource = write.upsert(id(NodeKind::Operation, "source")); + relationTarget = write.upsert(id(NodeKind::Process, "target")); + write.setParent(parent, removedNode); + write.setParent(removedNode, child); + write.relate(relationSource, RelationKind::OperationTarget, removedNode); + write.relate(removedNode, RelationKind::ProcessOwner, relationTarget); + static_cast(write.finish()); + } + + struct UiAttachment final { + int renderedRevision = 17; + } attachment; + removedNode->setUiAttachment(&attachment); + bool passed = expect(removedNode->uiAttachment() == &attachment, + "a node retains one opaque non-owning UI attachment"); + + std::weak_ptr lifetime = removedNode; + GraphChange removal; + { + auto write = graph.write(); + write.remove(removedNode); + removal = write.finish(); + } + passed &= expect(removal.revision == 2 && removal.removed.size() == 1 && + removal.removed.front() == removedNode && + removedNode->uiAttachment() == &attachment, + "removal queues a stable NodeRef for UI detachment"); + + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "the graph is readable after removal")) + return false; + passed &= + expect(!read->find(removedNode->id()) && + sameOrder(read->orderedNodes(), + {parent, child, relationSource, relationTarget}) && + read->removed(removedNode), + "removal erases canonical lookup and graph ordering"); + passed &= expect( + read->children(parent).empty() && !read->parent(child) && + read->related(relationSource, RelationKind::OperationTarget) + .empty() && + read->related(removedNode, RelationKind::ProcessOwner).empty(), + "removal unlinks hierarchy and cross-node relations"); + passed &= expect( + read->retiredNodes().size() == 1 && + read->retiredNodes().front() == removedNode && + read->retiredCount() == 1 && read->retiredAt(0) == removedNode && + !read->retiredAt(1) && read->retiredOrderGeneration() == 0 && + read->contains(removedNode), + "removed nodes support bounded retirement reads until " + "Qt acknowledges"); + } + + { + auto write = graph.write(); + passed &= expect(throws( + [&] { write.setField(removedNode, "invalid", true); }), + "removed nodes reject later graph mutation"); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 2 && unchanged.empty(), + "rejected removed-node mutation leaves no revision"); + } + + NodeRef detaching = removal.removed.front(); + removal.removed.clear(); + removedNode.reset(); + passed &= expect(!lifetime.expired(), + "the retired graph reference pins the removed node"); + detaching->setUiAttachment(nullptr); + passed &= expect(detaching->uiAttachment() == nullptr, + "Qt can clear the opaque attachment before release"); + { + std::vector acknowledged{detaching}; + auto write = graph.write(); + write.releaseRetired(acknowledged); + const GraphChange released = write.finish(); + passed &= expect(released.revision == 2 && released.empty() && + graph.publishedRevision() == 2, + "UI detachment acknowledgement is revision-neutral"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "the graph is readable after retirement")) + return false; + passed &= + expect(read->retiredNodes().empty() && read->retiredCount() == 0 && + read->retiredOrderGeneration() == 1 && + !read->contains(detaching), + "releaseRetired drops graph lifetime ownership and " + "invalidates an incremental retirement cursor"); + } + detaching.reset(); + passed &= + expect(lifetime.expired() && attachment.renderedRevision == 17, + "node destruction neither owns nor deletes its UI attachment"); + return passed; +} + +bool testMisuseRejection() { + NodeGraph graph; + NodeGraph otherGraph; + NodeRef root; + NodeRef child; + NodeRef grandchild; + NodeRef foreign; + { + auto write = graph.write(); + root = write.upsert(id(NodeKind::Thread, "same-canonical")); + child = write.upsert(id(NodeKind::Turn, "child")); + grandchild = write.upsert(id(NodeKind::Item, "grandchild")); + static_cast(write.finish()); + } + { + auto write = otherGraph.write(); + foreign = write.upsert(id(NodeKind::Thread, "same-canonical")); + static_cast(write.finish()); + } + + bool passed = true; + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "the graph is readable for misuse checks")) + return false; + passed &= expect( + throws( + [&] { static_cast(read->state(foreign)); }) && + throws( + [&] { static_cast(read->changedRevision(foreign)); }) && + throws( + [&] { static_cast(read->removed(foreign)); }) && + throws( + [&] { static_cast(read->parent(foreign)); }) && + throws( + [&] { static_cast(read->children(foreign)); }) && + throws([&] { + static_cast( + read->related(foreign, RelationKind::ForkChildThread)); + }) && + throws([&] { + static_cast( + read->relatedCount(foreign, RelationKind::ForkChildThread)); + }) && + throws([&] { + static_cast( + read->relatedAt(foreign, RelationKind::ForkChildThread, 0)); + }), + "foreign NodeRefs cannot be read under the wrong graph lock, " + "including through bounded relation access"); + } + { + auto write = graph.write(); + passed &= expect( + throws([&] { write.setParent(root, root); }), + "a node cannot parent itself"); + passed &= + expect(throws( + [&] { write.setField(foreign, "invalid", true); }) && + throws([&] { + write.relate(root, RelationKind::ForkChildThread, foreign); + }), + "foreign NodeRefs are rejected even when canonical IDs collide"); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 1 && unchanged.empty(), + "rejected self and foreign operations do not publish"); + } + + { + auto write = graph.write(); + write.setParent(root, child); + write.setParent(child, grandchild); + static_cast(write.finish()); + } + { + auto write = graph.write(); + passed &= expect(throws( + [&] { write.setParent(grandchild, root); }), + "parent assignment rejects an ancestor cycle"); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 2 && unchanged.empty(), + "rejected cycle leaves hierarchy and revision unchanged"); + } + { + auto read = graph.tryRead(); + if (!expect(read.has_value(), "the hierarchy remains readable")) + return false; + passed &= expect(!read->parent(root) && read->parent(child) == root && + read->parent(grandchild) == child && + sameOrder(read->children(root), {child}) && + sameOrder(read->children(child), {grandchild}), + "misuse rejection preserves the acyclic hierarchy"); + } + return passed; +} + +bool testBatchRemovalIsAtomicAndApproximatelyLinear() { + const auto measure = [](std::size_t count) { + NodeGraph graph; + NodeRef survivor; + std::vector removed; + removed.reserve(count); + { + auto write = graph.write(); + survivor = write.upsert(id(NodeKind::Runtime, "batch-survivor")); + for (std::size_t index = 0; index < count; ++index) { + NodeRef node = write.upsert( + id(NodeKind::Item, "batch-item-" + std::to_string(index))); + write.relate(node, RelationKind::OperationTarget, survivor); + removed.emplace_back(std::move(node)); + } + write.relate(survivor, RelationKind::PendingPrompt, removed.front()); + write.relate(survivor, RelationKind::PendingPrompt, removed[count / 2]); + write.relate(survivor, RelationKind::PendingPrompt, removed.back()); + static_cast(write.finish()); + } + + const auto started = std::chrono::steady_clock::now(); + GraphChange change; + { + auto write = graph.write(); + write.removeMany(removed); + change = write.finish(); + } + const auto elapsed = std::chrono::steady_clock::now() - started; + + bool valid = change.revision == 2 && change.removed.size() == count; + auto read = graph.tryRead(); + valid = valid && read && read->orderedNodes().size() == 1 && + read->orderedNodes().front() == survivor && + read->related(survivor, RelationKind::PendingPrompt).empty() && + read->retiredCount() == count && read->removed(removed.front()) && + read->removed(removed.back()); + return std::pair{valid, elapsed}; + }; + + const auto [smallValid, smallElapsed] = measure(3000); + const auto [largeValid, largeElapsed] = measure(6000); + const auto allowance = smallElapsed * 3 + std::chrono::milliseconds(20); + bool passed = expect( + smallValid && largeValid && largeElapsed <= allowance, + "batch removal preserves order, relations, lifetime, and near-linear " + "scaling"); + std::cout + << "batch-removal ns (3000 / 6000): " + << std::chrono::duration_cast(smallElapsed) + .count() + << " / " + << std::chrono::duration_cast(largeElapsed) + .count() + << '\n'; + + NodeGraph graph; + NodeGraph foreignGraph; + NodeRef local; + NodeRef foreign; + { + auto write = graph.write(); + local = write.upsert(id(NodeKind::Item, "atomic-local")); + static_cast(write.finish()); + } + { + auto write = foreignGraph.write(); + foreign = write.upsert(id(NodeKind::Item, "atomic-foreign")); + static_cast(write.finish()); + } + { + auto write = graph.write(); + const std::array invalid{local, foreign}; + passed &= expect( + throws([&] { write.removeMany(invalid); }), + "batch removal validates every NodeRef before mutation"); + const GraphChange unchanged = write.finish(); + passed &= expect(unchanged.revision == 1 && unchanged.empty(), + "failed batch validation leaves the graph unchanged"); + } + { + auto read = graph.tryRead(); + passed &= expect(read && read->find(local->id()) == local && + !read->removed(local), + "failed batch validation preserves lookup and lifetime"); + } + return passed; +} + +} // namespace + +int main() { + bool passed = true; + passed &= testValue(); + passed &= testInsertionLookupAndOrder(); + passed &= testAtomicStateRelationsAndNoOp(); + passed &= testStructuralChangeRevision(); + passed &= testAuthoritativeOrderingReplacement(); + passed &= testRemovalLifetimeAndAttachment(); + passed &= testMisuseRejection(); + passed &= testBatchRemovalIsAtomicAndApproximatelyLinear(); + return passed ? 0 : 1; +} diff --git a/tests/codex/nodegraph/ProtocolUpdaterTest.cpp b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp new file mode 100644 index 0000000..f48514f --- /dev/null +++ b/tests/codex/nodegraph/ProtocolUpdaterTest.cpp @@ -0,0 +1,4126 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/ProtocolUpdater.h" +#include "codex/nodegraph/ProtocolCatalog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace codexui::nodegraph; + +int failures = 0; + +void require(bool condition, std::string_view message) { + if (condition) + return; + ++failures; + std::cerr << "FAILED: " << message << '\n'; +} + +DecodedMessageKind decodedKind(ProtocolDirection direction) { + switch (direction) { + case ProtocolDirection::ClientRequest: + return DecodedMessageKind::ClientRequest; + case ProtocolDirection::ServerRequest: + return DecodedMessageKind::ServerRequest; + case ProtocolDirection::ServerNotification: + return DecodedMessageKind::ServerNotification; + case ProtocolDirection::ClientNotification: + return DecodedMessageKind::ClientNotification; + } + return DecodedMessageKind::ServerNotification; +} + +const Value *field(const std::shared_ptr &state, + std::string_view key) { + if (!state) + return nullptr; + const auto found = state->fields.find(key); + return found == state->fields.end() ? nullptr : &found->second; +} + +const Value *objectField(const Value::Object *object, std::string_view key) { + if (!object) + return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +std::vector canonicalIds(const std::vector &nodes) { + std::vector result; + result.reserve(nodes.size()); + for (const NodeRef &node : nodes) + result.emplace_back(node->id().canonical); + return result; +} + +std::vector protocolIds(const NodeGraph::ReadAccess &read, + const std::vector &nodes) { + std::vector result; + result.reserve(nodes.size()); + for (const NodeRef &node : nodes) + result.emplace_back(protocolCanonicalId(*read.state(node), node)); + return result; +} + +NodeRef findTurn(const NodeGraph::ReadAccess &read, std::string_view threadId, + std::string_view turnId) { + return read.find(scopedTurnNodeId(threadId, turnId)); +} + +NodeRef findItem(const NodeGraph::ReadAccess &read, std::string_view threadId, + std::string_view turnId, std::string_view itemId) { + return read.find( + scopedItemNodeId(scopedTurnNodeId(threadId, turnId), itemId)); +} + +NodeRef findProtocolNode(const NodeGraph::ReadAccess &read, NodeKind kind, + std::string_view protocolId, + std::uint64_t connectionGeneration) { + for (const NodeRef &node : read.orderedNodes()) { + if (node->id().kind != kind) + continue; + const auto state = read.state(node); + const Value *generation = field(state, "connectionGeneration"); + if (protocolCanonicalId(*state, node) == protocolId && generation && + generation->asUInt64() && + *generation->asUInt64() == connectionGeneration) + return node; + } + return {}; +} + +void catalogIsComplete() { + const auto methods = protocolMethods(); + require(methods.size() == 252, "catalog has all 252 methods"); + require(protocolMethodCount(ProtocolDirection::ClientRequest) == 157, + "catalog has 157 client requests"); + require(protocolMethodCount(ProtocolDirection::ServerRequest) == 11, + "catalog has 11 server requests"); + require(protocolMethodCount(ProtocolDirection::ServerNotification) == 83, + "catalog has 83 server notifications"); + require(protocolMethodCount(ProtocolDirection::ClientNotification) == 1, + "catalog has one client notification"); + + std::set> unique; + std::size_t graphUpdates = 0; + std::size_t operations = 0; + std::size_t interactions = 0; + std::size_t effects = 0; + std::size_t neutral = 0; + for (const MethodDescriptor &method : methods) { + require(!method.method.empty(), "catalog method name is not empty"); + require(unique.emplace(static_cast(method.direction), method.method) + .second, + "catalog direction/method key is unique"); + require(findProtocolMethod(method.direction, method.method).has_value(), + "every catalog method is findable"); + switch (method.disposition) { + case MessageDisposition::GraphUpdate: + ++graphUpdates; + break; + case MessageDisposition::WorkerOperationResult: + ++operations; + break; + case MessageDisposition::ReverseInteraction: + ++interactions; + break; + case MessageDisposition::TypedUiEffect: + ++effects; + break; + case MessageDisposition::IntentionallyStateNeutral: + ++neutral; + break; + } + } + require(graphUpdates == 75, "75 server notifications update graph state"); + require(operations == 158, + "157 requests plus initialized are worker operations"); + require(interactions == 11, "all server requests are interactions"); + require( + effects == 6, + "six provider notices update graph state and request a typed UI effect"); + require(neutral == 2, "two internal raw-response events are neutral"); + require(!findProtocolMethod(ProtocolDirection::ServerNotification, + "unknown/method"), + "unknown methods are not silently classified"); +} + +void everyKnownMethodDispatches() { + std::uint64_t ordinal = 0; + for (const MethodDescriptor &descriptor : protocolMethods()) { + ++ordinal; + NodeGraph graph; + ProtocolUpdater updater(graph); + const std::string suffix = std::to_string(ordinal); + DecodedMessage message; + message.kind = decodedKind(descriptor.direction); + message.method = std::string(descriptor.method); + message.payload = { + {"semanticMarker", Value("marker-" + suffix)}, + {"threadId", Value("thread-" + suffix)}, + {"turnId", Value("turn-" + suffix)}, + {"itemId", Value("item-" + suffix)}, + {"targetItemId", Value("target-" + suffix)}, + {"reviewId", Value("review-" + suffix)}, + {"projectId", Value("project-" + suffix)}, + {"processId", Value("process-" + suffix)}, + {"watchId", Value("watch-" + suffix)}, + {"sessionId", Value("session-" + suffix)}, + {"realtimeSessionId", Value("realtime-" + suffix)}, + {"subscriptionId", Value("subscription-" + suffix)}, + {"importId", Value("import-" + suffix)}, + {"name", Value("name-" + suffix)}, + {"delta", Value("delta-" + suffix)}, + {"deltaBase64", Value("ZGVsdGE=")}, + {"stream", Value("stdout")}, + {"status", Value("running")}, + {"thread", Value(Value::Object{{"id", Value("thread-" + suffix)}, + {"turns", Value(Value::Array{})}})}, + {"turn", Value(Value::Object{{"id", Value("turn-" + suffix)}, + {"items", Value(Value::Array{})}})}, + {"item", Value(Value::Object{ + {"id", Value("item-" + suffix)}, + {"type", Value("agentMessage")}, + {"realtimeSessionId", Value("realtime-" + suffix)}})}, + {"run", Value(Value::Object{{"id", Value("hook-" + suffix)}, + {"status", Value("running")}})}, + {"data", Value(Value::Array{Value( + Value::Object{{"id", Value("entry-" + suffix)}, + {"name", Value("entry-" + suffix)}})})}, + {"requestId", Value(std::int64_t{9000})}}; + if (descriptor.direction == ProtocolDirection::ClientRequest || + descriptor.direction == ProtocolDirection::ServerRequest) + message.requestId.emplace(std::int64_t(ordinal)); + + if (descriptor.direction == ProtocolDirection::ServerNotification && + descriptor.method == "thread/deleted") { + auto write = graph.write(); + static_cast(write.upsert({NodeKind::Thread, "thread-" + suffix})); + static_cast(write.finish()); + } + if (descriptor.direction == ProtocolDirection::ServerNotification && + descriptor.method == "serverRequest/resolved") { + static_cast(updater.apply( + {DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", ProtocolRequestId(9000), + Value::Object{{"threadId", Value("thread-" + suffix)}}})); + } + + const std::uint64_t before = graph.publishedRevision(); + const ApplyResult result = updater.apply(std::move(message)); + require(result.knownMethod, "known catalog method dispatches as known"); + require(result.disposition == descriptor.disposition, + "dispatch returns the catalog disposition"); + if (descriptor.direction == ProtocolDirection::ClientRequest) { + auto read = graph.tryRead(); + const auto state = result.primary ? read->state(result.primary) : nullptr; + const Value *requestPayload = field(state, "requestPayload"); + const Value *marker = + objectField(requestPayload ? requestPayload->asObject() : nullptr, + "semanticMarker"); + require( + result.primary && result.primary->id().kind == NodeKind::Operation && + state && state->status == NodeStatus::Pending && marker && + marker->asString() && *marker->asString() == "marker-" + suffix, + "every client request retains its payload in one pending " + "operation"); + read.reset(); + const ApplyResult completed = updater.apply( + {DecodedMessageKind::ClientResult, std::string(descriptor.method), + ProtocolRequestId(static_cast(ordinal)), + Value::Object{{"resultMarker", Value("result-" + suffix)}}, + result.primary}); + read = graph.tryRead(); + require( + completed.knownMethod && + !read->find({NodeKind::Operation, + ProtocolRequestId(static_cast(ordinal)) + .canonical()}), + "every client result consumes its exact request correlation"); + } else if (descriptor.direction == ProtocolDirection::ServerRequest) { + auto read = graph.tryRead(); + const auto state = result.primary ? read->state(result.primary) : nullptr; + const Value *payload = field(state, "payload"); + const Value *marker = objectField(payload ? payload->asObject() : nullptr, + "semanticMarker"); + require( + result.primary && + result.primary->id().kind == NodeKind::Interaction && state && + state->status == NodeStatus::Pending && marker && + marker->asString() && *marker->asString() == "marker-" + suffix && + !read->related(result.primary, RelationKind::InteractionTarget) + .empty(), + "every server request retains its payload and target relation"); + read.reset(); + const NodeRef interaction = result.primary; + static_cast(updater.resolveInteraction(interaction, true)); + read = graph.tryRead(); + require(!read->find(interaction->id()), + "every reverse interaction resolves by exact NodeRef"); + } else if (descriptor.disposition == + MessageDisposition::IntentionallyStateNeutral) { + require(result.change.empty(), "state-neutral method changes no nodes"); + require(graph.publishedRevision() == before, + "state-neutral method does not publish a revision"); + } else { + require(!result.change.empty() && graph.publishedRevision() > before, + "state-bearing notification publishes concrete state: " + + std::string(descriptor.method)); + } + } +} + +void nestedEntitiesAndStreamsStayCurrent() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + Value::Object item{{"id", Value("item-1")}, + {"type", Value("agentMessage")}, + {"status", Value("running")}}; + Value::Object turn{{"id", Value("turn-1")}, + {"status", Value("inProgress")}, + {"items", Value(Value::Array{Value(item)})}}; + Value::Object thread{{"id", Value("thread-1")}, + {"name", Value("Thread one")}, + {"turns", Value(Value::Array{Value(turn)})}}; + ApplyResult started = + updater.apply({DecodedMessageKind::ServerNotification, "thread/started", + std::nullopt, Value::Object{{"thread", Value(thread)}}}); + require(started.change.revision == 1, + "one nested notification publishes one graph revision"); + + NodeRef threadRef; + NodeRef turnRef; + NodeRef itemRef; + { + auto read = graph.tryRead(); + require(read.has_value(), "nested graph is readable"); + threadRef = read->find({NodeKind::Thread, "thread-1"}); + turnRef = findTurn(*read, "thread-1", "turn-1"); + itemRef = findItem(*read, "thread-1", "turn-1", "item-1"); + require(threadRef && turnRef && itemRef, + "nested thread, turn, and item are indexed"); + require(read->parent(turnRef) == threadRef, "turn is linked to thread"); + require(read->parent(itemRef) == turnRef, "item is linked to turn"); + require(read->state(turnRef)->status == NodeStatus::Running, + "typed status is normalized on the node"); + } + + for (const std::string_view delta : {"hello ", "world"}) { + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", std::nullopt, + Value::Object{{"threadId", Value("thread-1")}, + {"turnId", Value("turn-1")}, + {"itemId", Value("item-1")}, + {"delta", Value(delta)}}})); + } + { + auto read = graph.tryRead(); + const auto state = read->state(itemRef); + const Value *stream = field(state, "text"); + require(stream && stream->asString() && + *stream->asString() == "hello world", + "stream fragments append in arrival order on the item"); + require(!field(state, "item/agentMessage/delta"), + "stream state uses its semantic field instead of a method key"); + } +} + +void streamedTextIsBoundedAndReportsOmission() { + constexpr std::size_t RetainedTailBytes = 192 * 1024; + NodeGraph graph; + ProtocolUpdater updater(graph); + + Value::Array items{Value(Value::Object{{"id", Value("bounded-agent")}, + {"type", Value("agentMessage")}}), + Value(Value::Object{{"id", Value("bounded-reasoning")}, + {"type", Value("reasoning")}})}; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("bounded-stream-thread")}, + {"turns", Value(Value::Array{Value(Value::Object{ + {"id", Value("bounded-stream-turn")}, + {"items", Value(std::move(items))}})})}})}}})); + + std::string firstChunk; + firstChunk.reserve(300001); + for (std::size_t index = 0; index < 100000; ++index) + firstChunk.append("\xE2\x82\xAC"); + firstChunk.push_back('x'); + const std::size_t firstChunkBytes = firstChunk.size(); + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", std::nullopt, + Value::Object{{"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"itemId", Value("bounded-agent")}, + {"delta", Value(std::move(firstChunk))}}})); + + const auto retentionCounts = [](const std::shared_ptr &state, + std::string_view retainedField) { + const Value *retention = field(state, "textRetention"); + const Value *entry = + objectField(retention ? retention->asObject() : nullptr, retainedField); + const Value *discarded = + objectField(entry ? entry->asObject() : nullptr, "discardedBytes"); + const Value *retained = + objectField(entry ? entry->asObject() : nullptr, "retainedBytes"); + return std::pair{ + discarded && discarded->asUInt64() ? *discarded->asUInt64() : 0U, + retained && retained->asUInt64() ? *retained->asUInt64() : 0U}; + }; + + { + auto read = graph.tryRead(); + const NodeRef item = findItem(*read, "bounded-stream-thread", + "bounded-stream-turn", "bounded-agent"); + const auto state = read->state(item); + const std::string *text = field(state, "text")->asString(); + const auto [discarded, retained] = retentionCounts(state, "text"); + require(text && !text->empty() && text->size() <= RetainedTailBytes && + (static_cast(text->front()) & 0xc0U) != 0x80U && + text->back() == 'x' && retained == text->size() && + discarded + retained == firstChunkBytes, + "large agent deltas retain a UTF-8-aligned bounded tail and exact " + "omission metadata"); + } + + std::string secondChunk(100 * 1024, 'z'); + const std::size_t totalAgentBytes = firstChunkBytes + secondChunk.size(); + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", std::nullopt, + Value::Object{{"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"itemId", Value("bounded-agent")}, + {"delta", Value(std::move(secondChunk))}}})); + { + auto read = graph.tryRead(); + const auto state = + read->state(findItem(*read, "bounded-stream-thread", + "bounded-stream-turn", "bounded-agent")); + const std::string *text = field(state, "text")->asString(); + const auto [discarded, retained] = retentionCounts(state, "text"); + require(text && text->size() <= RetainedTailBytes && + retained == text->size() && + discarded + retained == totalAgentBytes, + "successive stream truncations accumulate exact omitted bytes " + "without growing current node state"); + } + + std::string reasoningChunk(270 * 1024, 'r'); + const std::size_t reasoningBytes = reasoningChunk.size(); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, + "item/reasoning/summaryTextDelta", std::nullopt, + Value::Object{{"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"itemId", Value("bounded-reasoning")}, + {"summaryIndex", Value(std::uint64_t{0})}, + {"delta", Value(std::move(reasoningChunk))}}})); + { + auto read = graph.tryRead(); + const auto state = + read->state(findItem(*read, "bounded-stream-thread", + "bounded-stream-turn", "bounded-reasoning")); + const Value::Array *summary = field(state, "summary")->asArray(); + const std::string *text = + summary && !summary->empty() ? summary->front().asString() : nullptr; + const auto [discarded, retained] = retentionCounts(state, "summary"); + require(summary && summary->size() == 1 && text && + text->size() <= RetainedTailBytes && retained == text->size() && + discarded + retained == reasoningBytes, + "indexed reasoning streams retain one bounded tail with omission " + "metadata"); + } + const std::uint64_t beforeSparseIndex = graph.publishedRevision(); + const ApplyResult sparseIndex = + updater.apply({DecodedMessageKind::ServerNotification, + "item/reasoning/summaryTextDelta", std::nullopt, + Value::Object{{"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"itemId", Value("bounded-reasoning")}, + {"summaryIndex", Value(std::uint64_t{5000})}, + {"delta", Value("ignored")}}}); + require(sparseIndex.change.empty() && + graph.publishedRevision() == beforeSparseIndex, + "an out-of-range text index cannot allocate an unbounded sparse " + "array or publish a false change"); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/completed", std::nullopt, + Value::Object{ + {"threadId", Value("bounded-stream-thread")}, + {"turnId", Value("bounded-stream-turn")}, + {"item", Value(Value::Object{{"id", Value("bounded-agent")}, + {"type", Value("agentMessage")}, + {"text", Value("complete text")}})}}})); + { + auto read = graph.tryRead(); + const auto state = + read->state(findItem(*read, "bounded-stream-thread", + "bounded-stream-turn", "bounded-agent")); + require(field(state, "text") && field(state, "text")->asString() && + *field(state, "text")->asString() == "complete text" && + !field(state, "textRetention"), + "authoritative completion replaces the stream tail and clears its " + "obsolete truncation notice"); + } +} + +void longStreamingDeltasStayBoundedInStateAndCost() { + constexpr std::size_t MaximumRetainedBytes = 256 * 1024; + constexpr std::size_t WarmupDeltas = 4097; + constexpr std::size_t FirstMeasuredDeltas = 1024; + constexpr std::size_t SecondMeasuredDeltas = 2048; + const std::string chunk(64, 's'); + + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("long-stream-thread")}, + {"turns", + Value(Value::Array{Value(Value::Object{ + {"id", Value("long-stream-turn")}, + {"items", + Value(Value::Array{Value(Value::Object{ + {"id", Value("long-stream-item")}, + {"type", Value("agentMessage")}})})}})})}})}}})); + const std::uint64_t initialRevision = graph.publishedRevision(); + + const auto append = [&](std::size_t count) { + const auto started = std::chrono::steady_clock::now(); + for (std::size_t index = 0; index < count; ++index) + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/agentMessage/delta", + std::nullopt, + Value::Object{{"threadId", Value("long-stream-thread")}, + {"turnId", Value("long-stream-turn")}, + {"itemId", Value("long-stream-item")}, + {"delta", Value(chunk)}}})); + return std::chrono::steady_clock::now() - started; + }; + + static_cast(append(WarmupDeltas)); + const auto firstElapsed = append(FirstMeasuredDeltas); + const auto secondElapsed = append(SecondMeasuredDeltas); + const auto allowance = firstElapsed * 3 + std::chrono::milliseconds(25); + + auto read = graph.tryRead(); + const NodeRef item = read ? findItem(*read, "long-stream-thread", + "long-stream-turn", "long-stream-item") + : NodeRef{}; + const auto state = item ? read->state(item) : nullptr; + const Value *textValue = field(state, "text"); + const std::string *text = textValue ? textValue->asString() : nullptr; + const Value *retentionValue = field(state, "textRetention"); + const Value *entry = objectField( + retentionValue ? retentionValue->asObject() : nullptr, "text"); + const Value *discarded = + objectField(entry ? entry->asObject() : nullptr, "discardedBytes"); + const std::uint64_t totalBytes = + static_cast(WarmupDeltas + FirstMeasuredDeltas + + SecondMeasuredDeltas) * + chunk.size(); + require( + text && text->size() <= MaximumRetainedBytes && discarded && + discarded->asUInt64() && + *discarded->asUInt64() + text->size() == totalBytes && + graph.publishedRevision() == initialRevision + WarmupDeltas + + FirstMeasuredDeltas + + SecondMeasuredDeltas && + secondElapsed <= allowance, + "long streaming publishes once per input with bounded retained text and " + "approximately linear steady-state cost"); + std::cout + << "long-stream steady-state ns: " + << std::chrono::duration_cast(firstElapsed) + .count() + << " / " + << std::chrono::duration_cast(secondElapsed) + .count() + << '\n'; +} + +void scopedProviderIdentityCannotCrossParents() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + const auto startItem = [&](std::string threadId, std::string turnId, + std::string marker) { + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{ + {"threadId", Value(threadId)}, + {"turnId", Value(turnId)}, + {"item", Value(Value::Object{{"id", Value("shared-item")}, + {"marker", Value(marker)}, + {"status", Value("running")}})}}})); + }; + startItem("thread-a", "shared-turn", "from-a"); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("thread-b")}, + {"turn", Value(Value::Object{{"id", Value("shared-turn")}, + {"status", Value("running")}})}}})); + startItem("thread-b", "shared-turn", "from-b"); + startItem("thread-a", "second-turn", "from-a-second"); + + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", std::nullopt, + Value::Object{{"threadId", Value("thread-b")}, + {"turnId", Value("shared-turn")}, + {"itemId", Value("shared-item")}, + {"delta", Value("only-b")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/completed", std::nullopt, + Value::Object{ + {"threadId", Value("thread-b")}, + {"turnId", Value("shared-turn")}, + {"item", Value(Value::Object{{"id", Value("shared-item")}, + {"marker", Value("final-b")}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/completed", std::nullopt, + Value::Object{ + {"threadId", Value("thread-b")}, + {"turn", Value(Value::Object{{"id", Value("shared-turn")}})}}})); + + auto read = graph.tryRead(); + const NodeRef turnA = findTurn(*read, "thread-a", "shared-turn"); + const NodeRef turnB = findTurn(*read, "thread-b", "shared-turn"); + const NodeRef itemA = + findItem(*read, "thread-a", "shared-turn", "shared-item"); + const NodeRef itemB = + findItem(*read, "thread-b", "shared-turn", "shared-item"); + const NodeRef secondTurnItem = + findItem(*read, "thread-a", "second-turn", "shared-item"); + require(turnA && turnB && turnA != turnB && itemA && itemB && + secondTurnItem && itemA != itemB && itemA != secondTurnItem, + "raw turn and item IDs are scoped by every containing parent"); + require(read->parent(turnA)->id().canonical == "thread-a" && + read->parent(turnB)->id().canonical == "thread-b" && + read->parent(itemA) == turnA && read->parent(itemB) == turnB, + "same raw IDs never reparent a node from another thread"); + require(protocolCanonicalId(*read->state(turnB), turnB) == "shared-turn" && + protocolCanonicalId(*read->state(itemB), itemB) == + "shared-item" && + field(read->state(itemA), "text") == nullptr && + field(read->state(itemB), "text") && + *field(read->state(itemB), "text")->asString() == "only-b", + "scoped nodes retain raw boundary IDs and isolate stream updates"); + require(read->state(itemA)->status == NodeStatus::Running && + read->state(itemB)->status == NodeStatus::Completed && + read->state(turnB)->status == NodeStatus::Completed, + "completion notifications advance existing Running nodes"); + require(!read->find({NodeKind::Turn, "shared-turn"}) && + !read->find({NodeKind::Item, "shared-item"}), + "provider-scoped entities never leak into raw global indexes"); + require(scopedTurnNodeId("a", "b:c") != scopedTurnNodeId("a:b", "c") && + scopedItemNodeId(scopedTurnNodeId("a", "b:c"), "d:e") != + scopedItemNodeId(scopedTurnNodeId("a:b", "c"), "e"), + "length-prefixed scope encoding is collision-safe"); +} + +void activeTurnRelationTracksLifecycle() { + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"turn", Value(Value::Object{{"id", Value("active-turn")}, + {"status", Value("inProgress")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + const NodeRef turn = findTurn(*read, "active-thread", "active-turn"); + require(thread && turn && + read->related(thread, RelationKind::ActiveTurn) == + std::vector{turn}, + "turn start records the current active turn directly"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/status/changed", + std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"status", Value(Value::Object{{"type", Value("idle")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + require(thread && read->state(thread)->status == NodeStatus::Completed && + read->related(thread, RelationKind::ActiveTurn).empty(), + "object-shaped idle thread status normalizes typed state and " + "clears the direct active-turn relation"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"turn", Value(Value::Object{{"id", Value("active-turn")}, + {"status", Value("inProgress")}})}}})); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/completed", std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"turn", Value(Value::Object{{"id", Value("active-turn")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + require(thread && read->related(thread, RelationKind::ActiveTurn).empty(), + "turn completion clears the direct active-turn relation even " + "when the payload omits status"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"turn", Value(Value::Object{{"id", Value("closing-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/closed", std::nullopt, + Value::Object{{"threadId", Value("active-thread")}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + const Value *status = field(read->state(thread), "status"); + require(thread && read->state(thread)->status == NodeStatus::NotLoaded && + status && status->asString() && + *status->asString() == "notLoaded" && + read->related(thread, RelationKind::ActiveTurn).empty(), + "closing a thread clears its active turn and converges both " + "typed and protocol-facing status"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/status/changed", + std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"status", Value(Value::Object{{"type", Value("systemError")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + require(thread && read->state(thread)->status == NodeStatus::Failed, + "object-shaped systemError status normalizes to typed failure"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("active-thread")}, + {"turn", Value(Value::Object{{"id", Value("stale-active")}, + {"status", Value("inProgress")}})}}})); + NodeRef staleActive; + { + auto read = graph.tryRead(); + staleActive = findTurn(*read, "active-thread", "stale-active"); + } + Value::Array replacementTurns{Value(Value::Object{ + {"id", Value("replacement-turn")}, {"status", Value("completed")}})}; + const ApplyResult replacement = updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("active-thread-replacement"), + Value::Object{ + {"thread", Value(Value::Object{ + {"id", Value("active-thread")}, + {"turns", Value(std::move(replacementTurns))}})}}}); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "active-thread"}); + require(thread && staleActive && + !findTurn(*read, "active-thread", "stale-active") && + read->removed(staleActive) && + std::ranges::find(replacement.change.removed, staleActive) != + replacement.change.removed.end() && + read->related(thread, RelationKind::ActiveTurn).empty(), + "authoritative history replacement retires an omitted active " + "turn and clears it as the current action target"); + } +} + +void effectiveThreadSettingsConvergeAcrossWireShapes() { + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/resume", + ProtocolRequestId("settings-resume"), + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("settings-thread")}})}, + {"model", Value("gpt-current")}, + {"reasoningEffort", Value("high")}, + {"approvalPolicy", Value("never")}, + {"sandbox", Value("workspaceWrite")}, + {"activePermissionProfile", Value("trusted")}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "settings-thread"}); + const auto state = read->state(thread); + require(thread && field(state, "model") && + *field(state, "model")->asString() == "gpt-current" && + field(state, "reasoningEffort") && + *field(state, "reasoningEffort")->asString() == "high" && + field(state, "approvalPolicy") && + *field(state, "approvalPolicy")->asString() == "never" && + field(state, "sandbox") && + *field(state, "sandbox")->asString() == "workspaceWrite" && + field(state, "activePermissionProfile") && + *field(state, "activePermissionProfile")->asString() == + "trusted", + "thread resume wrapper settings become direct current thread " + "state"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/settings/updated", + std::nullopt, + Value::Object{ + {"threadId", Value("settings-thread")}, + {"threadSettings", + Value(Value::Object{{"model", Value("gpt-next")}, + {"effort", Value("medium")}, + {"sandboxPolicy", Value("readOnly")}, + {"personality", Value("friendly")}})}}})); + std::uint64_t firstSettingsRevision = 0; + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "settings-thread"}); + const auto state = read->state(thread); + const Value *revision = field(state, "settingsRevision"); + firstSettingsRevision = + revision && revision->asUInt64() ? *revision->asUInt64() : 0; + require(field(state, "model") && + *field(state, "model")->asString() == "gpt-next" && + field(state, "effort") && + *field(state, "effort")->asString() == "medium" && + !field(state, "reasoningEffort") && + field(state, "sandboxPolicy") && + *field(state, "sandboxPolicy")->asString() == "readOnly" && + !field(state, "sandbox") && firstSettingsRevision != 0, + "modern settings merge sparsely and retire stale legacy aliases"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/settings/updated", + std::nullopt, + Value::Object{ + {"threadId", Value("settings-thread")}, + {"threadSettings", + Value(Value::Object{{"personality", Value(nullptr)}})}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "settings-thread"}); + const auto state = read->state(thread); + const Value *latest = field(state, "latestSettingsUpdate"); + const Value::Object *latestObject = latest ? latest->asObject() : nullptr; + const Value *revision = field(state, "settingsRevision"); + require(field(state, "model") && + *field(state, "model")->asString() == "gpt-next" && + !field(state, "personality") && latestObject && + latestObject->size() == 1 && + latestObject->contains("personality") && + latestObject->at("personality").isNull() && revision && + revision->asUInt64() && + *revision->asUInt64() > firstSettingsRevision, + "sparse settings retain prior facts, merge explicit null, retain " + "the latest patch, and advance a settings-only revision"); + } +} + +void threadItemPagesMaintainScopedContainmentAndOrder() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const ProtocolRequestId firstId("items-page-one"); + const ApplyResult firstRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/items/list", firstId, + Value::Object{{"threadId", Value("items-thread")}, + {"turnId", Value("items-turn")}, + {"sortDirection", Value("desc")}}}); + Value::Array firstPage{ + Value(Value::Object{ + {"turnId", Value("items-turn")}, + {"item", Value(Value::Object{{"id", Value("newer-item")}, + {"type", Value("agentMessage")}})}}), + Value(Value::Object{ + {"turnId", Value("items-turn")}, + {"item", Value(Value::Object{{"id", Value("older-item")}, + {"type", Value("agentMessage")}})}})}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/items/list", firstId, + Value::Object{{"data", Value(std::move(firstPage))}, + {"nextCursor", Value("older-page")}}, + firstRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef turn = findTurn(*read, "items-thread", "items-turn"); + const auto state = read->state(turn); + require(turn && + protocolIds(*read, read->children(turn)) == + std::vector{"older-item", "newer-item"} && + field(state, "itemsHistoryHasMore") && + *field(state, "itemsHistoryHasMore")->asBool() && + field(state, "itemsHistoryNextCursor") && + *field(state, "itemsHistoryNextCursor")->asString() == + "older-page", + "thread/items/list decodes entry containment, canonicalizes a " + "descending page, and retains its cursor on the addressed turn"); + } + + const ProtocolRequestId secondId("items-page-two"); + const ApplyResult secondRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/items/list", secondId, + Value::Object{{"threadId", Value("items-thread")}, + {"turnId", Value("items-turn")}, + {"cursor", Value("older-page")}, + {"sortDirection", Value("desc")}}}); + Value::Array secondPage{Value(Value::Object{ + {"turnId", Value("items-turn")}, + {"item", Value(Value::Object{{"id", Value("oldest-item")}, + {"type", Value("agentMessage")}})}})}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/items/list", secondId, + Value::Object{{"data", Value(std::move(secondPage))}, + {"nextCursor", Value(nullptr)}, + {"backwardsCursor", Value("newer-page")}}, + secondRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef turn = findTurn(*read, "items-thread", "items-turn"); + const auto state = read->state(turn); + require(protocolIds(*read, read->children(turn)) == + std::vector{"oldest-item", "older-item", + "newer-item"} && + field(state, "itemsHistoryHasMore") && + !*field(state, "itemsHistoryHasMore")->asBool() && + !field(state, "itemsHistoryNextCursor") && + field(state, "itemsHistoryBackwardsCursor") && + *field(state, "itemsHistoryBackwardsCursor")->asString() == + "newer-page", + "later item pages prepend without rebuilding or duplicating the " + "current turn order"); + } +} + +void rootOrderAndThreadHierarchyAreExplicit() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + for (const std::string_view id : {"retained-a", "retained-b"}) { + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{{"id", Value(id)}})}}})); + } + + const ProtocolRequestId listId("ordered-list"); + Value::Array listed{ + Value(Value::Object{{"id", Value("provider-a")}}), + Value(Value::Object{{"id", Value("structural-child")}, + {"parentThreadId", Value("structural-parent")}}), + Value(Value::Object{{"id", Value("structural-parent")}, + {"parentThreadId", Value(nullptr)}}), + Value(Value::Object{{"id", Value("provider-b")}}), + Value(Value::Object{{"id", Value("provider-a")}})}; + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "thread/list", listId, + Value::Object{{"data", Value(std::move(listed))}}})); + + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef parent = read->find({NodeKind::Thread, "structural-parent"}); + const NodeRef child = read->find({NodeKind::Thread, "structural-child"}); + require(runtime && canonicalIds( + read->related(runtime, RelationKind::RootThread)) == + std::vector{ + "provider-a", "structural-parent", "provider-b", + "retained-b", "retained-a"}, + "thread/list replaces the provider prefix and preserves one " + "ordered retained tail"); + require(parent && child && + canonicalIds(read->related( + parent, RelationKind::StructuralChildThread)) == + std::vector{"structural-child"} && + read->related(child, RelationKind::ThreadOwner) == + std::vector{parent}, + "parentThreadId creates direct structural and owner relations"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/fork", + ProtocolRequestId("fork-result"), + Value::Object{ + {"thread", Value(Value::Object{ + {"id", Value("fork-child")}, + {"forkedFromId", Value("structural-parent")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef parent = read->find({NodeKind::Thread, "structural-parent"}); + const NodeRef fork = read->find({NodeKind::Thread, "fork-child"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(!roots.empty() && roots.front() == fork && + canonicalIds( + read->related(parent, RelationKind::ForkChildThread)) == + std::vector{"fork-child"}, + "forkedFromId keeps a fork relation while the fork remains a " + "visible root"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("agent-child")}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{ + {"threadId", Value("structural-parent")}, + {"turnId", Value("agent-turn")}, + {"item", + Value(Value::Object{{"id", Value("spawn-item")}, + {"type", Value("subAgentActivity")}, + {"agentThreadId", Value("agent-child")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef parent = read->find({NodeKind::Thread, "structural-parent"}); + const NodeRef item = + findItem(*read, "structural-parent", "agent-turn", "spawn-item"); + const NodeRef child = read->find({NodeKind::Thread, "agent-child"}); + const auto roots = + canonicalIds(read->related(runtime, RelationKind::RootThread)); + require(std::find(roots.begin(), roots.end(), "agent-child") == roots.end(), + "an agent-owned child is removed from canonical root order"); + require(parent && item && child && + read->related(parent, RelationKind::AgentChildThread) == + std::vector{child} && + read->related(item, RelationKind::AgentChildThread) == + std::vector{child} && + read->related(child, RelationKind::ThreadOwner) == + std::vector{parent}, + "agent activity relates both its owner thread and source item to " + "the stable child thread"); + } +} + +void agentChildAggregatesTrackEveryReferencingItem() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + for (const std::string_view id : + {"shared-agent-child", "replacement-agent-child"}) { + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{{"id", Value(id)}})}}})); + } + + const auto agentItem = [](std::string id, std::string childId) { + return Value(Value::Object{{"id", Value(std::move(id))}, + {"type", Value("subAgentActivity")}, + {"agentThreadId", Value(std::move(childId))}}); + }; + Value::Object owner{ + {"id", Value("agent-owner")}, + {"turns", + Value(Value::Array{Value(Value::Object{ + {"id", Value("agent-owner-turn")}, + {"items", + Value(Value::Array{ + agentItem("first-agent-item", "shared-agent-child"), + agentItem("second-agent-item", "shared-agent-child")})}})})}}; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(std::move(owner))}}})); + + NodeRef firstItem; + NodeRef secondItem; + NodeRef sharedChild; + NodeRef replacementChild; + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef ownerThread = read->find({NodeKind::Thread, "agent-owner"}); + firstItem = + findItem(*read, "agent-owner", "agent-owner-turn", "first-agent-item"); + secondItem = + findItem(*read, "agent-owner", "agent-owner-turn", "second-agent-item"); + sharedChild = read->find({NodeKind::Thread, "shared-agent-child"}); + replacementChild = + read->find({NodeKind::Thread, "replacement-agent-child"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(ownerThread && firstItem && secondItem && sharedChild && + replacementChild && + read->related(firstItem, RelationKind::AgentChildThread) == + std::vector{sharedChild} && + read->related(secondItem, RelationKind::AgentChildThread) == + std::vector{sharedChild} && + read->related(ownerThread, RelationKind::AgentChildThread) == + std::vector{sharedChild} && + read->related(sharedChild, RelationKind::ThreadOwner) == + std::vector{ownerThread} && + std::ranges::find(roots, sharedChild) == roots.end(), + "multiple items share one owner-level agent-child relation"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/completed", std::nullopt, + Value::Object{{"threadId", Value("agent-owner")}, + {"turnId", Value("agent-owner-turn")}, + {"item", agentItem("first-agent-item", + "replacement-agent-child")}}})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef ownerThread = read->find({NodeKind::Thread, "agent-owner"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(ownerThread && firstItem && secondItem && sharedChild && + replacementChild && + read->related(firstItem, RelationKind::AgentChildThread) == + std::vector{replacementChild} && + read->related(secondItem, RelationKind::AgentChildThread) == + std::vector{sharedChild} && + read->related(ownerThread, RelationKind::AgentChildThread) == + std::vector{replacementChild, sharedChild} && + read->related(sharedChild, RelationKind::ThreadOwner) == + std::vector{ownerThread} && + read->related(replacementChild, RelationKind::ThreadOwner) == + std::vector{ownerThread} && + std::ranges::find(roots, sharedChild) == roots.end() && + std::ranges::find(roots, replacementChild) == roots.end(), + "reassigning one of multiple items preserves the aggregate edge " + "still referenced by its sibling item"); + } + + const ProtocolRequestId firstReadId("agent-owner-first-read"); + const ApplyResult firstRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", firstReadId, + Value::Object{{"threadId", Value("agent-owner")}}}); + Value::Object retainedOwner{ + {"id", Value("agent-owner")}, + {"turns", + Value(Value::Array{Value(Value::Object{ + {"id", Value("agent-owner-turn")}, + {"items", Value(Value::Array{agentItem( + "second-agent-item", "shared-agent-child")})}})})}}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", firstReadId, + Value::Object{{"thread", Value(std::move(retainedOwner))}}, + firstRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef ownerThread = read->find({NodeKind::Thread, "agent-owner"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(firstItem && read->removed(firstItem) && + !read->find(firstItem->id()) && ownerThread && secondItem && + read->find(secondItem->id()) == secondItem && sharedChild && + replacementChild && + read->related(ownerThread, RelationKind::AgentChildThread) == + std::vector{sharedChild} && + read->related(sharedChild, RelationKind::ThreadOwner) == + std::vector{ownerThread} && + read->related(replacementChild, RelationKind::ThreadOwner) + .empty() && + std::ranges::find(roots, sharedChild) == roots.end() && + std::ranges::find(roots, replacementChild) != roots.end(), + "removing one referencing item keeps the shared owner aggregate " + "and releases only its unreferenced child"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/completed", std::nullopt, + Value::Object{{"threadId", Value("agent-owner")}, + {"turnId", Value("agent-owner-turn")}, + {"item", agentItem("second-agent-item", + "replacement-agent-child")}}})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef ownerThread = read->find({NodeKind::Thread, "agent-owner"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(ownerThread && secondItem && sharedChild && replacementChild && + read->related(secondItem, RelationKind::AgentChildThread) == + std::vector{replacementChild} && + read->related(ownerThread, RelationKind::AgentChildThread) == + std::vector{replacementChild} && + read->related(sharedChild, RelationKind::ThreadOwner).empty() && + read->related(replacementChild, RelationKind::ThreadOwner) == + std::vector{ownerThread} && + std::ranges::find(roots, sharedChild) != roots.end() && + std::ranges::find(roots, replacementChild) == roots.end(), + "reassigning the final referencing item replaces only its exact " + "owner aggregate and promotes the released child"); + } + + const ProtocolRequestId emptyReadId("agent-owner-empty-read"); + const ApplyResult emptyRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", emptyReadId, + Value::Object{{"threadId", Value("agent-owner")}}}); + Value::Object emptyOwner{{"id", Value("agent-owner")}, + {"turns", Value(Value::Array{Value(Value::Object{ + {"id", Value("agent-owner-turn")}, + {"items", Value(Value::Array{})}})})}}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", emptyReadId, + Value::Object{{"thread", Value(std::move(emptyOwner))}}, + emptyRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef ownerThread = read->find({NodeKind::Thread, "agent-owner"}); + const auto roots = read->related(runtime, RelationKind::RootThread); + require(secondItem && read->removed(secondItem) && + !read->find(secondItem->id()) && ownerThread && + replacementChild && + read->related(ownerThread, RelationKind::AgentChildThread) + .empty() && + read->related(replacementChild, RelationKind::ThreadOwner) + .empty() && + std::ranges::find(roots, replacementChild) != roots.end(), + "removing the final referencing item clears the aggregate and " + "promotes its released child"); + } +} + +void forkRelationsFollowTheCurrentSource() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + for (const std::string_view id : {"fork-source-a", "fork-source-b"}) { + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{{"id", Value(id)}})}}})); + } + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/fork", + ProtocolRequestId("initial-fork"), + Value::Object{ + {"thread", + Value(Value::Object{{"id", Value("changing-fork")}, + {"forkedFromId", Value("fork-source-a")}})}}})); + + NodeRef fork; + { + auto read = graph.tryRead(); + const NodeRef sourceA = read->find({NodeKind::Thread, "fork-source-a"}); + fork = read->find({NodeKind::Thread, "changing-fork"}); + require(sourceA && fork && + read->related(sourceA, RelationKind::ForkChildThread) == + std::vector{fork}, + "a fork starts with one source-to-child relation"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{{"id", Value("changing-fork")}, + {"forkedFromId", Value("fork-source-b")}})}}})); + { + auto read = graph.tryRead(); + const NodeRef sourceA = read->find({NodeKind::Thread, "fork-source-a"}); + const NodeRef sourceB = read->find({NodeKind::Thread, "fork-source-b"}); + require(read->find({NodeKind::Thread, "changing-fork"}) == fork && + read->related(sourceA, RelationKind::ForkChildThread).empty() && + read->related(sourceB, RelationKind::ForkChildThread) == + std::vector{fork}, + "fork reassignment removes the old source-to-child direction " + "before adding the new source"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("changing-fork")}, + {"forkedFromId", Value(nullptr)}})}}})); + { + auto read = graph.tryRead(); + const NodeRef sourceA = read->find({NodeKind::Thread, "fork-source-a"}); + const NodeRef sourceB = read->find({NodeKind::Thread, "fork-source-b"}); + require(read->find({NodeKind::Thread, "changing-fork"}) == fork && + read->related(sourceA, RelationKind::ForkChildThread).empty() && + read->related(sourceB, RelationKind::ForkChildThread).empty(), + "clearing forkedFromId removes the prior source relation while " + "preserving the fork NodeRef"); + } +} + +void semanticDeltasAndHydratedOrderStayCurrent() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + const auto delta = [&](std::string method, std::string itemId, + std::string text, std::string indexName = {}, + std::uint64_t index = 0) { + Value::Object payload{{"threadId", Value("semantic-thread")}, + {"turnId", Value("semantic-turn")}, + {"itemId", Value(std::move(itemId))}, + {"delta", Value(std::move(text))}}; + if (!indexName.empty()) + payload.emplace(std::move(indexName), Value(index)); + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)})); + }; + delta("item/plan/delta", "plan-item", "step one"); + delta("item/reasoning/summaryTextDelta", "reasoning-item", "second", + "summaryIndex", 1); + delta("item/reasoning/summaryTextDelta", "reasoning-item", " part", + "summaryIndex", 1); + delta("item/reasoning/textDelta", "reasoning-item", "details", "contentIndex", + 0); + delta("item/commandExecution/outputDelta", "command-item", "line one\n"); + + { + auto read = graph.tryRead(); + const auto plan = read->state( + findItem(*read, "semantic-thread", "semantic-turn", "plan-item")); + const auto reasoning = read->state( + findItem(*read, "semantic-thread", "semantic-turn", "reasoning-item")); + const auto command = read->state( + findItem(*read, "semantic-thread", "semantic-turn", "command-item")); + const Value *text = field(plan, "text"); + const Value *summary = field(reasoning, "summary"); + const Value *content = field(reasoning, "content"); + const Value *output = field(command, "aggregatedOutput"); + require( + text && text->asString() && *text->asString() == "step one" && + summary && summary->asArray() && summary->asArray()->size() == 2 && + summary->asArray()->at(1).asString() && + *summary->asArray()->at(1).asString() == "second part" && content && + content->asArray() && content->asArray()->front().asString() && + *content->asArray()->front().asString() == "details" && output && + output->asString() && *output->asString() == "line one\n", + "all item deltas append to their concrete semantic fields"); + } + + Value::Object hydrated{ + {"id", Value("semantic-thread")}, + {"turns", + Value(Value::Array{ + Value(Value::Object{ + {"id", Value("turn-two")}, + {"items", + Value(Value::Array{ + Value(Value::Object{{"id", Value("item-two")}}), + Value(Value::Object{{"id", Value("item-one")}})})}}), + Value(Value::Object{ + {"id", Value("turn-one")}, + {"items", Value(Value::Array{Value(Value::Object{ + {"id", Value("item-three")}})})}})})}}; + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("hydrate-order"), + Value::Object{{"thread", Value(std::move(hydrated))}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "semantic-thread"}); + const NodeRef turnTwo = findTurn(*read, "semantic-thread", "turn-two"); + require(protocolIds(*read, read->children(thread)) == + std::vector{"turn-two", "turn-one"} && + protocolIds(*read, read->children(turnTwo)) == + std::vector{"item-two", "item-one"}, + "thread/read publishes exact turn and item order in one revision"); + } +} + +void realtimeNotificationsMaintainOneCurrentSession() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto notify = [&](std::string method, Value::Object payload) { + return updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)}); + }; + + static_cast( + notify("thread/realtime/started", + Value::Object{{"threadId", Value("realtime-thread")}, + {"realtimeSessionId", Value("session-one")}, + {"version", Value("v1")}})); + static_cast(notify( + "thread/realtime/itemAdded", + Value::Object{{"threadId", Value("realtime-thread")}, + {"item", Value(Value::Object{ + {"id", Value("raw-item")}, + {"type", Value("transcriptSegment")}, + {"text", Value("already committed")}})}})); + static_cast(notify( + "thread/realtime/item/started", + Value::Object{{"threadId", Value("realtime-thread")}, + {"item", Value(Value::Object{ + {"id", Value("stream-item")}, + {"realtimeSessionId", Value("session-one")}, + {"type", Value("transcriptSegment")}})}})); + for (const std::string_view delta : {"hello ", "world"}) { + static_cast( + notify("thread/realtime/item/transcript/delta", + Value::Object{{"threadId", Value("realtime-thread")}, + {"itemId", Value("stream-item")}, + {"delta", Value(delta)}})); + } + static_cast(notify("thread/realtime/transcript/delta", + Value::Object{{"threadId", Value("realtime-thread")}, + {"role", Value("assistant")}, + {"delta", Value("draft")}})); + static_cast(notify("thread/realtime/transcript/delta", + Value::Object{{"threadId", Value("realtime-thread")}, + {"role", Value("user")}, + {"delta", Value("question")}})); + static_cast(notify("thread/realtime/transcript/done", + Value::Object{{"threadId", Value("realtime-thread")}, + {"role", Value("assistant")}, + {"text", Value("final answer")}})); + for (const std::string_view data : {"YXVkaW8x", "YXVkaW8y"}) { + static_cast(notify( + "thread/realtime/outputAudio/delta", + Value::Object{ + {"threadId", Value("realtime-thread")}, + {"audio", Value(Value::Object{{"itemId", Value("stream-item")}, + {"data", Value(data)}, + {"sampleRate", Value(24000)}})}})); + } + static_cast(notify("thread/realtime/sdp", + Value::Object{{"threadId", Value("realtime-thread")}, + {"sdp", Value("current-sdp")}})); + for (const std::string_view error : {"first error", "second error"}) { + static_cast( + notify("thread/realtime/error", + Value::Object{{"threadId", Value("realtime-thread")}, + {"message", Value(error)}})); + } + static_cast( + notify("thread/realtime/item/completed", + Value::Object{ + {"threadId", Value("realtime-thread")}, + {"item", Value(Value::Object{ + {"id", Value("stream-item")}, + {"realtimeSessionId", Value("session-one")}, + {"text", Value("authoritative transcript")}})}})); + static_cast(notify("thread/realtime/closed", + Value::Object{{"threadId", Value("realtime-thread")}, + {"reason", Value("remote close")}})); + + NodeRef session; + NodeRef streamItem; + NodeRef rawItem; + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "realtime-thread"}); + for (const NodeRef &child : read->children(thread)) { + if (child->id().kind == NodeKind::RealtimeSession) + session = child; + } + require(session && read->parent(session) == thread, + "realtime started creates one session contained by its thread"); + for (const NodeRef &child : read->children(session)) { + const std::string id = protocolCanonicalId(*read->state(child), child); + if (id == "stream-item") + streamItem = child; + else if (id == "raw-item") + rawItem = child; + } + const auto sessionState = read->state(session); + const auto itemState = read->state(streamItem); + const Value *transcripts = field(sessionState, "transcripts"); + const Value *completion = field(sessionState, "transcriptCompleted"); + const Value *errors = field(sessionState, "errors"); + const Value *audio = field(itemState, "outputAudioChunks"); + require( + rawItem && streamItem && itemState->status == NodeStatus::Completed && + field(itemState, "transcript") && + *field(itemState, "transcript")->asString() == "hello world" && + audio && audio->asArray() && audio->asArray()->size() == 2, + "realtime items retain identity, transcript, audio, and completion"); + require( + transcripts && transcripts->asObject() && + transcripts->find("assistant") && + *transcripts->find("assistant")->asString() == "final answer" && + transcripts->find("user") && + *transcripts->find("user")->asString() == "question" && + completion && completion->find("assistant") && + *completion->find("assistant")->asBool(), + "role transcripts finalize independently without overwriting peers"); + require(errors && errors->asArray() && errors->asArray()->size() == 2 && + field(sessionState, "sdp") && + *field(sessionState, "sdp")->asString() == "current-sdp" && + sessionState->status == NodeStatus::Failed && + field(sessionState, "active") && + !*field(sessionState, "active")->asBool(), + "errors append and close preserves the session's failed status"); + } + + const ApplyResult restarted = + notify("thread/realtime/started", + Value::Object{{"threadId", Value("realtime-thread")}, + {"realtimeSessionId", Value("session-two")}, + {"version", Value("v2")}}); + { + auto read = graph.tryRead(); + const auto state = read->state(session); + require(read->children(session).empty() && + std::ranges::find(restarted.change.removed, streamItem) != + restarted.change.removed.end() && + std::ranges::find(restarted.change.removed, rawItem) != + restarted.change.removed.end(), + "a new realtime incarnation removes old current-session items"); + require(state->status == NodeStatus::Running && + field(state, "realtimeSessionId") && + *field(state, "realtimeSessionId")->asString() == + "session-two" && + !field(state, "errors") && !field(state, "transcripts"), + "new realtime start atomically resets only the current session"); + } +} + +void hookRunsKeepNestedIdentityAndCurrentOwnership() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto notify = [&](std::string method, Value::Object payload) { + return updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)}); + }; + + const ApplyResult firstStarted = notify( + "hook/started", + Value::Object{ + {"threadId", Value("hook-thread")}, + {"turnId", Value("hook-turn")}, + {"run", Value(Value::Object{{"id", Value("hook-run-a")}, + {"eventName", Value("preToolUse")}, + {"handlerType", Value("command")}, + {"status", Value("running")}, + {"startedAt", Value(std::int64_t{100})}, + {"entries", Value(Value::Array{})}})}}); + const ApplyResult secondStarted = notify( + "hook/started", + Value::Object{{"threadId", Value("hook-thread")}, + {"run", Value(Value::Object{ + {"id", Value("hook-run-b")}, + {"eventName", Value("postToolUse")}, + {"handlerType", Value("mcpTool")}, + {"status", Value("running")}, + {"startedAt", Value(std::int64_t{110})}})}}); + + NodeRef first; + NodeRef second; + std::uint64_t secondStartedRevision = 0; + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "hook-thread"}); + const NodeRef turn = findTurn(*read, "hook-thread", "hook-turn"); + first = read->find({NodeKind::Hook, "hook-run-a"}); + second = read->find({NodeKind::Hook, "hook-run-b"}); + const auto firstState = read->state(first); + const auto secondState = read->state(second); + secondStartedRevision = read->changedRevision(second); + require(first && second && first != second && thread && turn && + read->parent(first) == turn && read->parent(turn) == thread && + read->parent(second) == thread, + "nested run ids preserve concurrent hook nodes under their " + "optional turn or thread owner"); + require(firstState->status == NodeStatus::Running && + secondState->status == NodeStatus::Running && + protocolCanonicalId(*firstState, first) == "hook-run-a" && + protocolCanonicalId(*secondState, second) == "hook-run-b" && + field(firstState, "eventName") && + *field(firstState, "eventName")->asString() == "preToolUse" && + field(firstState, "threadId") && + *field(firstState, "threadId")->asString() == "hook-thread" && + field(firstState, "turnId") && + *field(firstState, "turnId")->asString() == "hook-turn", + "hook starts retain the current nested summary and protocol " + "addressing fields"); + require(std::ranges::find(firstStarted.change.affected, first) != + firstStarted.change.affected.end() && + std::ranges::find(secondStarted.change.affected, second) != + secondStarted.change.affected.end(), + "each hook start reports its independently affected run"); + } + + const ApplyResult firstCompleted = notify( + "hook/completed", + Value::Object{ + {"threadId", Value("hook-thread")}, + {"turnId", Value("hook-turn")}, + {"run", + Value(Value::Object{ + {"id", Value("hook-run-a")}, + {"eventName", Value("preToolUse")}, + {"status", Value("completed")}, + {"completedAt", Value(std::int64_t{145})}, + {"durationMs", Value(std::int64_t{45})}, + {"statusMessage", Value("accepted")}, + {"entries", Value(Value::Array{Value(Value::Object{ + {"kind", Value("context")}, + {"text", Value("current output")}})})}})}}); + { + auto read = graph.tryRead(); + const NodeRef currentFirst = read->find({NodeKind::Hook, "hook-run-a"}); + const NodeRef currentSecond = read->find({NodeKind::Hook, "hook-run-b"}); + const auto firstState = read->state(currentFirst); + const auto secondState = read->state(currentSecond); + const Value *entries = field(firstState, "entries"); + require( + currentFirst == first && currentSecond == second && + firstState->status == NodeStatus::Completed && + field(firstState, "startedAt") && + *field(firstState, "startedAt")->asInt64() == 100 && + field(firstState, "completedAt") && + *field(firstState, "completedAt")->asInt64() == 145 && + field(firstState, "lastMethod") && + *field(firstState, "lastMethod")->asString() == "hook/completed" && + entries && entries->asArray() && entries->asArray()->size() == 1, + "hook completion updates the same run with its latest fields " + "while retaining still-current start facts"); + require(secondState->status == NodeStatus::Running && + read->changedRevision(second) == secondStartedRevision && + std::ranges::find(firstCompleted.change.affected, second) == + firstCompleted.change.affected.end(), + "completing one hook run does not conflate or rewrite a concurrent " + "run"); + } + + static_cast(notify( + "hook/completed", + Value::Object{{"threadId", Value("hook-thread")}, + {"run", Value(Value::Object{ + {"id", Value("hook-run-b")}, + {"status", Value("blocked")}, + {"statusMessage", + Value("policy prevented execution")}})}})); + static_cast(notify( + "hook/completed", + Value::Object{{"threadId", Value("hook-thread")}, + {"run", Value(Value::Object{ + {"id", Value("hook-run-c")}, + {"completedAt", Value(std::int64_t{200})}})}})); + { + auto read = graph.tryRead(); + const NodeRef blocked = read->find({NodeKind::Hook, "hook-run-b"}); + const NodeRef completed = read->find({NodeKind::Hook, "hook-run-c"}); + require( + blocked == second && + read->state(blocked)->status == NodeStatus::Failed && completed && + read->state(completed)->status == NodeStatus::Completed && + field(read->state(completed), "status") && + *field(read->state(completed), "status")->asString() == "completed", + "hook terminal status is normalized while the raw current status " + "remains available on the node"); + } + + const std::uint64_t beforeInvalid = graph.publishedRevision(); + const ApplyResult invalid = + notify("hook/started", + Value::Object{ + {"threadId", Value("hook-thread")}, + {"run", Value(Value::Object{{"status", Value("running")}})}}); + { + auto read = graph.tryRead(); + require(invalid.change.empty() && read->revision() == beforeInvalid && + !read->find({NodeKind::Hook, "hook/started"}), + "a hook notification without nested run.id is explicitly " + "state-neutral instead of collapsing unrelated runs"); + } +} + +void graphRelationsInvalidationAndIncarnationsAreExplicit() { + NodeGraph graph; + ProtocolUpdater updater(graph); + { + auto write = graph.write(); + NodeRef connection = write.upsert({NodeKind::Connection, "connection"}); + write.setField(connection, "connectionGeneration", Value(1)); + write.setField(connection, "providerGeneration", Value(7)); + static_cast(write.finish()); + } + + const auto notify = [&](std::string method, Value::Object payload) { + return updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)}); + }; + static_cast(notify("command/exec/outputDelta", + Value::Object{{"processId", Value("reused-process")}, + {"stream", Value("stdout")}, + {"deltaBase64", Value("Zmlyc3Q=")}, + {"capReached", Value(false)}})); + static_cast(notify( + "fs/changed", + Value::Object{{"watchId", Value("reused-watch")}, + {"changedPaths", Value(Value::Array{Value("/first")})}})); + { + auto write = graph.write(); + NodeRef connection = write.find({NodeKind::Connection, "connection"}); + write.setField(connection, "connectionGeneration", Value(2)); + write.setField(connection, "providerGeneration", Value(1)); + static_cast(write.finish()); + } + static_cast(notify("command/exec/outputDelta", + Value::Object{{"processId", Value("reused-process")}, + {"stream", Value("stdout")}, + {"deltaBase64", Value("c2Vjb25k")}, + {"capReached", Value(true)}})); + static_cast(notify( + "fs/changed", + Value::Object{{"watchId", Value("reused-watch")}, + {"changedPaths", Value(Value::Array{Value("/second")})}})); + { + auto read = graph.tryRead(); + const NodeRef firstProcess = + findProtocolNode(*read, NodeKind::Process, "reused-process", 1); + const NodeRef secondProcess = + findProtocolNode(*read, NodeKind::Process, "reused-process", 2); + const NodeRef firstWatch = + findProtocolNode(*read, NodeKind::FilesystemWatch, "reused-watch", 1); + const NodeRef secondWatch = + findProtocolNode(*read, NodeKind::FilesystemWatch, "reused-watch", 2); + const NodeRef connection = read->find({NodeKind::Connection, "connection"}); + require( + firstProcess && secondProcess && firstProcess != secondProcess && + firstWatch && secondWatch && firstWatch != secondWatch, + "connection-scoped process and watch IDs cannot cross incarnations"); + require( + field(read->state(firstProcess), "stdoutBase64") && + *field(read->state(firstProcess), "stdoutBase64")->asString() == + "Zmlyc3Q=" && + field(read->state(secondProcess), "stdoutBase64") && + *field(read->state(secondProcess), "stdoutBase64")->asString() == + "c2Vjb25k", + "same raw process ID retains isolated output per incarnation"); + const auto owned = read->related(connection, RelationKind::ProcessOwner); + require(std::ranges::find(owned, firstProcess) != owned.end() && + std::ranges::find(owned, secondProcess) != owned.end(), + "the connection directly owns its scoped process nodes"); + } + + Value::Object collabItem{ + {"id", Value("collab-item")}, + {"type", Value("collabAgentToolCall")}, + {"receiverThreadIds", + Value(Value::Array{Value("receiver-a"), Value("receiver-b")})}}; + Value::Object thread{ + {"id", Value("related-thread")}, + {"projectId", Value("project-a")}, + {"section", Value(Value::Object{{"id", Value("section-a")}, + {"name", Value("Section A")}})}, + {"turns", Value(Value::Array{Value(Value::Object{ + {"id", Value("related-turn")}, + {"items", Value(Value::Array{Value(collabItem)})}})})}}; + static_cast(notify( + "thread/started", Value::Object{{"thread", Value(std::move(thread))}})); + { + auto read = graph.tryRead(); + const NodeRef owner = read->find({NodeKind::Thread, "related-thread"}); + const NodeRef item = + findItem(*read, "related-thread", "related-turn", "collab-item"); + const NodeRef project = read->find({NodeKind::Project, "project-a"}); + const NodeRef section = read->find({NodeKind::ThreadSection, "section-a"}); + const NodeRef receiverA = read->find({NodeKind::Thread, "receiver-a"}); + const NodeRef receiverB = read->find({NodeKind::Thread, "receiver-b"}); + require(read->related(owner, RelationKind::ProjectMembership) == + std::vector{project} && + read->related(owner, RelationKind::SectionMembership) == + std::vector{section}, + "thread descriptors populate direct project and section relations"); + require(read->related(item, RelationKind::AgentChildThread) == + std::vector{receiverA, receiverB} && + read->related(owner, RelationKind::AgentChildThread) == + std::vector{receiverA, receiverB}, + "all receiverThreadIds populate stable child-thread relations"); + } + + static_cast(notify( + "item/completed", + Value::Object{ + {"threadId", Value("related-thread")}, + {"turnId", Value("related-turn")}, + {"item", + Value(Value::Object{{"id", Value("collab-item")}, + {"type", Value("collabAgentToolCall")}, + {"receiverThreadIds", + Value(Value::Array{Value("receiver-b")})}})}})); + static_cast(notify("thread/project/updated", + Value::Object{{"threadId", Value("related-thread")}, + {"projectId", Value("project-b")}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/section/move", std::nullopt, + Value::Object{{"threadId", Value("related-thread")}, + {"sectionId", Value("section-b")}}})); + static_cast(notify( + "thread/goal/updated", + Value::Object{{"threadId", Value("related-thread")}, + {"turnId", Value("related-turn")}, + {"goal", Value(Value::Object{{"text", Value("ship")}})}})); + static_cast( + notify("thread/goal/cleared", + Value::Object{{"threadId", Value("related-thread")}})); + static_cast( + notify("thread/queue/changed", + Value::Object{{"threadId", Value("related-thread")}})); + static_cast(notify("skills/changed", Value::Object{})); + static_cast(notify("project/changed", + Value::Object{{"projectId", Value("project-b")}, + {"changeType", Value("updated")}})); + { + auto read = graph.tryRead(); + const NodeRef owner = read->find({NodeKind::Thread, "related-thread"}); + const NodeRef item = + findItem(*read, "related-thread", "related-turn", "collab-item"); + const NodeRef receiverB = read->find({NodeKind::Thread, "receiver-b"}); + const NodeRef projectB = read->find({NodeKind::Project, "project-b"}); + const NodeRef sectionB = read->find({NodeKind::ThreadSection, "section-b"}); + const auto ownerState = read->state(owner); + require(read->related(item, RelationKind::AgentChildThread) == + std::vector{receiverB} && + read->related(owner, RelationKind::AgentChildThread) == + std::vector{receiverB}, + "authoritative receiver replacement removes stale child relations"); + require(read->related(owner, RelationKind::ProjectMembership) == + std::vector{projectB} && + read->related(owner, RelationKind::SectionMembership) == + std::vector{sectionB}, + "exact project and section assignments replace prior relations"); + require(field(ownerState, "goal") && field(ownerState, "goal")->isNull() && + field(ownerState, "goalTurnId") && + field(ownerState, "goalTurnId")->isNull() && + field(ownerState, "queueStale") && + *field(ownerState, "queueStale")->asBool(), + "goal clearing is known-null and queue changes invalidate state"); + const auto skills = read->state(read->find({NodeKind::Catalog, "skills"})); + require(field(skills, "stale") && *field(skills, "stale")->asBool() && + field(read->state(projectB), "stale") && + *field(read->state(projectB), "stale")->asBool(), + "catalog and project invalidations are explicit current facts"); + } + + static_cast(notify("thread/project/updated", + Value::Object{{"threadId", Value("related-thread")}, + {"projectId", Value(nullptr)}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/section/move", std::nullopt, + Value::Object{{"threadId", Value("related-thread")}, + {"sectionId", Value(nullptr)}}})); + { + auto read = graph.tryRead(); + const NodeRef owner = read->find({NodeKind::Thread, "related-thread"}); + require(read->related(owner, RelationKind::ProjectMembership).empty() && + read->related(owner, RelationKind::SectionMembership).empty(), + "nullable project and section updates remove stale membership"); + } +} + +void promptMaterializationDoesNotAcknowledgeDelivery() { + NodeGraph graph; + NodeRef local; + { + auto write = graph.write(); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + NodeRef thread = write.upsert({NodeKind::Thread, "prompt-thread"}); + NodeRef turn = write.upsert({NodeKind::Turn, "local-turn"}); + local = write.upsert({NodeKind::Item, "local-prompt"}); + write.setParent(thread, turn); + write.setParent(turn, local); + write.setField(local, "local", Value(true)); + write.setField(local, "clientUserMessageId", Value("client-prompt")); + write.setField(local, "dispatchState", Value("awaitingResult")); + write.setStatus(local, NodeStatus::Running); + write.relate(turn, RelationKind::TurnRootItem, local); + write.relate(runtime, RelationKind::PendingPrompt, local); + static_cast(write.finish()); + } + + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{{"threadId", Value("prompt-thread")}, + {"turnId", Value("provider-turn")}, + {"item", Value(Value::Object{ + {"id", Value("provider-item")}, + {"type", Value("userMessage")}, + {"clientId", Value("client-prompt")}})}}})); + auto read = graph.tryRead(); + const NodeRef authoritative = + findItem(*read, "prompt-thread", "provider-turn", "provider-item"); + const auto localState = read->state(local); + require(read->related(authoritative, RelationKind::PromptMaterialization) == + std::vector{local}, + "authoritative prompt identity is related to its local node"); + require(read->related(read->parent(authoritative), + RelationKind::TurnRootItem) == + std::vector{authoritative} && + read->related(read->parent(local), RelationKind::TurnRootItem) == + std::vector{local}, + "inbound materialization roots the provider Turn without changing " + "the still-unacknowledged optimistic Turn ownership"); + require(localState->status == NodeStatus::Running && + field(localState, "dispatchState") && + *field(localState, "dispatchState")->asString() == + "awaitingResult", + "inbound materialization alone never acknowledges outbound delivery"); +} + +void steeringMaterializationKeepsTheSubmittedSlot() { + NodeGraph graph; + ProtocolUpdater updater(graph); + NodeRef local; + NodeRef intervening; + { + auto write = graph.write(); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + NodeRef thread = write.upsert({NodeKind::Thread, "steering-thread"}); + NodeRef turn = write.upsert( + scopedTurnNodeId("steering-thread", "steering-turn")); + write.setField(turn, "protocolId", Value("steering-turn")); + write.setField(turn, "protocolThreadId", Value("steering-thread")); + NodeRef root = write.upsert( + scopedItemNodeId(turn->id(), "opening-prompt")); + write.setField(root, "protocolId", Value("opening-prompt")); + write.setField(root, "type", Value("userMessage")); + local = write.upsert({NodeKind::Item, "local-steering"}); + write.setField(local, "type", Value("localPrompt")); + write.setField(local, "submissionId", Value(std::uint64_t{77})); + write.setField(local, "clientUserMessageId", Value("steering-client")); + write.setField(local, "startsTurn", Value(false)); + intervening = write.upsert( + scopedItemNodeId(turn->id(), "intervening-activity")); + write.setField(intervening, "protocolId", Value("intervening-activity")); + write.setField(intervening, "type", Value("agentMessage")); + write.setParent(thread, turn); + write.setParent(turn, root); + write.setParent(turn, local); + write.setParent(turn, intervening); + write.relate(turn, RelationKind::TurnRootItem, root); + write.relate(runtime, RelationKind::PendingPrompt, local); + static_cast(write.finish()); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{{"threadId", Value("steering-thread")}, + {"turnId", Value("steering-turn")}, + {"item", Value(Value::Object{ + {"id", Value("provider-steering")}, + {"type", Value("userMessage")}, + {"clientId", Value("steering-client")}, + {"text", Value("Steer here")}})}}})); + + auto read = graph.tryRead(); + const NodeRef turn = + findTurn(*read, "steering-thread", "steering-turn"); + const NodeRef authoritative = + findItem(*read, "steering-thread", "steering-turn", + "provider-steering"); + const auto ordered = read->children(turn); + const auto localPosition = std::ranges::find(ordered, local); + const auto authoritativePosition = + std::ranges::find(ordered, authoritative); + const auto interveningPosition = std::ranges::find(ordered, intervening); + const auto authoritativeState = read->state(authoritative); + const Value *submission = + field(authoritativeState, "localSubmissionId"); + require(authoritative && submission && submission->asUInt64() && + *submission->asUInt64() == 77 && + localPosition != ordered.end() && + authoritativePosition == std::next(localPosition) && + interveningPosition != ordered.end() && + authoritativePosition < interveningPosition, + "a correlated steering item retains the submitted local slot and " + "its stable visual identity ahead of later activity"); +} + +void turnRootsAndPagedHistoryStayExplicit() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + Value::Array completeItems; + completeItems.emplace_back(Value::Object{{"id", Value("opening-prompt")}, + {"type", Value("userMessage")}}); + for (std::size_t index = 0; index < 82; ++index) { + completeItems.emplace_back( + Value::Object{{"id", Value("activity-" + std::to_string(index))}, + {"type", Value("agentMessage")}}); + } + completeItems.emplace_back(Value::Object{{"id", Value("later-steering")}, + {"type", Value("userMessage")}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("complete-history"), + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("history-thread")}, + {"turns", + Value(Value::Array{Value(Value::Object{ + {"id", Value("long-turn")}, + {"items", Value(std::move(completeItems))}})})}})}}})); + + NodeRef openingPrompt; + std::uint64_t threadRevision = 0; + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "history-thread"}); + const NodeRef turn = findTurn(*read, "history-thread", "long-turn"); + openingPrompt = + findItem(*read, "history-thread", "long-turn", "opening-prompt"); + const Value *loaded = field(read->state(thread), "historyLoadedItemCount"); + require(turn && openingPrompt && + read->related(turn, RelationKind::TurnRootItem) == + std::vector{openingPrompt}, + "a turn directly relates to its authoritative opening prompt"); + require(loaded && loaded->asUInt64() && *loaded->asUInt64() == 84, + "thread history records the complete loaded item count for local " + "windowing"); + threadRevision = read->changedRevision(thread); + } + const ApplyResult streamUpdate = + updater.apply({DecodedMessageKind::ServerNotification, + "item/agentMessage/delta", std::nullopt, + Value::Object{{"threadId", Value("history-thread")}, + {"turnId", Value("long-turn")}, + {"itemId", Value("activity-81")}, + {"delta", Value("streamed")}}}); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "history-thread"}); + const Value *loaded = field(read->state(thread), "historyLoadedItemCount"); + require(read->changedRevision(thread) > threadRevision && loaded && + loaded->asUInt64() && *loaded->asUInt64() == 84 && + std::ranges::find(streamUpdate.change.affected, thread) == + streamUpdate.change.affected.end(), + "an existing-item stream advances its aggregate correlation " + "revision without recomputing history metadata or scheduling a " + "thread-row render"); + } + + Value::Array retainedSuffix{ + Value(Value::Object{{"id", Value("later-steering")}, + {"type", Value("userMessage")}}), + Value(Value::Object{{"id", Value("latest-activity")}, + {"type", Value("agentMessage")}})}; + Value::Object suffixTurn{{"id", Value("long-turn")}, + {"items", Value(std::move(retainedSuffix))}}; + Value::Object suffixThread{ + {"id", Value("history-thread")}, + {"turns", Value(Value::Array{Value(std::move(suffixTurn))})}}; + const ApplyResult suffixReplacement = updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", + ProtocolRequestId("suffix-history"), + Value::Object{{"thread", Value(std::move(suffixThread))}}}); + NodeRef retainedTurn; + NodeRef steering; + NodeRef latest; + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "history-thread"}); + retainedTurn = findTurn(*read, "history-thread", "long-turn"); + steering = findItem(*read, "history-thread", "long-turn", "later-steering"); + latest = findItem(*read, "history-thread", "long-turn", "latest-activity"); + const Value *loaded = field(read->state(thread), "historyLoadedItemCount"); + require(read->children(retainedTurn) == + std::vector{steering, latest} && + read->related(retainedTurn, RelationKind::TurnRootItem) == + std::vector{steering} && + !read->find(openingPrompt->id()) && + read->removed(openingPrompt) && + std::ranges::find(suffixReplacement.change.removed, + openingPrompt) != + suffixReplacement.change.removed.end(), + "an authoritative suffix retires its omitted provider items and " + "selects the first retained user message as the current root"); + require(loaded && loaded->asUInt64() && *loaded->asUInt64() == 2, + "loaded history count includes only current authoritative items"); + } + + const ApplyResult removedHistory = updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("history-thread")}}}); + require(std::ranges::find(removedHistory.change.removed, retainedTurn) != + removedHistory.change.removed.end() && + std::ranges::find(removedHistory.change.removed, steering) != + removedHistory.change.removed.end() && + std::ranges::find(removedHistory.change.removed, latest) != + removedHistory.change.removed.end(), + "removing a hydrated thread retires all remaining current history"); + + const ProtocolRequestId firstPage("turn-page-1"); + const ApplyResult firstPageRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/turns/list", firstPage, + Value::Object{{"threadId", Value("paged-thread")}}}); + Value::Array firstTurns{Value(Value::Object{ + {"id", Value("newer-turn")}, + {"items", Value(Value::Array{ + Value(Value::Object{{"id", Value("newer-root")}, + {"type", Value("userMessage")}}), + Value(Value::Object{{"id", Value("newer-steering")}, + {"type", Value("userMessage")}})})}})}; + const ApplyResult firstPageResult = updater.apply( + {DecodedMessageKind::ClientResult, "thread/turns/list", firstPage, + Value::Object{{"data", Value(std::move(firstTurns))}, + {"nextCursor", Value("older-page")}}, + firstPageRequest.primary}); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "paged-thread"}); + const NodeRef turn = findTurn(*read, "paged-thread", "newer-turn"); + const NodeRef root = + findItem(*read, "paged-thread", "newer-turn", "newer-root"); + const NodeRef operation = + read->find({NodeKind::Operation, firstPage.canonical()}); + const auto state = read->state(thread); + const Value *hasMore = field(state, "historyHasMore"); + const Value *cursor = field(state, "historyNextCursor"); + const Value *loaded = field(state, "historyLoadedItemCount"); + require(thread && turn && root && + read->related(turn, RelationKind::TurnRootItem) == + std::vector{root}, + "a turns page retains the opening item relation for each turn"); + require(!operation && firstPageRequest.primary && + std::ranges::find(firstPageResult.change.removed, + firstPageRequest.primary) != + firstPageResult.change.removed.end(), + "a turns/list result uses its correlated request scope then " + "retires the completed operation"); + require(hasMore && hasMore->asBool() && *hasMore->asBool() && cursor && + cursor->asString() && *cursor->asString() == "older-page" && + loaded && loaded->asUInt64() && *loaded->asUInt64() == 2, + "a turns page exposes provider continuation and loaded history " + "size on its thread"); + } + + const ProtocolRequestId lastPage("turn-page-2"); + static_cast(updater.apply( + {DecodedMessageKind::ClientRequest, "thread/turns/list", lastPage, + Value::Object{{"threadId", Value("paged-thread")}, + {"cursor", Value("older-page")}}})); + Value::Array olderTurns{Value(Value::Object{ + {"id", Value("older-turn")}, + {"items", + Value(Value::Array{Value(Value::Object{ + {"id", Value("older-root")}, {"type", Value("userMessage")}})})}})}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/turns/list", lastPage, + Value::Object{{"data", Value(std::move(olderTurns))}, + {"nextCursor", Value(nullptr)}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "paged-thread"}); + const auto state = read->state(thread); + const Value *hasMore = field(state, "historyHasMore"); + const Value *loaded = field(state, "historyLoadedItemCount"); + require(protocolIds(*read, read->children(thread)) == + std::vector{"older-turn", "newer-turn"} && + hasMore && hasMore->asBool() && !*hasMore->asBool() && + !field(state, "historyNextCursor") && loaded && + loaded->asUInt64() && *loaded->asUInt64() == 3, + "the final turns page clears provider continuation while " + "retaining accumulated chronological history"); + } +} + +void resultsAndListsCorrelate() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const ProtocolRequestId requestId("list-1"); + + ApplyResult request = + updater.apply({DecodedMessageKind::ClientRequest, "thread/list", + requestId, Value::Object{{"limit", Value(20)}}}); + NodeRef operation; + { + auto read = graph.tryRead(); + operation = read->find({NodeKind::Operation, requestId.canonical()}); + require(operation && read->state(operation)->status == NodeStatus::Pending, + "client request creates a pending correlated operation node"); + } + + Value::Array threads{ + Value(Value::Object{{"id", Value("listed-1")}, {"name", Value("First")}}), + Value( + Value::Object{{"id", Value("listed-2")}, {"name", Value("Second")}})}; + ApplyResult result = + updater.apply({DecodedMessageKind::ClientResult, "thread/list", requestId, + Value::Object{{"data", Value(std::move(threads))}, + {"nextCursor", Value("next")}}, + operation}); + require(result.change.revision == request.change.revision + 1, + "correlated result is one later atomic revision"); + { + auto read = graph.tryRead(); + require(!read->find({NodeKind::Operation, requestId.canonical()}) && + std::ranges::find(result.change.removed, operation) != + result.change.removed.end(), + "successful result applies current state then retires its pending " + "operation"); + require(read->find({NodeKind::Thread, "listed-1"}) && + read->find({NodeKind::Thread, "listed-2"}), + "thread list result materializes its current entities"); + } + + const ProtocolRequestId failedId(22); + const ApplyResult failedRequest = + updater.apply({DecodedMessageKind::ClientRequest, "thread/read", failedId, + Value::Object{{"threadId", Value("missing")}}}); + const ApplyResult failedResult = updater.apply( + {DecodedMessageKind::ClientError, "thread/read", failedId, + Value::Object{{"code", Value(-32001)}, {"message", Value("overloaded")}}, + failedRequest.primary}); + { + auto read = graph.tryRead(); + NodeRef failed = read->find({NodeKind::Operation, failedId.canonical()}); + require(!failed && failedRequest.primary && + std::ranges::find(failedResult.change.removed, + failedRequest.primary) != + failedResult.change.removed.end(), + "failed result retires its pending operation instead of keeping " + "terminal history"); + } +} + +void lateResultsCannotRecreateDeletedTargets() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const ProtocolRequestId requestId("late-rename"); + + const ApplyResult request = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/name/set", requestId, + Value::Object{{"threadId", Value("deleted-target")}, + {"name", Value("stale name")}}}); + require(static_cast(request.primary), + "an addressed rename request exposes its pending operation"); + { + auto read = graph.tryRead(); + require(read->find({NodeKind::Thread, "deleted-target"}) && + read->related(request.primary, RelationKind::OperationTarget) == + std::vector{ + read->find({NodeKind::Thread, "deleted-target"})}, + "an addressed rename records its original operation target"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("deleted-target")}}})); + { + auto read = graph.tryRead(); + require( + !read->find({NodeKind::Thread, "deleted-target"}) && + read->find(request.primary->id()) == request.primary && + read->related(request.primary, RelationKind::OperationTarget) + .empty(), + "deleting the rename target unlinks it while retaining the in-flight " + "operation for correlation"); + } + + const ApplyResult late = + updater.apply({DecodedMessageKind::ClientResult, "thread/name/set", + requestId, Value::Object{}, request.primary}); + { + auto read = graph.tryRead(); + require(!read->find({NodeKind::Thread, "deleted-target"}) && + !read->find(request.primary->id()) && + std::ranges::find(late.change.removed, request.primary) != + late.change.removed.end(), + "a late mutation result retires without recreating its deleted " + "original target"); + } +} + +void exactRequestTargetsOverridePayloadAddressingAndLifetime() { + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("exact-target")}, + {"name", Value("Original")}})}}})); + + NodeRef exactTarget; + { + auto read = graph.tryRead(); + exactTarget = read->find({NodeKind::Thread, "exact-target"}); + } + const ProtocolRequestId requestId("exact-target-read"); + DecodedMessage request{ + DecodedMessageKind::ClientRequest, "thread/read", requestId, + Value::Object{{"threadId", Value("exact-target")}, + {"turnId", Value("payload-decoy-turn")}, + {"itemId", Value("payload-decoy-item")}}}; + request.requestTarget = exactTarget; + const ApplyResult admitted = updater.apply(std::move(request)); + { + auto read = graph.tryRead(); + const NodeId decoyTurn = + scopedTurnNodeId("exact-target", "payload-decoy-turn"); + require( + admitted.primary && exactTarget && + read->related(admitted.primary, RelationKind::OperationTarget) == + std::vector{exactTarget} && + !read->find(decoyTurn) && + !read->find(scopedItemNodeId(decoyTurn, "payload-decoy-item")), + "a client request retains its exact action NodeRef instead of " + "reconstructing a more-specific target from payload fields"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("exact-target")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("exact-target")}, + {"name", Value("Replacement")}})}}})); + const ApplyResult late = updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", requestId, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("exact-target")}, + {"name", Value("Late stale")}, + {"turns", Value(Value::Array{})}})}}, + admitted.primary}); + { + auto read = graph.tryRead(); + const NodeRef replacement = read->find({NodeKind::Thread, "exact-target"}); + const Value *name = + replacement ? field(read->state(replacement), "name") : nullptr; + require(replacement && replacement != exactTarget && + read->removed(exactTarget) && name && name->asString() && + *name->asString() == "Replacement" && + !read->find(admitted.primary->id()) && + std::ranges::find(late.change.removed, admitted.primary) != + late.change.removed.end(), + "removing an exact target prevents its late response from " + "mutating a replacement node with the same canonical id"); + } +} + +void accountFacetsConvergeAndRateLimitPatchesStaySparse() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + const ProtocolRequestId accountReadId("account-read"); + const ApplyResult accountRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "account/read", accountReadId, {}}); + const Value::Object fullAccount{ + {"account", Value(Value::Object{{"type", Value("chatgpt")}, + {"email", Value("person@example.com")}, + {"planType", Value("plus")}})}, + {"requiresOpenaiAuth", Value(false)}}; + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "account/read", + accountReadId, fullAccount, accountRequest.primary})); + + NodeRef accountNode; + { + auto read = graph.tryRead(); + accountNode = read->find({NodeKind::Account, "account"}); + require(accountNode && !read->find({NodeKind::Account, "account/read"}), + "account/read materializes the one current account facet"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "account/updated", std::nullopt, + Value::Object{{"authMode", Value("chatgpt")}, + {"planType", Value("team")}}})); + { + auto read = graph.tryRead(); + const NodeRef current = read->find({NodeKind::Account, "account"}); + const auto state = current ? read->state(current) : nullptr; + const Value *account = field(state, "account"); + const Value *email = + objectField(account ? account->asObject() : nullptr, "email"); + const Value *requiresAuth = field(state, "requiresOpenaiAuth"); + const Value *authMode = field(state, "authMode"); + const Value *planType = field(state, "planType"); + require(current == accountNode && + !read->find({NodeKind::Account, "account/updated"}) && email && + email->asString() && + *email->asString() == "person@example.com" && requiresAuth && + requiresAuth->asBool() && !*requiresAuth->asBool() && + authMode && authMode->asString() && + *authMode->asString() == "chatgpt" && planType && + planType->asString() && *planType->asString() == "team", + "account/updated sparsely augments the same account/read node"); + } + + const ProtocolRequestId rateReadId("rate-limits-read"); + const ApplyResult rateRequest = + updater.apply({DecodedMessageKind::ClientRequest, + "account/rateLimits/read", + rateReadId, + {}}); + const Value::Object fullRateLimits{ + {"accountId", Value("account-1")}, + {"rateLimitUpsell", + Value(Value::Object{{"message", Value("upgrade available")}})}, + {"rateLimitResetCredits", + Value(Value::Object{{"availableCount", Value(2)}})}, + {"rateLimitsByLimitId", + Value(Value::Object{ + {"secondary-limit", + Value(Value::Object{{"limitName", Value("secondary")}})}})}, + {"rateLimits", + Value(Value::Object{ + {"limitId", Value("old-limit")}, + {"limitName", Value("preserve")}, + {"planType", Value("plus")}, + {"primary", Value(Value::Object{{"usedPercent", Value(25)}, + {"resetsAt", Value(1000)}, + {"windowDurationMins", Value(300)}, + {"futurePrimary", Value("keep")}})}, + {"secondary", Value(Value::Object{{"usedPercent", Value(10)}, + {"resetsAt", Value(2000)}})}, + {"futureOld", Value(1)}})}}; + static_cast(updater.apply({DecodedMessageKind::ClientResult, + "account/rateLimits/read", rateReadId, + fullRateLimits, rateRequest.primary})); + + NodeRef rateLimitsNode; + { + auto read = graph.tryRead(); + rateLimitsNode = read->find({NodeKind::Account, "rate-limits"}); + require(rateLimitsNode && + !read->find({NodeKind::Account, "account/rateLimits/read"}), + "rate-limit reads materialize one current rate-limit facet"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "account/rateLimits/updated", + std::nullopt, + Value::Object{ + {"rateLimits", + Value(Value::Object{ + {"limitId", Value("new-limit")}, + {"primary", Value(Value::Object{{"usedPercent", Value(40)}})}, + {"futureNew", Value(2)}})}}})); + { + auto read = graph.tryRead(); + const NodeRef current = read->find({NodeKind::Account, "rate-limits"}); + const auto state = current ? read->state(current) : nullptr; + const Value *limitsValue = field(state, "rateLimits"); + const Value::Object *limits = + limitsValue ? limitsValue->asObject() : nullptr; + const Value *primaryValue = objectField(limits, "primary"); + const Value::Object *primary = + primaryValue ? primaryValue->asObject() : nullptr; + const Value *secondaryValue = objectField(limits, "secondary"); + const Value::Object *secondary = + secondaryValue ? secondaryValue->asObject() : nullptr; + const Value *limitId = objectField(limits, "limitId"); + const Value *limitName = objectField(limits, "limitName"); + const Value *planType = objectField(limits, "planType"); + const Value *usedPercent = objectField(primary, "usedPercent"); + const Value *resetsAt = objectField(primary, "resetsAt"); + const Value *duration = objectField(primary, "windowDurationMins"); + const Value *futurePrimary = objectField(primary, "futurePrimary"); + const Value *secondaryPercent = objectField(secondary, "usedPercent"); + require( + current == rateLimitsNode && + !read->find({NodeKind::Account, "account/rateLimits/updated"}) && + limitId && limitId->asString() && + *limitId->asString() == "new-limit" && limitName && + limitName->asString() && *limitName->asString() == "preserve" && + planType && planType->asString() && + *planType->asString() == "plus" && usedPercent && + usedPercent->asInt64() && *usedPercent->asInt64() == 40 && + resetsAt && resetsAt->asInt64() && *resetsAt->asInt64() == 1000 && + duration && duration->asInt64() && *duration->asInt64() == 300 && + futurePrimary && futurePrimary->asString() && + *futurePrimary->asString() == "keep" && secondaryPercent && + secondaryPercent->asInt64() && *secondaryPercent->asInt64() == 10 && + objectField(limits, "futureOld") && + objectField(limits, "futureNew") && + field(state, "rateLimitUpsell") && + field(state, "rateLimitResetCredits") && + field(state, "rateLimitsByLimitId"), + "a sparse rate-limit notification patches present nested fields " + "without clearing full-read metadata"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "account/rateLimits/updated", + std::nullopt, + Value::Object{{"rateLimits", + Value(Value::Object{{"limitName", Value(nullptr)}})}}})); + { + auto read = graph.tryRead(); + const auto state = read->state(rateLimitsNode); + const Value *limitsValue = field(state, "rateLimits"); + const Value::Object *limits = + limitsValue ? limitsValue->asObject() : nullptr; + const Value *limitName = objectField(limits, "limitName"); + const Value *primaryValue = objectField(limits, "primary"); + const Value::Object *primary = + primaryValue ? primaryValue->asObject() : nullptr; + const Value *usedPercent = objectField(primary, "usedPercent"); + require(limitName && limitName->isNull() && usedPercent && + usedPercent->asInt64() && *usedPercent->asInt64() == 40 && + field(state, "rateLimitResetCredits"), + "an explicit null clears only its rate-limit field while omitted " + "current fields remain intact"); + } + + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "account/usage/read", + std::nullopt, Value::Object{{"usage", Value(7)}}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "account/workspaceMessages/read", + std::nullopt, Value::Object{{"messages", Value(Value::Array{})}}})); + { + auto read = graph.tryRead(); + require( + read->find({NodeKind::Account, "account"}) == accountNode && + read->find({NodeKind::Account, "rate-limits"}) == rateLimitsNode && + read->find({NodeKind::Account, "account/usage/read"}) && + read->find({NodeKind::Account, "account/workspaceMessages/read"}), + "usage and workspace-message snapshots remain separate account " + "facets"); + } + + const ProtocolRequestId logoutId("account-logout"); + const ApplyResult logoutRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "account/logout", logoutId, {}}); + static_cast(updater.apply({DecodedMessageKind::ClientResult, + "account/logout", + logoutId, + {}, + logoutRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef current = read->find({NodeKind::Account, "account"}); + const auto state = current ? read->state(current) : nullptr; + require( + current == accountNode && + !read->find({NodeKind::Account, "account/logout"}) && + field(state, "account") && field(state, "account")->isNull() && + field(state, "authMode") && field(state, "authMode")->isNull() && + field(state, "planType") && field(state, "planType")->isNull() && + field(state, "requiresOpenaiAuth") && + field(state, "requiresOpenaiAuth")->asBool() && + !*field(state, "requiresOpenaiAuth")->asBool() && + field(state, "lastMethod") && + field(state, "lastMethod")->asString() && + *field(state, "lastMethod")->asString() == "account/logout", + "logout clears the canonical account facet while preserving the " + "provider authentication requirement"); + } +} + +void successfulRefreshesRetireInvalidationsAndConfigWritesInvalidate() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + static_cast(updater.apply({DecodedMessageKind::ServerNotification, + "skills/changed", + std::nullopt, + {}})); + static_cast(updater.apply({DecodedMessageKind::ServerNotification, + "app/list/updated", + std::nullopt, + {}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "skills/list", std::nullopt, + Value::Object{{"data", Value(Value::Array{Value("skill")})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "app/list", std::nullopt, + Value::Object{{"data", Value(Value::Array{Value("app")})}}})); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "project/changed", std::nullopt, + Value::Object{{"projectId", Value("project-refresh")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "project/read", std::nullopt, + Value::Object{ + {"project", Value(Value::Object{{"id", Value("project-refresh")}, + {"name", Value("Current")}})}}})); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/queue/changed", + std::nullopt, Value::Object{{"threadId", Value("refresh-thread")}}})); + const ProtocolRequestId queueId("queue-refresh"); + const ApplyResult queueRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/queue/list", queueId, + Value::Object{{"threadId", Value("refresh-thread")}}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/queue/list", queueId, + Value::Object{{"data", Value(Value::Array{Value("queued")})}}, + queueRequest.primary})); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/reverted", std::nullopt, + Value::Object{{"threadId", Value("refresh-thread")}}})); + const ProtocolRequestId readId("history-refresh"); + const ApplyResult readRequest = + updater.apply({DecodedMessageKind::ClientRequest, "thread/read", readId, + Value::Object{{"threadId", Value("refresh-thread")}, + {"includeTurns", Value(true)}}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", readId, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("refresh-thread")}, + {"turns", Value(Value::Array{})}})}}, + readRequest.primary})); + + { + auto read = graph.tryRead(); + const auto skills = read->state(read->find({NodeKind::Catalog, "skills"})); + const auto apps = read->state(read->find({NodeKind::Catalog, "app"})); + const auto project = + read->state(read->find({NodeKind::Project, "project-refresh"})); + const auto thread = + read->state(read->find({NodeKind::Thread, "refresh-thread"})); + require(!field(skills, "stale") && !field(skills, "invalidatedBy") && + !field(apps, "stale") && !field(apps, "invalidatedBy") && + !field(project, "stale") && !field(thread, "queueStale") && + !field(thread, "historyStale"), + "successful snapshots retire their matching invalidation facts"); + } + + const Value::Object firstConfig{ + {"config", Value(Value::Object{{"model", Value("old-model")}})}, + {"origins", Value(Value::Object{})}}; + static_cast(updater.apply({DecodedMessageKind::ClientResult, + "config/read", std::nullopt, firstConfig})); + NodeRef configuration; + { + auto read = graph.tryRead(); + configuration = read->find({NodeKind::Configuration, "config/read"}); + } + + const ProtocolRequestId writeId("config-write"); + const ApplyResult writeRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "config/value/write", writeId, + Value::Object{{"keyPath", Value("model")}, + {"value", Value("new-model")}}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "config/value/write", writeId, + Value::Object{{"status", Value("ok")}, {"version", Value("2")}}, + writeRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef current = + read->find({NodeKind::Configuration, "config/read"}); + const auto state = current ? read->state(current) : nullptr; + require(current == configuration && field(state, "stale") && + field(state, "stale")->asBool() && + *field(state, "stale")->asBool() && + field(state, "invalidatedBy") && + field(state, "invalidatedBy")->asString() && + *field(state, "invalidatedBy")->asString() == + "config/value/write" && + !read->find({NodeKind::Configuration, "config/value/write"}), + "config writes invalidate the canonical effective snapshot"); + } + + const Value::Object refreshedConfig{ + {"config", Value(Value::Object{{"model", Value("new-model")}})}, + {"origins", Value(Value::Object{})}}; + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "config/read", + std::nullopt, refreshedConfig})); + { + auto read = graph.tryRead(); + const NodeRef current = + read->find({NodeKind::Configuration, "config/read"}); + const auto state = current ? read->state(current) : nullptr; + const Value *configValue = field(state, "config"); + const Value *model = + objectField(configValue ? configValue->asObject() : nullptr, "model"); + require(current == configuration && !field(state, "stale") && + !field(state, "invalidatedBy") && model && model->asString() && + *model->asString() == "new-model", + "a fresh config read replaces and validates the same singleton"); + } + + const ProtocolRequestId batchId("config-batch-write"); + const ApplyResult batchRequest = + updater.apply({DecodedMessageKind::ClientRequest, "config/batchWrite", + batchId, Value::Object{{"edits", Value(Value::Array{})}}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "config/batchWrite", batchId, + Value::Object{{"status", Value("ok")}, {"version", Value("3")}}, + batchRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef current = + read->find({NodeKind::Configuration, "config/read"}); + const auto state = current ? read->state(current) : nullptr; + require( + current == configuration && field(state, "stale") && + field(state, "stale")->asBool() && + *field(state, "stale")->asBool() && field(state, "invalidatedBy") && + field(state, "invalidatedBy")->asString() && + *field(state, "invalidatedBy")->asString() == "config/batchWrite" && + !read->find({NodeKind::Configuration, "config/batchWrite"}), + "batch config writes invalidate that same effective snapshot"); + } +} + +void catalogResultsMaterializeNaturalEntityKinds() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto result = [&](std::string method, Value::Object payload) { + return updater.apply({DecodedMessageKind::ClientResult, std::move(method), + std::nullopt, std::move(payload)}); + }; + + static_cast(result( + "model/list", + {{"data", Value(Value::Array{ + Value(Value::Object{{"id", Value("model-a")}, + {"displayName", Value("Model A")}}), + Value(Value::Object{{"id", Value("model-b")}, + {"displayName", Value("Model B")}})})}, + {"nextCursor", Value("models-next")}})); + static_cast( + result("permissionProfile/list", + {{"data", Value(Value::Array{Value(Value::Object{ + {"id", Value("trusted")}, + {"description", Value("Trusted profile")}, + {"allowed", Value(true)}})})}})); + static_cast( + result("skills/list", + {{"data", Value(Value::Array{Value(Value::Object{ + {"cwd", Value("/workspace")}, + {"skills", Value(Value::Array{Value(Value::Object{ + {"name", Value("review")}, + {"path", Value("/workspace/review")}, + {"enabled", Value(true)}})})}})})}})); + static_cast( + result("hooks/list", + {{"data", Value(Value::Array{Value(Value::Object{ + {"cwd", Value("/workspace")}, + {"hooks", Value(Value::Array{Value(Value::Object{ + {"key", Value("after-turn")}, + {"eventName", Value("afterAgent")}, + {"enabled", Value(true)}})})}})})}})); + static_cast( + result("plugin/list", + {{"marketplaces", + Value(Value::Array{Value(Value::Object{ + {"name", Value("local")}, + {"plugins", Value(Value::Array{Value(Value::Object{ + {"id", Value("plugin-a")}, + {"name", Value("Plugin A")}})})}})})}})); + static_cast(result( + "app/list", + {{"data", Value(Value::Array{Value(Value::Object{ + {"id", Value("app-a")}, {"name", Value("App A")}})})}})); + static_cast(result("mcpServerStatus/list", + {{"data", Value(Value::Array{Value(Value::Object{ + {"name", Value("server-a")}, + {"status", Value("connected")}})})}})); + + NodeRef retainedModel; + { + auto read = graph.tryRead(); + const NodeRef models = read->find({NodeKind::Catalog, "model"}); + const NodeRef profiles = + read->find({NodeKind::Catalog, "permissionProfile"}); + const NodeRef skills = read->find({NodeKind::Catalog, "skills"}); + const NodeRef hooks = read->find({NodeKind::Catalog, "hooks"}); + const NodeRef plugins = read->find({NodeKind::Catalog, "plugin"}); + const NodeRef apps = read->find({NodeKind::Catalog, "app"}); + const NodeRef servers = read->find({NodeKind::Catalog, "mcpServer"}); + const auto modelChildren = read->children(models); + retainedModel = modelChildren.size() == 2 ? modelChildren[1] : NodeRef{}; + require(models && profiles && skills && hooks && plugins && apps && + servers && modelChildren.size() == 2 && + modelChildren[0]->id().kind == NodeKind::CatalogEntry && + read->children(profiles).size() == 1 && + read->children(profiles)[0]->id().kind == + NodeKind::PermissionProfile && + read->children(skills).size() == 1 && + read->children(skills)[0]->id().kind == NodeKind::Skill && + read->children(hooks).size() == 1 && + read->children(hooks)[0]->id().kind == NodeKind::Hook && + read->children(plugins).size() == 1 && + read->children(plugins)[0]->id().kind == NodeKind::Plugin && + read->children(apps).size() == 1 && + read->children(apps)[0]->id().kind == NodeKind::App && + read->children(servers).size() == 1 && + read->children(servers)[0]->id().kind == NodeKind::McpServer && + field(read->state(models), "nextCursor") && + field(read->state(read->children(skills)[0]), "catalogScope"), + "catalog envelopes retain paging facts while concrete ordered " + "children use every declared natural entity kind"); + } + + static_cast(result( + "model/list", + {{"data", + Value(Value::Array{ + Value(Value::Object{{"id", Value("model-b")}, + {"displayName", Value("Model B2")}}), + Value(Value::Object{{"id", Value("model-c")}, + {"displayName", Value("Model C")}})})}})); + { + auto read = graph.tryRead(); + const NodeRef models = read->find({NodeKind::Catalog, "model"}); + const auto children = read->children(models); + const auto retainedState = + retainedModel ? read->state(retainedModel) : nullptr; + require(children.size() == 2, + "authoritative catalog replacement has two current rows"); + require(!children.empty() && children[0] == retainedModel, + "authoritative catalog replacement preserves stable retained " + "NodeRefs and first-to-last provider order"); + require(field(retainedState, "displayName") && + field(retainedState, "displayName")->asString() && + *field(retainedState, "displayName")->asString() == "Model B2", + "authoritative catalog replacement refreshes retained row state"); + require(!read->find({NodeKind::CatalogEntry, "scope:5:model:7:model-a"}), + "authoritative catalog replacement retires omitted entity rows"); + } +} + +void specializedNotificationFamiliesKeepCurrentSemantics() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto notify = [&](std::string method, Value::Object payload) { + return updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)}); + }; + + static_cast(notify( + "item/started", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"item", Value(Value::Object{{"id", Value("target-1")}, + {"type", Value("commandExecution")}})}})); + static_cast( + notify("item/autoApprovalReview/started", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"targetItemId", Value("target-1")}, + {"reviewId", Value("review-1")}, + {"action", Value(Value::Object{{"type", Value("command")}})}, + {"review", Value(Value::Object{{"status", Value("pending")}})}})); + + NodeRef review; + NodeRef target; + { + auto read = graph.tryRead(); + const NodeRef turn = findTurn(*read, "semantic-notifications", "turn-1"); + review = findItem(*read, "semantic-notifications", "turn-1", "review-1"); + target = findItem(*read, "semantic-notifications", "turn-1", "target-1"); + const auto state = review ? read->state(review) : nullptr; + require(turn && review && target && state && + state->status == NodeStatus::Running && field(state, "type") && + field(state, "type")->asString() && + *field(state, "type")->asString() == "autoApprovalReview" && + read->related(review, RelationKind::ReviewTarget) == + std::vector{target}, + "auto-review start creates one concrete review item related to " + "its exact target item"); + } + static_cast(notify( + "item/autoApprovalReview/completed", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"targetItemId", Value("target-1")}, + {"reviewId", Value("review-1")}, + {"decisionSource", Value("guardian")}, + {"review", Value(Value::Object{{"status", Value("approved")}})}})); + { + auto read = graph.tryRead(); + const NodeRef current = + findItem(*read, "semantic-notifications", "turn-1", "review-1"); + const auto state = current ? read->state(current) : nullptr; + require(current == review && state && + state->status == NodeStatus::Completed && + field(state, "decisionSource") && + read->related(current, RelationKind::ReviewTarget) == + std::vector{target}, + "auto-review completion updates the stable review and preserves " + "its target relation"); + } + + static_cast(notify("item/reasoning/summaryPartAdded", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"itemId", Value("reasoning-1")}, + {"summaryIndex", Value(2)}})); + static_cast(notify("item/reasoning/summaryTextDelta", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"itemId", Value("reasoning-1")}, + {"summaryIndex", Value(2)}, + {"delta", Value("third part")}})); + { + auto read = graph.tryRead(); + const NodeRef reasoning = + findItem(*read, "semantic-notifications", "turn-1", "reasoning-1"); + const auto state = reasoning ? read->state(reasoning) : nullptr; + const Value *summary = field(state, "summary"); + const Value::Array *parts = summary ? summary->asArray() : nullptr; + require(parts && parts->size() == 3 && (*parts)[2].asString() && + *(*parts)[2].asString() == "third part", + "summary part boundaries materialize their indexed empty slot " + "before later text deltas append"); + } + + static_cast(notify("autoApprovalReview/strictReviewRequired", + {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}, + {"startedAtMs", Value(15)}})); + static_cast(notify("thread/environment/connected", + {{"threadId", Value("semantic-notifications")}, + {"environmentId", Value("environment-1")}})); + static_cast(notify("thread/environment/disconnected", + {{"threadId", Value("semantic-notifications")}, + {"environmentId", Value("environment-1")}, + {"reason", Value("closed")}})); + static_cast( + notify("thread/compacted", {{"threadId", Value("semantic-notifications")}, + {"turnId", Value("turn-1")}})); + static_cast( + notify("modelProvider/authRecoveryStarted", + {{"provider", Value("openai")}, {"attempt", Value(2)}})); + static_cast( + notify("modelProvider/authRecoveryCompleted", + {{"provider", Value("openai")}, {"recovered", Value(true)}})); + { + auto read = graph.tryRead(); + const NodeRef thread = + read->find({NodeKind::Thread, "semantic-notifications"}); + const NodeRef turn = findTurn(*read, "semantic-notifications", "turn-1"); + const NodeRef provider = read->find({NodeKind::Catalog, "modelProvider"}); + const auto threadState = thread ? read->state(thread) : nullptr; + const auto turnState = turn ? read->state(turn) : nullptr; + const auto providerState = provider ? read->state(provider) : nullptr; + require(threadState && turnState && providerState && + field(turnState, "strictReviewRequired") && + field(turnState, "strictReviewRequired")->asBool() && + *field(turnState, "strictReviewRequired")->asBool() && + field(threadState, "environmentConnected") && + field(threadState, "environmentConnected")->asBool() && + !*field(threadState, "environmentConnected")->asBool() && + field(threadState, "environmentStatus") && + field(threadState, "compacted") && + field(threadState, "lastCompactedTurnId") && + field(providerState, "authRecoveryActive") && + field(providerState, "authRecoveryActive")->asBool() && + !*field(providerState, "authRecoveryActive")->asBool() && + field(providerState, "recovered") && + providerState->status == NodeStatus::Completed, + "review escalation, environment, compaction, and provider-auth " + "families retain explicit current lifecycle facts"); + } +} + +void reusedWireIdsRequireExactCurrentNodes() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const ProtocolRequestId reused("reused-request"); + + const ApplyResult first = + updater.apply({DecodedMessageKind::ClientRequest, "thread/read", reused, + Value::Object{{"threadId", Value("old-thread")}}}); + const ApplyResult second = + updater.apply({DecodedMessageKind::ClientRequest, "thread/list", reused, + Value::Object{{"limit", Value(10)}}}); + require(first.primary && second.primary && first.primary != second.primary && + std::ranges::find(second.change.removed, first.primary) != + second.change.removed.end(), + "reusing a request id replaces rather than mutates its old " + "Operation NodeRef"); + + const std::uint64_t beforeLate = graph.publishedRevision(); + const ApplyResult late = updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", reused, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("late-thread")}})}}, + first.primary}); + { + auto read = graph.tryRead(); + require(late.change.empty() && late.change.revision == beforeLate && + read->find({NodeKind::Operation, reused.canonical()}) == + second.primary && + !read->find({NodeKind::Thread, "late-thread"}), + "a late result retaining the replaced NodeRef cannot mutate the " + "new operation or graph"); + } + + Value::Array currentThreads{ + Value(Value::Object{{"id", Value("current-thread")}})}; + const ApplyResult current = + updater.apply({DecodedMessageKind::ClientResult, "thread/list", reused, + Value::Object{{"data", Value(std::move(currentThreads))}}, + second.primary}); + { + auto read = graph.tryRead(); + require(read->find({NodeKind::Thread, "current-thread"}) && + !read->find({NodeKind::Operation, reused.canonical()}) && + std::ranges::find(current.change.removed, second.primary) != + current.change.removed.end(), + "the exact current operation accepts its result and is pruned"); + } + + const ProtocolRequestId interactionId("reused-interaction"); + const ApplyResult oldInteraction = updater.apply( + {DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", interactionId, + Value::Object{{"threadId", Value("old-interaction-thread")}}}); + const ApplyResult newInteraction = updater.apply( + {DecodedMessageKind::ServerRequest, "item/fileChange/requestApproval", + interactionId, + Value::Object{{"threadId", Value("new-interaction-thread")}}}); + require(oldInteraction.primary && newInteraction.primary && + oldInteraction.primary != newInteraction.primary && + std::ranges::find(newInteraction.change.removed, + oldInteraction.primary) != + newInteraction.change.removed.end(), + "reusing a server-request id creates a distinct Interaction " + "NodeRef"); + const GraphChange staleResolution = + updater.resolveInteraction(oldInteraction.primary, true); + { + auto read = graph.tryRead(); + require( + staleResolution.empty() && + read->find({NodeKind::Interaction, interactionId.canonical()}) == + newInteraction.primary, + "an exact response for the retired interaction cannot resolve its " + "replacement"); + } + const GraphChange exactResolution = + updater.resolveInteraction(newInteraction.primary, true); + require(std::ranges::find(exactResolution.removed, newInteraction.primary) != + exactResolution.removed.end(), + "the exact current interaction resolves normally"); +} + +void keyedRuntimeNotificationsKeepIndependentCurrentState() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + const auto importResults = [](std::string_view itemType) { + return Value::Array{ + Value(Value::Object{{"itemType", Value(itemType)}, + {"successes", Value(Value::Array{})}, + {"failures", Value(Value::Array{})}})}; + }; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, + "externalAgentConfig/import/progress", std::nullopt, + Value::Object{{"importId", Value("import-a")}, + {"itemTypeResults", Value(importResults("SKILLS"))}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, + "externalAgentConfig/import/progress", std::nullopt, + Value::Object{{"importId", Value("import-b")}, + {"itemTypeResults", Value(importResults("CONFIG"))}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, + "externalAgentConfig/import/completed", std::nullopt, + Value::Object{{"importId", Value("import-a")}, + {"itemTypeResults", Value(importResults("PLUGINS"))}}})); + + const auto fuzzyFiles = [](std::string_view path) { + return Value::Array{ + Value(Value::Object{{"file_name", Value(path)}, + {"match_type", Value("file")}, + {"path", Value(path)}, + {"root", Value("/workspace")}, + {"score", Value(std::uint64_t{1})}})}; + }; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "fuzzyFileSearch/sessionUpdated", + std::nullopt, + Value::Object{{"sessionId", Value("search-a")}, + {"query", Value("alpha")}, + {"files", Value(fuzzyFiles("alpha.cpp"))}}})); + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "fuzzyFileSearch/sessionUpdated", std::nullopt, + Value::Object{{"sessionId", Value("search-b")}, + {"query", Value("beta")}, + {"files", Value(fuzzyFiles("beta.cpp"))}}})); + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "fuzzyFileSearch/sessionCompleted", std::nullopt, + Value::Object{{"sessionId", Value("search-a")}}})); + + static_cast(updater.apply({DecodedMessageKind::ServerNotification, + "account/login/completed", std::nullopt, + Value::Object{{"loginId", Value("login-a")}, + {"success", Value(true)}, + {"error", Value(nullptr)}}})); + static_cast(updater.apply({DecodedMessageKind::ServerNotification, + "account/login/completed", std::nullopt, + Value::Object{{"loginId", Value("login-b")}, + {"success", Value(false)}, + {"error", Value("denied")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "account/login/completed", + std::nullopt, + Value::Object{{"loginId", Value(nullptr)}, + {"success", Value(true)}, + {"error", Value(nullptr)}, + {"onboardingEntrypoint", Value("life_sciences")}}})); + + auto read = graph.tryRead(); + const NodeRef importA = + read->find({NodeKind::ExternalAgentImport, "import-a"}); + const NodeRef importB = + read->find({NodeKind::ExternalAgentImport, "import-b"}); + const auto importAState = importA ? read->state(importA) : nullptr; + const auto importBState = importB ? read->state(importB) : nullptr; + const Value *importAResults = field(importAState, "itemTypeResults"); + const Value *importBResults = field(importBState, "itemTypeResults"); + const auto firstItemType = [](const Value *results) -> std::string { + const Value::Array *items = results ? results->asArray() : nullptr; + const Value::Object *item = + items && !items->empty() ? items->front().asObject() : nullptr; + const auto found = + item ? item->find("itemType") : Value::Object::const_iterator{}; + const Value *type = item && found != item->end() ? &found->second : nullptr; + return type && type->asString() ? *type->asString() : std::string{}; + }; + require(importA && importB && importA != importB && importAState && + importBState && importAState->status == NodeStatus::Completed && + importBState->status == NodeStatus::Running && + firstItemType(importAResults) == "PLUGINS" && + firstItemType(importBResults) == "CONFIG", + "concurrent external-agent imports remain distinct by importId and " + "completion updates only the addressed import"); + + const NodeRef searchA = + findProtocolNode(*read, NodeKind::FuzzyFileSearchSession, "search-a", 0); + const NodeRef searchB = + findProtocolNode(*read, NodeKind::FuzzyFileSearchSession, "search-b", 0); + const auto searchAState = searchA ? read->state(searchA) : nullptr; + const auto searchBState = searchB ? read->state(searchB) : nullptr; + const Value *searchAQuery = field(searchAState, "query"); + const Value *searchBQuery = field(searchBState, "query"); + const Value *searchAFiles = field(searchAState, "files"); + require(searchA && searchB && searchA != searchB && searchAState && + searchBState && searchAState->status == NodeStatus::Completed && + searchBState->status == NodeStatus::Running && searchAQuery && + searchAQuery->asString() && + *searchAQuery->asString() == "alpha" && searchBQuery && + searchBQuery->asString() && *searchBQuery->asString() == "beta" && + searchAFiles && searchAFiles->asArray() && + searchAFiles->asArray()->size() == 1, + "fuzzy-search completion addresses one connection-scoped session " + "and retains its prior query and result snapshot"); + + NodeRef loginA; + NodeRef loginB; + NodeRef nullLogin; + std::size_t loginCount = 0; + for (const NodeRef &node : read->orderedNodes()) { + if (node->id().kind != NodeKind::LoginAttempt) + continue; + ++loginCount; + const auto state = read->state(node); + const Value *protocolId = field(state, "protocolId"); + if (protocolId && protocolId->asString() && + *protocolId->asString() == "login-a") + loginA = node; + else if (protocolId && protocolId->asString() && + *protocolId->asString() == "login-b") + loginB = node; + else if (protocolId && protocolId->isNull()) + nullLogin = node; + } + const auto loginAState = loginA ? read->state(loginA) : nullptr; + const auto loginBState = loginB ? read->state(loginB) : nullptr; + const auto nullLoginState = nullLogin ? read->state(nullLogin) : nullptr; + const Value *nullProtocolId = field(nullLoginState, "protocolId"); + require(loginCount == 3 && loginA && loginB && nullLogin && + loginA != loginB && loginA != nullLogin && loginB != nullLogin && + loginAState->status == NodeStatus::Completed && + loginBState->status == NodeStatus::Failed && + nullLoginState->status == NodeStatus::Completed && + nullProtocolId && nullProtocolId->isNull(), + "login completions retain independent valued loginId attempts and " + "an explicit nullable-loginId attempt without collisions"); +} + +void turnErrorsUpdateAddressedStateWithoutLosingTheNotice() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const Value::Object retryError{ + {"message", Value("provider disconnected")}, + {"additionalDetails", Value("retrying shortly")}, + {"codexErrorInfo", Value("responseStreamDisconnected")}}; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "error", std::nullopt, + Value::Object{{"threadId", Value("error-thread")}, + {"turnId", Value("error-turn")}, + {"error", Value(retryError)}, + {"willRetry", Value(true)}}})); + + { + auto read = graph.tryRead(); + const NodeRef notice = read->find({NodeKind::Notice, "provider-notice"}); + const NodeRef thread = read->find({NodeKind::Thread, "error-thread"}); + const NodeRef turn = findTurn(*read, "error-thread", "error-turn"); + const auto noticeState = notice ? read->state(notice) : nullptr; + const auto threadState = thread ? read->state(thread) : nullptr; + const auto turnState = turn ? read->state(turn) : nullptr; + const Value *threadError = field(threadState, "error"); + const Value *turnError = field(turnState, "error"); + const Value *threadRetry = field(threadState, "willRetry"); + const Value *turnRetry = field(turnState, "willRetry"); + require(notice && noticeState && + noticeState->status == NodeStatus::Failed && thread && turn && + read->parent(turn) == thread && threadError && turnError && + *threadError == Value(retryError) && + *turnError == Value(retryError) && threadRetry && + threadRetry->asBool() && *threadRetry->asBool() && turnRetry && + turnRetry->asBool() && *turnRetry->asBool(), + "error remains a provider notice while atomically updating the " + "addressed thread and scoped turn error/retry facts"); + } + + const Value::Object finalError{{"message", Value("retry exhausted")}}; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "error", std::nullopt, + Value::Object{{"threadId", Value("error-thread")}, + {"turnId", Value("error-turn")}, + {"error", Value(finalError)}, + {"willRetry", Value(false)}}})); + auto read = graph.tryRead(); + const auto threadState = + read->state(read->find({NodeKind::Thread, "error-thread"})); + const auto turnState = + read->state(findTurn(*read, "error-thread", "error-turn")); + const Value *threadRetry = field(threadState, "willRetry"); + const Value *turnRetry = field(turnState, "willRetry"); + require(field(threadState, "error") && + *field(threadState, "error") == Value(finalError) && + field(turnState, "error") && + *field(turnState, "error") == Value(finalError) && threadRetry && + threadRetry->asBool() && !*threadRetry->asBool() && turnRetry && + turnRetry->asBool() && !*turnRetry->asBool(), + "a later terminal turn error replaces current error state and " + "clears willRetry on exactly the same thread and turn nodes"); +} + +void mcpServerNotificationsKeepCanonicalScope() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto apply = [&](std::string method, Value::Object payload) { + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + std::move(method), std::nullopt, std::move(payload)})); + }; + + apply("mcpServer/startupStatus/updated", + {{"name", Value("server-a")}, {"status", Value("ready")}}); + apply("mcpServer/startupStatus/updated", + {{"name", Value("server-b")}, {"status", Value("failed")}}); + apply("mcpServer/oauthLogin/completed", + {{"name", Value("server-a")}, {"success", Value(true)}}); + apply("mcpServer/startupStatus/updated", {{"threadId", Value("thread-one")}, + {"name", Value("server-a")}, + {"status", Value("starting")}}); + apply("mcpServer/event/stream/notification", + {{"subscriptionId", Value("subscription-one")}, + {"notification", Value(Value::Object{{"method", Value("first")}})}}); + apply("mcpServer/event/stream/notification", + {{"subscriptionId", Value("subscription-two")}, + {"notification", Value(Value::Object{{"method", Value("second")}})}}); + + auto read = graph.tryRead(); + std::vector servers; + for (const NodeRef &node : read->orderedNodes()) + if (node->id().kind == NodeKind::McpServer) + servers.push_back(node); + + const auto find = [&](std::string_view protocolId, + std::string_view threadId = {}) { + return std::ranges::find_if(servers, [&](const NodeRef &node) { + const auto state = read->state(node); + const Value *thread = field(state, "threadId"); + const std::string actualThread = + thread && thread->asString() ? *thread->asString() : std::string{}; + return protocolCanonicalId(*state, node) == protocolId && + actualThread == threadId; + }); + }; + const auto globalA = find("server-a"); + const auto globalB = find("server-b"); + const auto threadA = find("server-a", "thread-one"); + const auto subscriptionOne = find("subscription-one"); + const auto subscriptionTwo = find("subscription-two"); + require(servers.size() == 5 && globalA != servers.end() && + globalB != servers.end() && threadA != servers.end() && + subscriptionOne != servers.end() && + subscriptionTwo != servers.end(), + "MCP server and subscription notifications retain distinct " + "canonical scopes"); + if (globalA != servers.end()) { + const auto state = read->state(*globalA); + require(field(state, "success") && field(state, "success")->asBool() && + *field(state, "success")->asBool(), + "OAuth completion updates the matching global MCP server"); + } + if (globalB != servers.end()) { + const auto state = read->state(*globalB); + require(field(state, "status") && field(state, "status")->asString() && + *field(state, "status")->asString() == "failed", + "one MCP server update cannot overwrite another server"); + } +} + +void interactionsAndRemovalKeepLifetime() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const ProtocolRequestId interactionId("approval-1"); + ApplyResult request = + updater.apply({DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", interactionId, + Value::Object{{"threadId", Value("thread-approval")}, + {"turnId", Value("turn-approval")}, + {"itemId", Value("item-approval")}}}); + require(request.disposition == MessageDisposition::ReverseInteraction, + "approval is a reverse interaction"); + + NodeRef interaction; + { + auto read = graph.tryRead(); + interaction = + read->find({NodeKind::Interaction, interactionId.canonical()}); + require(interaction && + read->state(interaction)->status == NodeStatus::Pending, + "reverse request creates a pending interaction node"); + const auto targets = + read->related(interaction, RelationKind::InteractionTarget); + require(targets.size() == 1 && + protocolCanonicalId(*read->state(targets.front()), + targets.front()) == "item-approval", + "interaction directly relates to its addressed item"); + const NodeRef turn = findTurn(*read, "thread-approval", "turn-approval"); + const NodeRef thread = read->find({NodeKind::Thread, "thread-approval"}); + require(turn && thread && read->parent(targets.front()) == turn && + read->parent(turn) == thread, + "interaction targets retain their addressed containment chain"); + require( + read->related(thread, RelationKind::PendingInteraction) == + std::vector{interaction} && + field(read->state(thread), "pendingInteractionCount") && + field(read->state(thread), "pendingInteractionCount")->asUInt64() && + *field(read->state(thread), "pendingInteractionCount") + ->asUInt64() == 1, + "the addressed thread directly indexes and counts its pending " + "interaction"); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + require(runtime && + read->related(runtime, RelationKind::PendingInteraction) == + std::vector{interaction}, + "runtime directly indexes the complete pending interaction set"); + } + + GraphChange rejected = + updater.resolveInteraction(interactionId, false, "bridge rejected"); + require(!rejected.empty(), "rejected response updates interaction state"); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "thread-approval"}); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + require(read->state(interaction)->status == NodeStatus::Failed, + "rejected response remains visibly failed"); + require( + thread && + read->related(thread, RelationKind::PendingInteraction) == + std::vector{interaction} && + field(read->state(thread), "pendingInteractionCount") && + field(read->state(thread), "pendingInteractionCount")->asUInt64() && + *field(read->state(thread), "pendingInteractionCount") + ->asUInt64() == 1, + "a rejected response remains counted as unresolved thread attention"); + require(runtime && + read->related(runtime, RelationKind::PendingInteraction) == + std::vector{interaction}, + "a rejected response remains in the runtime interaction set"); + } + + GraphChange accepted = updater.resolveInteraction(interactionId, true); + require(accepted.removed.size() == 1 && + accepted.removed.front() == interaction, + "accepted response removes and notifies with the same NodeRef"); + { + auto read = graph.tryRead(); + require(!read->find({NodeKind::Interaction, interactionId.canonical()}), + "resolved interaction leaves canonical indexes"); + require(read->retiredNodes().size() == 1, + "removed interaction stays reachable for Qt detachment"); + const NodeRef thread = read->find({NodeKind::Thread, "thread-approval"}); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + require( + thread && runtime && + read->related(thread, RelationKind::PendingInteraction).empty() && + read->related(runtime, RelationKind::PendingInteraction).empty() && + field(read->state(thread), "pendingInteractionCount") && + field(read->state(thread), "pendingInteractionCount")->asUInt64() && + *field(read->state(thread), "pendingInteractionCount") + ->asUInt64() == 0, + "interaction removal unlinks both direct pending indexes and keeps " + "the derived count current"); + } + + const ProtocolRequestId externallyResolvedId("approval-externally-resolved"); + static_cast( + updater.apply({DecodedMessageKind::ServerRequest, + "item/fileChange/requestApproval", externallyResolvedId, + Value::Object{{"threadId", Value("thread-approval")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "serverRequest/resolved", + std::nullopt, + Value::Object{{"requestId", Value("approval-externally-resolved")}}})); + { + auto read = graph.tryRead(); + const NodeRef thread = read->find({NodeKind::Thread, "thread-approval"}); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const Value *count = field(read->state(thread), "pendingInteractionCount"); + require( + thread && runtime && count && count->asUInt64() && + *count->asUInt64() == 0 && + read->related(thread, RelationKind::PendingInteraction).empty() && + read->related(runtime, RelationKind::PendingInteraction).empty(), + "provider-side request resolution clears the same indexes and " + "cached thread attention count"); + } +} + +void unknownAndNeutralAreIsolated() { + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("known-thread")}, + {"name", Value("Known")}})}}})); + NodeRef known; + std::shared_ptr before; + { + auto read = graph.tryRead(); + known = read->find({NodeKind::Thread, "known-thread"}); + before = read->state(known); + } + + const std::uint64_t beforeNeutral = graph.publishedRevision(); + ApplyResult neutral = updater.apply( + {DecodedMessageKind::ServerNotification, "rawResponse/completed", + std::nullopt, Value::Object{{"secret", Value("not retained")}}}); + require(neutral.knownMethod && neutral.change.empty() && + graph.publishedRevision() == beforeNeutral, + "known raw-response event is explicitly state-neutral"); + + ApplyResult unknown = + updater.apply({DecodedMessageKind::ServerNotification, + "future/newAlternative", std::nullopt, + Value::Object{{"threadId", Value("known-thread")}, + {"newData", Value(7)}}}); + require(!unknown.knownMethod && !unknown.change.empty(), + "unknown alternative is retained in its own changed node"); + { + auto read = graph.tryRead(); + require(read->state(known) == before, + "unknown alternative cannot corrupt addressed known state"); + bool foundUnknown = false; + for (const NodeRef &node : read->orderedNodes()) { + if (node->id().kind == NodeKind::UnknownProtocol) + foundUnknown = true; + } + require(foundUnknown, "unknown alternative has a discoverable node"); + } + + static_cast( + updater.apply({DecodedMessageKind::ServerNotification, + "future/newAlternative", std::nullopt, + Value::Object{{"threadId", Value("known-thread")}, + {"future", Value("latest")}}})); + { + auto read = graph.tryRead(); + std::size_t unknownCount = 0; + for (const NodeRef &node : read->orderedNodes()) + unknownCount += node->id().kind == NodeKind::UnknownProtocol ? 1U : 0U; + require(unknownCount == 1 && read->state(known) == before, + "repeated unknown alternatives replace current fallback state " + "without creating a journal or mutating known state"); + } +} + +void lifecycleFactsAndRemovalPreserveThreadHierarchy() { + NodeGraph graph; + ProtocolUpdater updater(graph); + Value::Array threads{ + Value(Value::Object{{"id", Value("lifecycle-parent")}}), + Value(Value::Object{{"id", Value("lifecycle-child")}, + {"parentThreadId", Value("lifecycle-parent")}}), + Value(Value::Object{{"id", Value("lifecycle-grandchild")}, + {"parentThreadId", Value("lifecycle-child")}})}; + static_cast( + updater.apply({DecodedMessageKind::ClientResult, "thread/list", + ProtocolRequestId("lifecycle-list"), + Value::Object{{"data", Value(std::move(threads))}}})); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/archived", std::nullopt, + Value::Object{{"threadId", Value("lifecycle-parent")}}})); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef parent = read->find({NodeKind::Thread, "lifecycle-parent"}); + const auto state = read->state(parent); + const Value *archived = field(state, "archived"); + require( + archived && archived->asBool() && *archived->asBool() && + canonicalIds(read->related(runtime, RelationKind::RootThread)) == + std::vector{"lifecycle-parent"}, + "archive is retained as a lifecycle fact without deleting the " + "visible thread"); + } + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/closed", std::nullopt, + Value::Object{{"threadId", Value("lifecycle-parent")}}})); + { + auto read = graph.tryRead(); + const NodeRef parent = read->find({NodeKind::Thread, "lifecycle-parent"}); + require(parent && read->state(parent)->status == NodeStatus::NotLoaded, + "thread/closed marks provider loading state without removal"); + } + + const ApplyResult deleted = updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("lifecycle-parent")}}}); + require(deleted.change.removed.size() == 1, + "deleting a thread does not delete independently retained child " + "threads"); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef child = read->find({NodeKind::Thread, "lifecycle-child"}); + const NodeRef grandchild = + read->find({NodeKind::Thread, "lifecycle-grandchild"}); + require(canonicalIds(read->related(runtime, RelationKind::RootThread)) == + std::vector{"lifecycle-child"} && + child && grandchild && + read->related(child, RelationKind::StructuralChildThread) == + std::vector{grandchild}, + "parent removal promotes direct children and preserves their " + "descendant hierarchy"); + } +} + +void childReadCannotEraseSpawnOwnershipOrDisplaceRoot() { + NodeGraph graph; + ProtocolUpdater updater(graph); + for (const std::string_view id : {"selected-root", "selected-child"}) + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", + std::nullopt, + Value::Object{{"thread", Value(Value::Object{{"id", Value(id)}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{ + {"threadId", Value("selected-root")}, + {"turnId", Value("selected-root-turn")}, + {"item", Value(Value::Object{ + {"id", Value("selected-spawn")}, + {"type", Value("subAgentActivity")}, + {"agentThreadId", Value("selected-child")}})}}})); + + const ProtocolRequestId readId("selected-child-read"); + const ApplyResult request = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", readId, + Value::Object{{"threadId", Value("selected-child")}}}); + Value::Object childSnapshot{ + {"id", Value("selected-child")}, + {"parentThreadId", Value(nullptr)}, + {"turns", Value(Value::Array{})}}; + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", readId, + Value::Object{{"thread", Value(std::move(childSnapshot))}}, + request.primary})); + + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef root = read->find({NodeKind::Thread, "selected-root"}); + const NodeRef child = read->find({NodeKind::Thread, "selected-child"}); + require(runtime && root && child && + read->related(runtime, RelationKind::RootThread) == + std::vector{root} && + read->related(root, RelationKind::AgentChildThread) == + std::vector{child} && + read->related(child, RelationKind::ThreadOwner) == + std::vector{root}, + "hydrating a selected spawned child cannot erase its agent owner, " + "promote it to root, or displace the visible root thread"); +} + +void deletionUnlinksWholeGraph() { + NodeGraph graph; + ProtocolUpdater updater(graph); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("delete-thread")}, + {"turn", Value(Value::Object{{"id", Value("delete-turn")}})}}})); + NodeRef removed; + NodeRef removedTurn; + { + auto read = graph.tryRead(); + removed = read->find({NodeKind::Thread, "delete-thread"}); + removedTurn = findTurn(*read, "delete-thread", "delete-turn"); + } + ApplyResult result = updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("delete-thread")}}}); + require( + result.change.removed.size() == 2 && + std::find(result.change.removed.begin(), result.change.removed.end(), + removed) != result.change.removed.end() && + std::find(result.change.removed.begin(), result.change.removed.end(), + removedTurn) != result.change.removed.end(), + "thread deletion queues stable references for its whole contained " + "lifecycle"); + { + auto read = graph.tryRead(); + require(read->removed(removed), "removed node is marked removed"); + require(read->children(removed).empty(), + "removed thread is unlinked from child turns"); + NodeRef turn = findTurn(*read, "delete-thread", "delete-turn"); + require(!turn && read->removed(removedTurn), + "contained turns are removed rather than retained as orphans"); + } +} + +void largeThreadDeletionIsNearLinear() { + const auto measure = [](std::size_t itemCount) { + NodeGraph graph; + ProtocolUpdater updater(graph); + { + auto write = graph.write(); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + NodeRef thread = write.upsert({NodeKind::Thread, "bulk-delete-thread"}); + NodeRef turn = write.upsert( + scopedTurnNodeId("bulk-delete-thread", "bulk-delete-turn")); + write.setParent(thread, turn); + write.relate(runtime, RelationKind::RootThread, thread); + for (std::size_t index = 0; index < itemCount; ++index) { + NodeState state; + state.fields = {{"type", Value("agentMessage")}, + {"protocolId", Value(std::to_string(index))}}; + NodeRef item = + write.upsert(scopedItemNodeId(turn->id(), std::to_string(index)), + std::move(state)); + write.setParent(turn, item); + } + static_cast(write.finish()); + } + + const auto started = std::chrono::steady_clock::now(); + const ApplyResult result = updater.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("bulk-delete-thread")}}}); + const auto elapsed = std::chrono::steady_clock::now() - started; + auto read = graph.tryRead(); + const bool valid = result.change.removed.size() == itemCount + 2 && read && + !read->find({NodeKind::Thread, "bulk-delete-thread"}) && + read->retiredCount() == itemCount + 2; + return std::pair{valid, elapsed}; + }; + + const auto [smallValid, smallElapsed] = measure(1500); + const auto [largeValid, largeElapsed] = measure(3000); + require(smallValid && largeValid && + largeElapsed <= smallElapsed * 3 + std::chrono::milliseconds(25), + "large canonical thread deletion scales approximately linearly"); + std::cout + << "thread-delete ns (1500 / 3000 items): " + << std::chrono::duration_cast(smallElapsed) + .count() + << " / " + << std::chrono::duration_cast(largeElapsed) + .count() + << '\n'; +} + +void correlatedThreadReadsPreserveOnlyInterveningLiveState() { + NodeGraph graph; + ProtocolUpdater updater(graph); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("merging-read-thread")}, + {"cwd", Value("/old/cwd")}})}}})); + + const ProtocolRequestId mergingReadId("merging-thread-read"); + const ApplyResult mergingRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", mergingReadId, + Value::Object{{"threadId", Value("merging-read-thread")}}}); + require(static_cast(mergingRequest.primary), + "correlated merging read exposes its exact operation"); + + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("merging-read-thread")}, + {"turn", Value(Value::Object{{"id", Value("live-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/plan/delta", std::nullopt, + Value::Object{{"threadId", Value("merging-read-thread")}, + {"turnId", Value("live-turn")}, + {"itemId", Value("live-plan")}, + {"delta", Value("live plan text")}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/name/updated", + std::nullopt, + Value::Object{{"threadId", Value("merging-read-thread")}, + {"threadName", Value("Live thread name")}}})); + Value::Array snapshotItems{Value(Value::Object{ + {"id", Value("snapshot-item")}, {"type", Value("agentMessage")}})}; + Value::Object snapshotTurn{{"id", Value("snapshot-turn")}, + {"items", Value(std::move(snapshotItems))}}; + Value::Array staleLiveItems{ + Value(Value::Object{{"id", Value("live-plan")}, + {"type", Value("plan")}, + {"text", Value("stale snapshot text")}})}; + Value::Object staleLiveTurn{{"id", Value("live-turn")}, + {"status", Value("completed")}, + {"items", Value(std::move(staleLiveItems))}}; + Value::Array snapshotTurns{Value(std::move(snapshotTurn)), + Value(std::move(staleLiveTurn))}; + Value::Object mergingThread{{"id", Value("merging-read-thread")}, + {"name", Value("Stale snapshot name")}, + {"cwd", Value("/authoritative/cwd")}, + {"turns", Value(std::move(snapshotTurns))}}; + DecodedMessage mergingResult{ + DecodedMessageKind::ClientResult, "thread/read", mergingReadId, + Value::Object{{"thread", Value(std::move(mergingThread))}}, + mergingRequest.primary}; + const ApplyResult merged = updater.apply(std::move(mergingResult)); + { + auto read = graph.tryRead(); + const NodeRef thread = + read->find({NodeKind::Thread, "merging-read-thread"}); + const NodeRef liveTurn = + findTurn(*read, "merging-read-thread", "live-turn"); + const NodeRef livePlan = + findItem(*read, "merging-read-thread", "live-turn", "live-plan"); + const NodeRef snapshotTurn = + findTurn(*read, "merging-read-thread", "snapshot-turn"); + const Value *text = field(read->state(livePlan), "text"); + const Value *name = field(read->state(thread), "name"); + const Value *cwd = field(read->state(thread), "cwd"); + const Value *type = field(read->state(livePlan), "type"); + require(!read->find(mergingRequest.primary->id()) && thread && liveTurn && + livePlan && snapshotTurn && + protocolIds(*read, read->children(thread)) == + std::vector{"snapshot-turn", "live-turn"} && + read->children(liveTurn) == std::vector{livePlan} && + text && text->asString() && + *text->asString() == "live plan text" && type && + type->asString() && *type->asString() == "plan" && + read->state(liveTurn)->status == NodeStatus::Running && name && + name->asString() && *name->asString() == "Live thread name" && + cwd && cwd->asString() && + *cwd->asString() == "/authoritative/cwd" && + std::ranges::find(merged.change.affected, livePlan) != + merged.change.affected.end() && + read->changedRevision(livePlan) == merged.change.revision, + "an intervening live plan mutation preserves newer text while " + "hydrating its missing authoritative type without replacing newer " + "top-level or turn state"); + } + + NodeGraph replacingGraph; + ProtocolUpdater replacingUpdater(replacingGraph); + static_cast(replacingUpdater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{ + {"threadId", Value("replacing-read-thread")}, + {"turnId", Value("old-turn")}, + {"item", Value(Value::Object{{"id", Value("old-item")}, + {"type", Value("agentMessage")}})}}})); + NodeRef replacedTurn; + NodeRef replacedItem; + { + auto read = replacingGraph.tryRead(); + replacedTurn = findTurn(*read, "replacing-read-thread", "old-turn"); + replacedItem = + findItem(*read, "replacing-read-thread", "old-turn", "old-item"); + } + const ProtocolRequestId replacingReadId("replacing-thread-read"); + const ApplyResult replacingRequest = replacingUpdater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", replacingReadId, + Value::Object{{"threadId", Value("replacing-read-thread")}}}); + require(static_cast(replacingRequest.primary), + "correlated replacing read exposes its exact operation"); + static_cast(replacingUpdater.apply( + {DecodedMessageKind::ServerNotification, "thread/name/updated", + std::nullopt, + Value::Object{{"threadId", Value("unrelated-thread")}, + {"threadName", Value("Unrelated change")}}})); + Value::Array freshItems{Value(Value::Object{ + {"id", Value("fresh-item")}, {"type", Value("agentMessage")}})}; + Value::Object freshTurn{{"id", Value("fresh-turn")}, + {"items", Value(std::move(freshItems))}}; + Value::Array freshTurns{Value(std::move(freshTurn))}; + Value::Object replacingThread{{"id", Value("replacing-read-thread")}, + {"name", Value("Fresh snapshot name")}, + {"turns", Value(std::move(freshTurns))}}; + DecodedMessage replacingResult{ + DecodedMessageKind::ClientResult, "thread/read", replacingReadId, + Value::Object{{"thread", Value(std::move(replacingThread))}}, + replacingRequest.primary}; + const ApplyResult replaced = + replacingUpdater.apply(std::move(replacingResult)); + { + auto read = replacingGraph.tryRead(); + const NodeRef thread = + read->find({NodeKind::Thread, "replacing-read-thread"}); + const NodeRef freshTurn = + findTurn(*read, "replacing-read-thread", "fresh-turn"); + const Value *name = field(read->state(thread), "name"); + require(thread && freshTurn && replacedTurn && replacedItem && + read->children(thread) == std::vector{freshTurn} && + !findTurn(*read, "replacing-read-thread", "old-turn") && + !read->find(replacedItem->id()) && + read->removed(replacedTurn) && read->removed(replacedItem) && + std::ranges::find(replaced.change.removed, replacedTurn) != + replaced.change.removed.end() && + name && name->asString() && + *name->asString() == "Fresh snapshot name", + "an unrelated thread mutation does not prevent the correlated " + "read from retiring omitted addressed-thread history"); + } + + NodeGraph omittedDeltaGraph; + ProtocolUpdater omittedDeltaUpdater(omittedDeltaGraph); + static_cast(omittedDeltaUpdater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("omitted-delta")}})}}})); + const ProtocolRequestId omittedDeltaReadId("omitted-delta-read"); + const ApplyResult omittedDeltaRequest = omittedDeltaUpdater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", omittedDeltaReadId, + Value::Object{{"threadId", Value("omitted-delta")}}}); + static_cast(omittedDeltaUpdater.apply( + {DecodedMessageKind::ServerNotification, "item/plan/delta", std::nullopt, + Value::Object{{"threadId", Value("omitted-delta")}, + {"turnId", Value("omitted-delta-turn")}, + {"itemId", Value("omitted-delta-item")}, + {"delta", Value("post-request delta")}}})); + NodeRef omittedDeltaTurn; + NodeRef omittedDeltaItem; + { + auto read = omittedDeltaGraph.tryRead(); + omittedDeltaTurn = + findTurn(*read, "omitted-delta", "omitted-delta-turn"); + omittedDeltaItem = findItem(*read, "omitted-delta", "omitted-delta-turn", + "omitted-delta-item"); + } + const ApplyResult omittedDeltaResult = omittedDeltaUpdater.apply( + {DecodedMessageKind::ClientResult, + "thread/read", + omittedDeltaReadId, + Value::Object{{"thread", + Value(Value::Object{ + {"id", Value("omitted-delta")}, + {"turns", Value(Value::Array{})}})}}, + omittedDeltaRequest.primary}); + { + auto read = omittedDeltaGraph.tryRead(); + require(omittedDeltaTurn && omittedDeltaItem && + !read->find(omittedDeltaTurn->id()) && + !read->find(omittedDeltaItem->id()) && + read->removed(omittedDeltaTurn) && + read->removed(omittedDeltaItem) && + std::ranges::find(omittedDeltaResult.change.removed, + omittedDeltaItem) != + omittedDeltaResult.change.removed.end(), + "an authoritative replacement retires an omitted provider delta " + "created after the request instead of retaining a detached " + "untyped node"); + } +} + +void authoritativeReplacementRetiresItemsAndPreservesLocalTail() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto item = [](std::string id) { + return Value(Value::Object{{"id", Value(std::move(id))}, + {"type", Value("agentMessage")}}); + }; + const auto turn = [&](std::string id, Value::Array items) { + return Value(Value::Object{{"id", Value(std::move(id))}, + {"items", Value(std::move(items))}}); + }; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("replace-membership")}, + {"turns", + Value(Value::Array{ + turn("retained-turn", + {item("retained-item"), item("omitted-item")}), + turn("omitted-turn", {item("omitted-child")})})}})}}})); + + NodeRef omittedItem; + NodeRef omittedTurn; + NodeRef omittedChild; + NodeRef localTurn; + NodeRef localPrompt; + { + auto write = graph.write(); + const NodeRef threadNode = + write.find({NodeKind::Thread, "replace-membership"}); + omittedItem = write.find(scopedItemNodeId( + scopedTurnNodeId("replace-membership", "retained-turn"), + "omitted-item")); + omittedTurn = + write.find(scopedTurnNodeId("replace-membership", "omitted-turn")); + omittedChild = write.find( + scopedItemNodeId(scopedTurnNodeId("replace-membership", "omitted-turn"), + "omitted-child")); + NodeState localTurnState; + localTurnState.status = NodeStatus::Pending; + localTurnState.fields = {{"type", Value("localTurn")}, + {"local", Value(true)}}; + localTurn = write.upsert({NodeKind::Turn, "local-turn:protected"}, + std::move(localTurnState)); + NodeState localPromptState; + localPromptState.status = NodeStatus::Pending; + localPromptState.fields = {{"type", Value("localPrompt")}, + {"local", Value(true)}, + {"text", Value("authored locally")}}; + localPrompt = write.upsert({NodeKind::Item, "local-prompt:protected"}, + std::move(localPromptState)); + write.setParent(threadNode, localTurn); + write.setParent(localTurn, localPrompt); + static_cast(write.finish()); + } + + const ProtocolRequestId requestId("replace-membership-read"); + const ApplyResult request = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/read", requestId, + Value::Object{{"threadId", Value("replace-membership")}}}); + const ApplyResult replacement = updater.apply( + {DecodedMessageKind::ClientResult, "thread/read", requestId, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("replace-membership")}, + {"turns", Value(Value::Array{turn( + "retained-turn", {item("retained-item")})})}})}}, + request.primary}); + + auto read = graph.tryRead(); + const NodeRef threadNode = + read->find({NodeKind::Thread, "replace-membership"}); + const NodeRef retainedTurn = + findTurn(*read, "replace-membership", "retained-turn"); + require(threadNode && retainedTurn && omittedItem && omittedTurn && + omittedChild && !read->find(omittedItem->id()) && + !read->find(omittedTurn->id()) && + !read->find(omittedChild->id()) && read->removed(omittedItem) && + read->removed(omittedTurn) && read->removed(omittedChild) && + std::ranges::find(replacement.change.removed, omittedItem) != + replacement.change.removed.end(), + "authoritative replacement retires omitted provider items and whole " + "provider turns instead of leaving resolvable orphans"); + require(read->find(localTurn->id()) == localTurn && + read->find(localPrompt->id()) == localPrompt && + read->parent(localTurn) == threadNode && + read->parent(localPrompt) == localTurn && + read->children(threadNode) == + std::vector{retainedTurn, localTurn}, + "authoritative replacement preserves the exact explicit local " + "optimistic tail and its stable NodeRefs"); +} + +void rollbackAndRevertReplaceAuthoritativeHistory() { + NodeGraph graph; + ProtocolUpdater updater(graph); + const auto turn = [](std::string id, std::string itemId) { + return Value( + Value::Object{{"id", Value(std::move(id))}, + {"status", Value("completed")}, + {"items", Value(Value::Array{Value(Value::Object{ + {"id", Value(std::move(itemId))}, + {"type", Value("agentMessage")}})})}}); + }; + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("history-mutation")}, + {"turns", Value(Value::Array{turn("turn-1", "item-1"), + turn("turn-2", "item-2"), + turn("turn-3", "item-3")})}})}}})); + static_cast(updater.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{ + {"threadId", Value("history-mutation")}, + {"turnId", Value("turn-2")}, + {"item", Value(Value::Object{{"id", Value("item-2-omitted")}, + {"type", Value("agentMessage")}})}}})); + NodeRef removedTurn; + NodeRef removedItem; + NodeRef removedRetainedTurnItem; + { + auto read = graph.tryRead(); + removedTurn = findTurn(*read, "history-mutation", "turn-3"); + removedItem = findItem(*read, "history-mutation", "turn-3", "item-3"); + removedRetainedTurnItem = + findItem(*read, "history-mutation", "turn-2", "item-2-omitted"); + } + + const ProtocolRequestId rollbackId("rollback-history"); + const ApplyResult rollbackRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/rollback", rollbackId, + Value::Object{{"threadId", Value("history-mutation")}, + {"numTurns", Value(std::uint64_t{1})}}}); + const ApplyResult rolledBack = updater.apply( + {DecodedMessageKind::ClientResult, "thread/rollback", rollbackId, + Value::Object{ + {"thread", + Value(Value::Object{ + {"id", Value("history-mutation")}, + {"turns", Value(Value::Array{turn("turn-1", "item-1"), + turn("turn-2", "item-2")})}})}}, + rollbackRequest.primary}); + NodeRef firstTurn; + NodeRef secondTurn; + { + auto read = graph.tryRead(); + const NodeRef threadNode = + read->find({NodeKind::Thread, "history-mutation"}); + firstTurn = findTurn(*read, "history-mutation", "turn-1"); + secondTurn = findTurn(*read, "history-mutation", "turn-2"); + require(threadNode && firstTurn && secondTurn && + protocolIds(*read, read->children(threadNode)) == + std::vector{"turn-1", "turn-2"} && + !read->find(removedTurn->id()) && + !read->find(removedItem->id()) && + !read->find(removedRetainedTurnItem->id()) && + read->removed(removedTurn) && read->removed(removedItem) && + read->removed(removedRetainedTurnItem) && + std::ranges::find(rolledBack.change.removed, removedTurn) != + rolledBack.change.removed.end() && + std::ranges::find(rolledBack.change.removed, + removedRetainedTurnItem) != + rolledBack.change.removed.end(), + "successful rollback replaces returned history and retires every " + "superseded turn and item"); + } + + const ProtocolRequestId revertId("revert-history"); + const ApplyResult revertRequest = updater.apply( + {DecodedMessageKind::ClientRequest, "thread/revert", revertId, + Value::Object{{"threadId", Value("history-mutation")}, + {"beforeTurnId", Value("turn-2")}}}); + static_cast(updater.apply( + {DecodedMessageKind::ClientResult, "thread/revert", revertId, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("history-mutation")}, + {"turns", Value(Value::Array{})}})}, + {"turnsBackwardsCursor", Value("retained-prefix-cursor")}, + {"itemsBackwardsCursor", Value("retained-items-cursor")}}, + revertRequest.primary})); + { + auto read = graph.tryRead(); + const NodeRef threadNode = + read->find({NodeKind::Thread, "history-mutation"}); + const auto state = threadNode ? read->state(threadNode) : nullptr; + require(threadNode && read->children(threadNode).empty() && + !read->find(firstTurn->id()) && !read->find(secondTurn->id()) && + read->removed(firstTurn) && read->removed(secondTurn) && + field(state, "historyHasMore") && + field(state, "historyHasMore")->asBool() && + *field(state, "historyHasMore")->asBool() && + field(state, "historyNextCursor") && + field(state, "historyNextCursor")->asString() && + *field(state, "historyNextCursor")->asString() == + "retained-prefix-cursor" && + field(state, "itemsHistoryBackwardsCursor") && + field(state, "itemsHistoryBackwardsCursor")->asString() && + *field(state, "itemsHistoryBackwardsCursor")->asString() == + "retained-items-cursor", + "successful paginated revert unlinks cached history and exposes " + "only authoritative reload cursors"); + } +} + +} // namespace + +int main() { + catalogIsComplete(); + everyKnownMethodDispatches(); + nestedEntitiesAndStreamsStayCurrent(); + streamedTextIsBoundedAndReportsOmission(); + longStreamingDeltasStayBoundedInStateAndCost(); + activeTurnRelationTracksLifecycle(); + effectiveThreadSettingsConvergeAcrossWireShapes(); + threadItemPagesMaintainScopedContainmentAndOrder(); + scopedProviderIdentityCannotCrossParents(); + rootOrderAndThreadHierarchyAreExplicit(); + agentChildAggregatesTrackEveryReferencingItem(); + forkRelationsFollowTheCurrentSource(); + semanticDeltasAndHydratedOrderStayCurrent(); + realtimeNotificationsMaintainOneCurrentSession(); + hookRunsKeepNestedIdentityAndCurrentOwnership(); + graphRelationsInvalidationAndIncarnationsAreExplicit(); + promptMaterializationDoesNotAcknowledgeDelivery(); + steeringMaterializationKeepsTheSubmittedSlot(); + turnRootsAndPagedHistoryStayExplicit(); + resultsAndListsCorrelate(); + lateResultsCannotRecreateDeletedTargets(); + exactRequestTargetsOverridePayloadAddressingAndLifetime(); + accountFacetsConvergeAndRateLimitPatchesStaySparse(); + successfulRefreshesRetireInvalidationsAndConfigWritesInvalidate(); + catalogResultsMaterializeNaturalEntityKinds(); + specializedNotificationFamiliesKeepCurrentSemantics(); + reusedWireIdsRequireExactCurrentNodes(); + keyedRuntimeNotificationsKeepIndependentCurrentState(); + turnErrorsUpdateAddressedStateWithoutLosingTheNotice(); + mcpServerNotificationsKeepCanonicalScope(); + interactionsAndRemovalKeepLifetime(); + unknownAndNeutralAreIsolated(); + lifecycleFactsAndRemovalPreserveThreadHierarchy(); + childReadCannotEraseSpawnOwnershipOrDisplaceRoot(); + deletionUnlinksWholeGraph(); + largeThreadDeletionIsNearLinear(); + correlatedThreadReadsPreserveOnlyInterveningLiveState(); + authoritativeReplacementRetiresItemsAndPreservesLocalTail(); + rollbackAndRevertReplaceAuthoritativeHistory(); + + if (failures != 0) { + std::cerr << failures << " nodegraph protocol assertion(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "codexui protocol updater tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/tests/codex/nodegraph/ThreadChannelsTest.cpp b/tests/codex/nodegraph/ThreadChannelsTest.cpp new file mode 100644 index 0000000..b214c84 --- /dev/null +++ b/tests/codex/nodegraph/ThreadChannelsTest.cpp @@ -0,0 +1,650 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/ThreadChannels.h" +#include "codex/nodegraph/EventFd.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using codexui::nodegraph::Attachment; +using codexui::nodegraph::ChannelSendStatus; +using codexui::nodegraph::deliveryGuaranteed; +using codexui::nodegraph::EventFd; +using codexui::nodegraph::GraphChange; +using codexui::nodegraph::GraphChanged; +using codexui::nodegraph::messageAdmitted; +using codexui::nodegraph::NodeAction; +using codexui::nodegraph::NodeActionKind; +using codexui::nodegraph::NodeGraph; +using codexui::nodegraph::NodeId; +using codexui::nodegraph::NodeKind; +using codexui::nodegraph::NodeRef; +using codexui::nodegraph::NodeState; +using codexui::nodegraph::NodeStatus; +using codexui::nodegraph::QtToWorkerMessage; +using codexui::nodegraph::RuntimeAction; +using codexui::nodegraph::RuntimeActionKind; +using codexui::nodegraph::ShutdownRequest; +using codexui::nodegraph::ThreadChannels; +using codexui::nodegraph::UiEffect; +using codexui::nodegraph::UiEffectKind; +using codexui::nodegraph::Value; +using codexui::nodegraph::wakeFailed; +using codexui::nodegraph::WorkerStopped; +using codexui::nodegraph::WorkerToQtMessage; + +bool expect(bool condition, std::string_view message) { + std::cout << (condition ? "PASS " : "FAIL ") << message << '\n'; + return condition; +} + +NodeId id(NodeKind kind, std::string canonical) { + return NodeId{kind, std::move(canonical)}; +} + +NodeRef insertNode(NodeGraph &graph, NodeId nodeId) { + auto write = graph.write(); + NodeRef node = write.upsert(std::move(nodeId)); + static_cast(write.finish()); + return node; +} + +NodeAction richPromptAction(const NodeRef &target) { + NodeAction action; + action.target = target; + action.kind = NodeActionKind::SubmitPrompt; + action.promptText = std::string(4096, 'p'); + action.attachments = { + Attachment{"/tmp/image.bin", "image.bin", "application/octet-stream", + std::vector{0, 1, 2, 3, 127, 128, 254, 255}}, + Attachment{"/tmp/reference.txt", "reference.txt", "text/plain", + std::nullopt}, + }; + action.payload = Value::Object{ + {"draft", "draft-17"}, + {"metadata", Value::Object{{"source", "composer"}, {"version", 3}}}, + }; + action.correlation = "prompt-correlation-17"; + return action; +} + +bool hasRequiredFlags(int descriptor) { + const int statusFlags = ::fcntl(descriptor, F_GETFL); + const int descriptorFlags = ::fcntl(descriptor, F_GETFD); + return statusFlags >= 0 && (statusFlags & O_NONBLOCK) != 0 && + descriptorFlags >= 0 && (descriptorFlags & FD_CLOEXEC) != 0; +} + +bool testEventFd() { + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_assignable_v); + + EventFd wake; + bool passed = true; + passed &= expect(wake.valid() && wake.creationError() == 0, + "eventfd construction succeeds without an error"); + if (!wake.valid()) + return false; + + const int originalDescriptor = wake.descriptor(); + passed &= expect(hasRequiredFlags(originalDescriptor), + "eventfd is non-blocking and close-on-exec"); + + const EventFd::DrainResult initiallyEmpty = wake.drain(); + passed &= + expect(initiallyEmpty.status == EventFd::DrainStatus::Empty && + initiallyEmpty.count == 0 && initiallyEmpty.errorNumber == 0 && + initiallyEmpty.accepted(), + "empty eventfd drain is non-blocking and successful"); + + const EventFd::NotifyResult first = wake.notify(); + const EventFd::NotifyResult second = wake.notify(); + const EventFd::NotifyResult third = wake.notify(); + passed &= + expect(first.status == EventFd::NotifyStatus::Notified && + second.status == EventFd::NotifyStatus::Notified && + third.status == EventFd::NotifyStatus::Notified && + first.accepted() && second.accepted() && third.accepted(), + "successive eventfd notifications are admitted"); + const EventFd::DrainResult accumulated = wake.drain(); + passed &= expect(accumulated.status == EventFd::DrainStatus::Drained && + accumulated.count == 3 && accumulated.errorNumber == 0 && + accumulated.accepted(), + "one eventfd read drains the accumulated wake count"); + passed &= expect(wake.drain().status == EventFd::DrainStatus::Empty, + "drained eventfd immediately reports empty"); + + EventFd moved(std::move(wake)); + passed &= expect(!wake.valid() && moved.valid() && + moved.descriptor() == originalDescriptor, + "eventfd move construction transfers its one descriptor"); + passed &= expect( + wake.notify() == + EventFd::NotifyResult{EventFd::NotifyStatus::Closed, EBADF} && + wake.drain() == + EventFd::DrainResult{EventFd::DrainStatus::Closed, 0, EBADF}, + "a moved-from eventfd reports closed operations"); + + EventFd assigned; + const int replacedDescriptor = assigned.descriptor(); + assigned = std::move(moved); + errno = 0; + const int replacedState = ::fcntl(replacedDescriptor, F_GETFD); + const int replacedError = errno; + passed &= expect(!moved.valid() && assigned.valid() && + assigned.descriptor() == originalDescriptor && + replacedState == -1 && replacedError == EBADF, + "eventfd move assignment closes the replaced descriptor"); + + assigned.close(); + assigned.close(); + passed &= expect( + !assigned.valid() && + assigned.notify() == + EventFd::NotifyResult{EventFd::NotifyStatus::Closed, EBADF} && + assigned.drain() == + EventFd::DrainResult{EventFd::DrainStatus::Closed, 0, EBADF}, + "closing an eventfd is idempotent and explicitly reported"); + return passed; +} + +bool testDescriptorsAndVariantOrder() { + ThreadChannels channels; + bool passed = true; + const int workerToQt = channels.workerToQtEventFd(); + const int qtToWorker = channels.qtToWorkerEventFd(); + passed &= + expect(channels.valid() && workerToQt >= 0 && qtToWorker >= 0 && + workerToQt != qtToWorker && + workerToQt == channels.workerToQtEventFd() && + qtToWorker == channels.qtToWorkerEventFd(), + "thread channels expose exactly two stable distinct eventfds"); + passed &= expect(hasRequiredFlags(workerToQt) && hasRequiredFlags(qtToWorker), + "both channel eventfds have the required Linux flags"); + + NodeGraph graph; + GraphChange graphChange; + NodeRef target; + { + auto write = graph.write(); + target = write.upsert( + id(NodeKind::Thread, "thread/order"), + NodeState{NodeStatus::Pending, {{"title", "Ordered thread"}}}); + graphChange = write.finish(); + } + const GraphChanged expectedGraphChanged{ + graphChange.revision, graphChange.affected, graphChange.removed, false}; + passed &= expect(channels.sendGraphChanged(std::move(graphChange)) == + ChannelSendStatus::Accepted, + "a graph change is admitted worker-to-Qt"); + + UiEffect effect{ + UiEffectKind::ShowNotice, target, "focus", {{"reason", "new-thread"}}}; + const UiEffect expectedEffect = effect; + passed &= expect(channels.sendUiEffect(effect) == ChannelSendStatus::Accepted, + "a UI effect is admitted worker-to-Qt"); + WorkerStopped stopped{"normal stop"}; + const WorkerStopped expectedStopped = stopped; + passed &= + expect(channels.sendWorkerStopped(stopped) == ChannelSendStatus::Accepted, + "worker termination is admitted worker-to-Qt"); + + const EventFd::DrainResult workerWake = channels.drainWorkerToQtWake(); + passed &= expect(workerWake.status == EventFd::DrainStatus::Drained && + workerWake.count == 3, + "worker-to-Qt eventfd accumulates one wake per admission"); + + WorkerToQtMessage workerMessage; + passed &= + expect(channels.tryReceiveForQt(workerMessage) && + std::holds_alternative(workerMessage) && + std::get(workerMessage) == expectedGraphChanged, + "GraphChanged remains first in worker-to-Qt FIFO order"); + passed &= expect(channels.tryReceiveForQt(workerMessage) && + std::holds_alternative(workerMessage) && + std::get(workerMessage) == expectedEffect, + "UiEffect remains second in worker-to-Qt FIFO order"); + passed &= + expect(channels.tryReceiveForQt(workerMessage) && + std::holds_alternative(workerMessage) && + std::get(workerMessage) == expectedStopped, + "WorkerStopped remains third in worker-to-Qt FIFO order"); + passed &= expect(!channels.tryReceiveForQt(workerMessage), + "worker-to-Qt queue is empty after ordered drain"); + + NodeAction nodeAction = richPromptAction(target); + const NodeAction expectedNodeAction = nodeAction; + const char *const promptStorage = nodeAction.promptText.data(); + const std::uint8_t *const attachmentStorage = + nodeAction.attachments.front().bytes->data(); + passed &= + expect(channels.sendNodeAction(nodeAction) == ChannelSendStatus::Accepted, + "a node action is admitted Qt-to-worker"); + passed &= + expect(!nodeAction.target && nodeAction.promptText.empty() && + nodeAction.attachments.empty() && nodeAction.payload.empty() && + nodeAction.correlation.empty(), + "admission moves prompt and attachment ownership from Qt"); + + RuntimeAction runtimeAction{ + RuntimeActionKind::Reconnect, {{"provider", "local"}}, "runtime-order"}; + const RuntimeAction expectedRuntimeAction = runtimeAction; + passed &= expect(channels.sendRuntimeAction(runtimeAction) == + ChannelSendStatus::Accepted, + "a runtime action is admitted Qt-to-worker"); + ShutdownRequest shutdown; + passed &= + expect(channels.sendShutdown(shutdown) == ChannelSendStatus::Accepted, + "shutdown is admitted Qt-to-worker"); + + const EventFd::DrainResult qtWake = channels.drainQtToWorkerWake(); + passed &= expect(qtWake.status == EventFd::DrainStatus::Drained && + qtWake.count == 3, + "Qt-to-worker eventfd accumulates one wake per admission"); + + QtToWorkerMessage qtMessage; + const bool receivedNodeAction = channels.tryReceiveForWorker(qtMessage); + const NodeAction *receivedAction = + receivedNodeAction ? std::get_if(&qtMessage) : nullptr; + passed &= expect( + receivedAction && *receivedAction == expectedNodeAction && + receivedAction->promptText.data() == promptStorage && + receivedAction->attachments.front().bytes->data() == + attachmentStorage, + "NodeAction arrives first with moved prompt and attachment storage"); + passed &= + expect(channels.tryReceiveForWorker(qtMessage) && + std::holds_alternative(qtMessage) && + std::get(qtMessage) == expectedRuntimeAction, + "RuntimeAction remains second in Qt-to-worker FIFO order"); + passed &= expect(channels.tryReceiveForWorker(qtMessage) && + std::holds_alternative(qtMessage), + "ShutdownRequest remains third in Qt-to-worker FIFO order"); + passed &= expect(!channels.tryReceiveForWorker(qtMessage), + "Qt-to-worker queue is empty after ordered drain"); + return passed; +} + +bool testQtToWorkerBackpressure() { + ThreadChannels channels; + NodeGraph graph; + const NodeRef target = + insertNode(graph, id(NodeKind::Thread, "thread/backpressure")); + + bool passed = true; + std::size_t ordinaryAdmissions = 0; + bool reachedOrdinaryLimit = false; + bool allOrdinaryAdmissionsAccepted = true; + for (std::size_t attempt = 0; attempt <= ThreadChannels::QtToWorkerCapacity; + ++attempt) { + RuntimeAction filler{RuntimeActionKind::RefreshThreads, + {}, + "runtime-fill-" + std::to_string(attempt)}; + const ChannelSendStatus status = channels.sendRuntimeAction(filler); + if (status == ChannelSendStatus::QueueFull) { + reachedOrdinaryLimit = true; + break; + } + allOrdinaryAdmissionsAccepted = + allOrdinaryAdmissionsAccepted && status == ChannelSendStatus::Accepted; + ++ordinaryAdmissions; + } + passed &= + expect(allOrdinaryAdmissionsAccepted && reachedOrdinaryLimit && + ordinaryAdmissions + 1 == ThreadChannels::QtToWorkerCapacity && + channels.qtToWorkerSizeApprox() == ordinaryAdmissions, + "ordinary Qt actions wake the worker and stop at the reserved " + "shutdown slot"); + + NodeAction rejected = richPromptAction(target); + const NodeAction expectedRejected = rejected; + const char *const rejectedPromptStorage = rejected.promptText.data(); + const std::uint8_t *const rejectedAttachmentStorage = + rejected.attachments.front().bytes->data(); + const ChannelSendStatus rejectedStatus = channels.sendNodeAction(rejected); + passed &= expect( + rejectedStatus == ChannelSendStatus::QueueFull && + !messageAdmitted(rejectedStatus) && !wakeFailed(rejectedStatus) && + rejected == expectedRejected && + rejected.promptText.data() == rejectedPromptStorage && + rejected.attachments.front().bytes->data() == + rejectedAttachmentStorage, + "a saturated mailbox leaves rejected user input byte-for-byte owned"); + + ShutdownRequest shutdown; + const ChannelSendStatus shutdownStatus = channels.sendShutdown(shutdown); + passed &= expect(shutdownStatus == ChannelSendStatus::Accepted && + channels.qtToWorkerSizeApprox() == + ThreadChannels::QtToWorkerCapacity, + "the reserved slot still admits shutdown exactly once"); + + const EventFd::DrainResult wake = channels.drainQtToWorkerWake(); + passed &= expect(wake.status == EventFd::DrainStatus::Drained && + wake.count == ordinaryAdmissions + 1, + "rejected actions add no eventfd wake"); + + QtToWorkerMessage message; + bool fifoOrder = true; + for (std::size_t index = 0; index < ordinaryAdmissions; ++index) { + const bool received = channels.tryReceiveForWorker(message); + const RuntimeAction *runtime = + received ? std::get_if(&message) : nullptr; + fifoOrder = fifoOrder && runtime && + runtime->correlation == "runtime-fill-" + std::to_string(index); + } + passed &= + expect(fifoOrder, "admitted ordinary Qt actions preserve FIFO order"); + passed &= expect(channels.tryReceiveForWorker(message) && + std::holds_alternative(message) && + !channels.tryReceiveForWorker(message), + "shutdown follows all previously admitted ordinary actions"); + return passed; +} + +bool testGraphCoalescingAndRetiredLifetime() { + ThreadChannels channels; + bool passed = true; + + std::size_t ordinaryAdmissions = 0; + bool reachedOrdinaryLimit = false; + bool allOrdinaryAdmissionsAccepted = true; + for (std::size_t attempt = 0; attempt <= ThreadChannels::WorkerToQtCapacity; + ++attempt) { + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + "ui-fill-" + std::to_string(attempt), + {}}; + const ChannelSendStatus status = channels.sendUiEffect(effect); + if (status == ChannelSendStatus::QueueFull) { + reachedOrdinaryLimit = true; + break; + } + allOrdinaryAdmissionsAccepted = + allOrdinaryAdmissionsAccepted && status == ChannelSendStatus::Accepted; + ++ordinaryAdmissions; + } + passed &= + expect(allOrdinaryAdmissionsAccepted && reachedOrdinaryLimit && + ordinaryAdmissions + ThreadChannels::WorkerToQtReservedSlots == + ThreadChannels::WorkerToQtCapacity && + channels.workerToQtSizeApprox() == ordinaryAdmissions, + "ordinary worker notifications preserve critical and terminal " + "slots"); + + UiEffect selection{ + UiEffectKind::SelectThread, std::nullopt, "critical selection", {}}; + passed &= expect( + channels.sendUiEffect(selection) == ChannelSendStatus::Accepted && + channels.workerToQtSizeApprox() == ordinaryAdmissions + 1, + "critical selection uses its reserved slot under ordinary saturation"); + + WorkerStopped stopped{"worker finished while Qt was saturated"}; + passed &= expect( + channels.sendWorkerStopped(stopped) == ChannelSendStatus::Accepted && + channels.workerToQtSizeApprox() == ThreadChannels::WorkerToQtCapacity, + "the reserved slot still admits WorkerStopped"); + + NodeGraph graph; + NodeRef affected; + NodeRef removed; + { + auto write = graph.write(); + affected = write.upsert(id(NodeKind::Thread, "thread/coalesced")); + removed = write.upsert(id(NodeKind::Item, "item/removed")); + static_cast(write.finish()); + } + + GraphChange change; + { + auto write = graph.write(); + write.setField(affected, "title", "latest title"); + write.remove(removed); + change = write.finish(); + } + const std::uint64_t latestRevision = change.revision; + passed &= expect( + change.affected.size() == 1 && change.affected.front() == affected && + change.removed.size() == 1 && change.removed.front() == removed, + "saturated GraphChange names affected and removed NodeRefs"); + + std::weak_ptr removedLifetime = removed; + const ChannelSendStatus coalesced = + channels.sendGraphChanged(std::move(change)); + removed.reset(); + passed &= expect( + coalesced == ChannelSendStatus::CoalescedRescan && + messageAdmitted(coalesced) && !wakeFailed(coalesced) && + channels.rescanPending() && + channels.workerToQtSizeApprox() == ThreadChannels::WorkerToQtCapacity, + "full worker mailbox coalesces GraphChanged into an explicit rescan"); + + const EventFd::DrainResult wake = channels.drainWorkerToQtWake(); + passed &= + expect(wake.status == EventFd::DrainStatus::Drained && + wake.count == ordinaryAdmissions + 3, + "coalesced rescan still wakes Qt without queue payload copies"); + + WorkerToQtMessage message; + passed &= + expect(channels.tryReceiveForQt(message) && + std::holds_alternative(message) && + std::get(message).revision == latestRevision && + std::get(message).affected.empty() && + std::get(message).removed.empty() && + std::get(message).rescanRequired && + !channels.rescanPending(), + "Qt receives the latest synthesized rescan before stale queued " + "notifications"); + bool fifoOrder = true; + for (std::size_t index = 0; index < ordinaryAdmissions; ++index) { + const bool received = channels.tryReceiveForQt(message); + const UiEffect *effect = + received ? std::get_if(&message) : nullptr; + fifoOrder = fifoOrder && effect && + effect->text == "ui-fill-" + std::to_string(index); + } + passed &= + expect(fifoOrder, + "queued worker notifications preserve FIFO order after rescan"); + passed &= + expect(channels.tryReceiveForQt(message) && + std::holds_alternative(message) && + std::get(message).kind == UiEffectKind::SelectThread, + "critical selection follows ordinary notifications"); + passed &= expect(channels.tryReceiveForQt(message) && + std::holds_alternative(message) && + std::get(message).reason == + "worker finished while Qt was saturated", + "WorkerStopped follows ordinary notifications"); + passed &= expect(!channels.tryReceiveForQt(message), + "rescan is synthesized only once"); + + NodeRef retired; + { + auto read = graph.tryRead(); + if (read && read->retiredNodes().size() == 1) + retired = read->retiredNodes().front(); + passed &= + expect(read.has_value() && retired && read->removed(retired) && + retired->id() == id(NodeKind::Item, "item/removed") && + !removedLifetime.expired(), + "removed node stays reachable after its notification coalesces"); + } + + NodeAction detached; + detached.target = retired; + detached.kind = NodeActionKind::UiDetached; + detached.correlation = "detach-item/removed"; + passed &= + expect(channels.sendNodeAction(detached) == ChannelSendStatus::Accepted && + !detached.target, + "Qt acknowledges detachment with the stable retired NodeRef"); + retired.reset(); + + const EventFd::DrainResult detachWake = channels.drainQtToWorkerWake(); + passed &= expect(detachWake.status == EventFd::DrainStatus::Drained && + detachWake.count == 1, + "UiDetached admission wakes the worker once"); + QtToWorkerMessage detachedMessage; + const bool receivedDetached = channels.tryReceiveForWorker(detachedMessage); + NodeAction *detachedAction = + receivedDetached ? std::get_if(&detachedMessage) : nullptr; + NodeRef acknowledged = detachedAction ? detachedAction->target : NodeRef{}; + passed &= expect(detachedAction && + detachedAction->kind == NodeActionKind::UiDetached && + acknowledged && !removedLifetime.expired(), + "worker receives the removal pin before retirement release"); + + { + const std::array released{acknowledged}; + auto write = graph.write(); + write.releaseRetired(released); + static_cast(write.finish()); + } + passed &= + expect(!removedLifetime.expired(), + "executing UiDetached keeps its NodeRef alive during release"); + acknowledged.reset(); + detachedMessage = ShutdownRequest{}; + passed &= + expect(removedLifetime.expired(), + "retired node dies only after graph and command release NodeRefs"); + return passed; +} + +bool testWakeFailureAfterAdmission() { + ThreadChannels channels; + NodeGraph graph; + const NodeRef target = + insertNode(graph, id(NodeKind::Thread, "thread/closed-wake")); + channels.failNextQtToWorkerWakeForTest(); + + NodeAction action = richPromptAction(target); + const NodeAction expected = action; + const ChannelSendStatus status = channels.sendNodeAction(action); + + bool passed = true; + passed &= expect(status == ChannelSendStatus::AcceptedWakeFailed && + messageAdmitted(status) && deliveryGuaranteed(status) && + wakeFailed(status) && + channels.qtToWorkerSizeApprox() == 1 && !action.target && + action.promptText.empty() && action.attachments.empty(), + "failed wake reports one admitted payload for bounded " + "fallback delivery"); + passed &= expect(channels.drainQtToWorkerWake().status == + EventFd::DrainStatus::Empty, + "injected wake failure leaves the eventfd unsignaled"); + + QtToWorkerMessage message; + passed &= + expect(channels.tryReceiveForWorker(message) && + std::holds_alternative(message) && + std::get(message) == expected && + !channels.tryReceiveForWorker(message), + "wake failure leaves exactly one admitted command in the mailbox"); + + ThreadChannels shutdownChannels; + shutdownChannels.failNextQtToWorkerWakeForTest(); + ShutdownRequest shutdown; + const ChannelSendStatus shutdownStatus = + shutdownChannels.sendShutdown(shutdown); + QtToWorkerMessage shutdownMessage; + passed &= expect( + shutdownStatus == ChannelSendStatus::AcceptedWakeFailed && + deliveryGuaranteed(shutdownStatus) && + shutdownChannels.drainQtToWorkerWake().status == + EventFd::DrainStatus::Empty && + shutdownChannels.tryReceiveForWorker(shutdownMessage) && + std::holds_alternative(shutdownMessage), + "an unwoken shutdown remains available to the worker timeout drain"); + + ThreadChannels workerChannels; + workerChannels.failNextWorkerToQtWakeForTest(); + UiEffect effect{UiEffectKind::ShowNotice, {}, "fallback notice", {}}; + const ChannelSendStatus workerStatus = workerChannels.sendUiEffect(effect); + WorkerToQtMessage workerMessage; + passed &= + expect(workerStatus == ChannelSendStatus::AcceptedWakeFailed && + deliveryGuaranteed(workerStatus) && wakeFailed(workerStatus) && + workerChannels.drainWorkerToQtWake().status == + EventFd::DrainStatus::Empty && + workerChannels.tryReceiveForQt(workerMessage) && + std::holds_alternative(workerMessage), + "Qt can recover one admitted worker message after a failed wake"); + + ThreadChannels closedChannels; + closedChannels.close(); + NodeAction rejected = richPromptAction(target); + const NodeAction retained = rejected; + passed &= expect( + closedChannels.sendNodeAction(rejected) == ChannelSendStatus::QueueFull && + !messageAdmitted(ChannelSendStatus::QueueFull) && + !deliveryGuaranteed(ChannelSendStatus::QueueFull) && + rejected == retained && closedChannels.qtToWorkerSizeApprox() == 0, + "a closed channel rejects without consuming user-owned payload"); + return passed; +} + +bool testOversizedGraphChangeRequiresRescan() { + ThreadChannels channels; + NodeGraph graph; + GraphChange oversized; + { + auto write = graph.write(); + for (std::size_t index = 0; + index <= ThreadChannels::MaximumDirectGraphReferences; ++index) { + static_cast(write.upsert( + id(NodeKind::Item, "oversized/" + std::to_string(index)))); + } + oversized = write.finish(); + } + const std::uint64_t revision = oversized.revision; + + bool passed = true; + passed &= expect( + channels.sendGraphChanged(std::move(oversized)) == + ChannelSendStatus::CoalescedRescan && + channels.workerToQtSizeApprox() == 0 && channels.rescanPending(), + "an oversized committed transaction coalesces even with queue space"); + passed &= expect(channels.drainWorkerToQtWake().count == 1, + "an oversized graph rescan produces one eventfd wake"); + WorkerToQtMessage message; + passed &= expect( + channels.tryReceiveForQt(message) && + std::holds_alternative(message) && + std::get(message).revision == revision && + std::get(message).rescanRequired && + std::get(message).affected.empty() && + std::get(message).removed.empty(), + "Qt receives only the explicit rescan marker, never an unbounded ref " + "vector"); + return passed; +} + +} // namespace + +int main() { + bool passed = true; + passed &= testEventFd(); + passed &= testDescriptorsAndVariantOrder(); + passed &= testQtToWorkerBackpressure(); + passed &= testGraphCoalescingAndRetiredLifetime(); + passed &= testOversizedGraphChangeRequiresRescan(); + passed &= testWakeFailureAfterAdmission(); + return passed ? 0 : 1; +} diff --git a/tests/codex/nodegraph/WorkerLogicTest.cpp b/tests/codex/nodegraph/WorkerLogicTest.cpp new file mode 100644 index 0000000..65ad63e --- /dev/null +++ b/tests/codex/nodegraph/WorkerLogicTest.cpp @@ -0,0 +1,2049 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT + +#include "codex/nodegraph/WorkerLogic.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace codexui::nodegraph; + +int failures = 0; + +void require(bool condition, std::string_view message) { + if (condition) + return; + ++failures; + std::cerr << "FAILED: " << message << '\n'; +} + +const Value *field(const std::shared_ptr &state, + std::string_view key) { + if (!state) + return nullptr; + const auto found = state->fields.find(key); + return found == state->fields.end() ? nullptr : &found->second; +} + +bool stringFieldEquals(const std::shared_ptr &state, + std::string_view key, std::string_view expected) { + const Value *value = field(state, key); + const std::string *text = value ? value->asString() : nullptr; + return text && *text == expected; +} + +bool unsignedFieldEquals(const std::shared_ptr &state, + std::string_view key, std::uint64_t expected) { + const Value *value = field(state, key); + const std::uint64_t *number = value ? value->asUInt64() : nullptr; + return number && *number == expected; +} + +bool boolFieldEquals(const std::shared_ptr &state, + std::string_view key, bool expected) { + const Value *value = field(state, key); + const bool *boolean = value ? value->asBool() : nullptr; + return boolean && *boolean == expected; +} + +bool objectStringFieldEquals(const std::shared_ptr &state, + std::string_view key, std::string_view member, + std::string_view expected) { + const Value *value = field(state, key); + const Value::Object *object = value ? value->asObject() : nullptr; + if (!object) + return false; + const auto found = object->find(member); + const std::string *text = + found == object->end() ? nullptr : found->second.asString(); + return text && *text == expected; +} + +bool signedFieldEquals(const std::shared_ptr &state, + std::string_view key, std::int64_t expected) { + const Value *value = field(state, key); + const std::int64_t *number = value ? value->asInt64() : nullptr; + return number && *number == expected; +} + +std::optional takeGraphChanged(ThreadChannels &channels, + std::uint64_t wakeCount = 1) { + const EventFd::DrainResult wake = channels.drainWorkerToQtWake(); + if (wake.status != EventFd::DrainStatus::Drained || wake.count != wakeCount) + return std::nullopt; + WorkerToQtMessage message; + if (!channels.tryReceiveForQt(message)) + return std::nullopt; + GraphChanged *changed = std::get_if(&message); + if (!changed) + return std::nullopt; + return std::move(*changed); +} + +std::vector takeWorkerMessages(ThreadChannels &channels) { + static_cast(channels.drainWorkerToQtWake()); + std::vector messages; + WorkerToQtMessage message; + while (channels.tryReceiveForQt(message)) { + messages.emplace_back(std::move(message)); + message = WorkerToQtMessage(GraphChanged{}); + } + return messages; +} + +void protocolUpdatesPublishForUnlockedReads() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + const ChannelSendStatus status = logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("worker-thread")}, + {"name", Value("Worker thread")}})}}}); + require(status == ChannelSendStatus::Accepted, + "protocol graph update is admitted to Qt"); + + const std::optional changed = takeGraphChanged(channels); + const bool includesThread = + changed && + std::ranges::find_if(changed->affected, [](const NodeRef &node) { + return node->id() == NodeId{NodeKind::Thread, "worker-thread"}; + }) != changed->affected.end(); + require(changed && changed->revision == graph.publishedRevision() && + !changed->rescanRequired && includesThread, + "protocol update queues its committed revision and addressed " + "NodeRef"); + + auto qtRead = graph.tryRead(); + require(qtRead.has_value(), + "Qt can try-read immediately after receiving the graph wake"); + if (!qtRead) + return; + const NodeRef thread = qtRead->find({NodeKind::Thread, "worker-thread"}); + require(thread && changed && changed->affected.front() == thread && + stringFieldEquals(qtRead->state(thread), "name", "Worker thread"), + "the post-unlock read sees the complete protocol update"); +} + +void connectionStateAndGenerationsStayCurrent() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + require(logic.transportEvent("connecting", "dialing bridge") == + ChannelSendStatus::Accepted, + "connecting state is published"); + require(takeGraphChanged(channels).has_value(), + "connecting state wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require(connection && state && state->status == NodeStatus::Pending && + stringFieldEquals(state, "transportState", "connecting") && + stringFieldEquals(state, "transportDetail", "dialing bridge") && + unsignedFieldEquals(state, "connectionGeneration", 0), + "connection node retains explicit connecting fields"); + } + + require(logic.transportEvent("connected", "unused") == + ChannelSendStatus::Accepted, + "connected state is published"); + require(takeGraphChanged(channels).has_value(), + "connected state wakes Qt once"); + require(logic.bridgeState("bridge-1", "controller", "bridge-1", 7, "ready", + "provider ready") == ChannelSendStatus::Accepted, + "bridge/provider state is published"); + require(takeGraphChanged(channels).has_value(), + "bridge/provider state wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require( + state && state->status == NodeStatus::Connected && + stringFieldEquals(state, "transportState", "connected") && + stringFieldEquals(state, "transportDetail", "") && + unsignedFieldEquals(state, "connectionGeneration", 1) && + stringFieldEquals(state, "connectionId", "bridge-1") && + stringFieldEquals(state, "role", "controller") && + stringFieldEquals(state, "controllerConnectionId", "bridge-1") && + unsignedFieldEquals(state, "providerGeneration", 7) && + stringFieldEquals(state, "providerState", "ready") && + stringFieldEquals(state, "providerDetail", "provider ready"), + "connection node holds current transport and bridge facts"); + } + + require(logic.connectionSettings( + {{"selected", Value("unix")}, {"tls", Value(false)}}) == + ChannelSendStatus::Accepted, + "connection settings are published"); + require(takeGraphChanged(channels).has_value(), + "connection settings wake Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require(state && state->status == NodeStatus::Connected && + stringFieldEquals(state, "transportState", "connected") && + unsignedFieldEquals(state, "connectionGeneration", 1) && + stringFieldEquals(state, "connectionId", "bridge-1") && + unsignedFieldEquals(state, "providerGeneration", 7) && + stringFieldEquals(state, "providerState", "ready") && + objectStringFieldEquals(state, "settings", "selected", "unix"), + "a settings-only update preserves current transport and provider " + "facts on the connection node"); + } + + require(logic.bridgeState("bridge-1", "observer", "bridge-2", 7, + std::nullopt) == ChannelSendStatus::Accepted, + "controller identity can change without a provider event"); + require(takeGraphChanged(channels).has_value(), + "controller identity change wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require( + state && stringFieldEquals(state, "role", "observer") && + stringFieldEquals(state, "controllerConnectionId", "bridge-2") && + stringFieldEquals(state, "providerState", "ready") && + stringFieldEquals(state, "providerDetail", "provider ready") && + stringFieldEquals(state, "transportState", "connected") && + objectStringFieldEquals(state, "settings", "selected", "unix"), + "an addressing-only bridge update preserves transport, settings, and " + "same-generation provider facts"); + } + + const std::uint64_t beforeStale = graph.publishedRevision(); + require(logic.bridgeState("bridge-1", "controller", "bridge-1", 6, + "disconnected", + "stale") == ChannelSendStatus::Accepted, + "stale provider state is handled without failure"); + require(graph.publishedRevision() == beforeStale && + channels.workerToQtSizeApprox() == 0 && + channels.drainWorkerToQtWake().status == + EventFd::DrainStatus::Empty, + "stale provider generation cannot publish or wake Qt"); + + require(logic.transportEvent("retrying", "retry scheduled") == + ChannelSendStatus::Accepted, + "retrying state is published"); + require(takeGraphChanged(channels).has_value(), + "retrying state wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require( + state && state->status == NodeStatus::Pending && + unsignedFieldEquals(state, "connectionGeneration", 1) && + unsignedFieldEquals(state, "providerGeneration", 7) && + stringFieldEquals(state, "connectionId", "") && + stringFieldEquals(state, "role", "") && + stringFieldEquals(state, "controllerConnectionId", "") && + stringFieldEquals(state, "providerState", "") && + stringFieldEquals(state, "providerDetail", "") && + stringFieldEquals(state, "transportDetail", "retry scheduled") && + objectStringFieldEquals(state, "settings", "selected", "unix"), + "a transport-only retry clears bridge facts but preserves both " + "generations and connection settings"); + } + + require(logic.transportEvent("disconnected", "connection lost") == + ChannelSendStatus::Accepted, + "disconnect state is published"); + require(takeGraphChanged(channels).has_value(), + "disconnect state wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require(state && state->status == NodeStatus::Disconnected && + unsignedFieldEquals(state, "connectionGeneration", 1), + "disconnect preserves the current connection generation"); + } + + require(logic.transportEvent("connected") == ChannelSendStatus::Accepted, + "reconnection is published"); + require(takeGraphChanged(channels).has_value(), "reconnection wakes Qt once"); + { + auto read = graph.tryRead(); + const NodeRef connection = + read ? read->find({NodeKind::Connection, "connection"}) : NodeRef{}; + const auto state = read && connection ? read->state(connection) : nullptr; + require(state && state->status == NodeStatus::Connected && + unsignedFieldEquals(state, "connectionGeneration", 2) && + unsignedFieldEquals(state, "providerGeneration", 0) && + objectStringFieldEquals(state, "settings", "selected", "unix"), + "each successful connection advances generation and resets " + "provider generation without replacing connection settings"); + } + + { + auto write = graph.write(); + const NodeRef connection = write.find({NodeKind::Connection, "connection"}); + write.setField(connection, "providerGeneration", Value(std::uint64_t{42})); + static_cast(write.finish()); + } + const std::uint64_t beforeGenerationRead = graph.publishedRevision(); + require(logic.generations() == WorkerGenerations{2, 42} && + graph.publishedRevision() == beforeGenerationRead, + "generation correlation reads the sole connection node authority " + "without publishing another revision"); +} + +void stateNeutralMessagesDoNotWakeQt() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + const std::uint64_t before = graph.publishedRevision(); + const ChannelSendStatus status = logic.apply( + {DecodedMessageKind::ServerNotification, "rawResponse/completed", + std::nullopt, Value::Object{{"ignored", Value(true)}}}); + WorkerToQtMessage message; + require(status == ChannelSendStatus::Accepted && + graph.publishedRevision() == before && + channels.workerToQtSizeApprox() == 0 && + channels.drainWorkerToQtWake().status == + EventFd::DrainStatus::Empty && + !channels.tryReceiveForQt(message), + "explicitly state-neutral protocol handling publishes no revision or " + "wake"); +} + +void hydrationReadinessIsCurrentGraphState() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + static_cast(logic.transportEvent("connected")); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("hydration-thread")}, + {"status", Value("notLoaded")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "hydration-thread"}); + } + + require(logic.threadHydration(thread, "loading") == + ChannelSendStatus::Accepted, + "hydration start updates its exact current thread"); + static_cast(takeWorkerMessages(channels)); + require(logic.threadHydration(thread, "failed", "read was rejected") == + ChannelSendStatus::Accepted, + "hydration failure is retained for visible admission gating"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const auto state = read->state(thread); + require( + stringFieldEquals(state, "hydrationState", "failed") && + stringFieldEquals(state, "hydrationError", "read was rejected") && + unsignedFieldEquals(state, "hydrationConnectionGeneration", 1), + "hydration state and connection generation live on the thread"); + } + + require(logic.threadHydration(thread, "ready") == ChannelSendStatus::Accepted, + "successful reload makes the same thread admission-ready"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const auto state = read->state(thread); + require(stringFieldEquals(state, "hydrationState", "ready") && + !field(state, "hydrationError"), + "successful hydration clears the retained failure"); + } +} + +void hydrationRequiresUsableAuthoritativeItemType() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + static_cast(logic.transportEvent("connected")); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("typed-hydration")}, + {"status", Value("notLoaded")}})}}})); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "item/plan/delta", std::nullopt, + Value::Object{{"threadId", Value("typed-hydration")}, + {"turnId", Value("typed-turn")}, + {"itemId", Value("typed-plan")}, + {"delta", Value("newer streamed plan")}}})); + static_cast(takeWorkerMessages(channels)); + + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "typed-hydration"}); + } + const ProtocolRequestId incompleteId("incomplete-hydration"); + WorkerApplyResult incompleteRequest = logic.applyDetailed( + {DecodedMessageKind::ClientRequest, "thread/read", incompleteId, + Value::Object{{"threadId", Value("typed-hydration")}}}); + static_cast(takeWorkerMessages(channels)); + Value::Object incompleteTurn{{"id", Value("typed-turn")}}; + Value::Object incompleteThread{ + {"id", Value("typed-hydration")}, + {"turns", Value(Value::Array{Value(std::move(incompleteTurn))})}}; + DecodedMessage incompleteResult{ + DecodedMessageKind::ClientResult, "thread/read", incompleteId, + Value::Object{{"thread", Value(std::move(incompleteThread))}}, + incompleteRequest.primary}; + require(logic.completeThreadHydration(std::move(incompleteResult), thread, + "ready") == ChannelSendStatus::Accepted, + "an incomplete hydration result is reduced atomically"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const auto state = read->state(thread); + require(stringFieldEquals(state, "hydrationState", "failed") && + stringFieldEquals( + state, "hydrationError", + "Thread hydration returned incomplete item identity"), + "a provider item without authoritative type cannot make hydration " + "ready"); + } + + const ProtocolRequestId completeId("complete-typed-hydration"); + WorkerApplyResult completeRequest = logic.applyDetailed( + {DecodedMessageKind::ClientRequest, "thread/read", completeId, + Value::Object{{"threadId", Value("typed-hydration")}}}); + static_cast(takeWorkerMessages(channels)); + Value::Object completeItem{{"id", Value("typed-plan")}, + {"type", Value("plan")}, + {"text", Value("newer streamed plan")}}; + Value::Object completeTurn{ + {"id", Value("typed-turn")}, + {"items", Value(Value::Array{Value(std::move(completeItem))})}}; + Value::Object completeThread{ + {"id", Value("typed-hydration")}, + {"turns", Value(Value::Array{Value(std::move(completeTurn))})}}; + DecodedMessage completeResult{ + DecodedMessageKind::ClientResult, "thread/read", completeId, + Value::Object{{"thread", Value(std::move(completeThread))}}, + completeRequest.primary}; + require(logic.completeThreadHydration(std::move(completeResult), thread, + "ready") == ChannelSendStatus::Accepted, + "a complete typed hydration result is reduced atomically"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef item = read->find(scopedItemNodeId( + scopedTurnNodeId("typed-hydration", "typed-turn"), "typed-plan")); + require( + item && stringFieldEquals(read->state(item), "type", "plan") && + stringFieldEquals(read->state(thread), "hydrationState", "ready") && + !field(read->state(thread), "hydrationError"), + "hydration becomes ready only after the same item has usable " + "authoritative identity and type"); + } +} + +void graphNotificationSaturationCoalesces() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + std::size_t admitted = 0; + bool reachedLimit = false; + bool statusesAccepted = true; + for (std::size_t attempt = 0; attempt <= ThreadChannels::WorkerToQtCapacity; + ++attempt) { + UiEffect effect{UiEffectKind::ShowNotice, + std::nullopt, + "fill-" + std::to_string(attempt), + {}}; + const ChannelSendStatus status = channels.sendUiEffect(effect); + if (status == ChannelSendStatus::QueueFull) { + reachedLimit = true; + break; + } + statusesAccepted = + statusesAccepted && status == ChannelSendStatus::Accepted; + ++admitted; + } + require(statusesAccepted && reachedLimit && + admitted + ThreadChannels::WorkerToQtReservedSlots == + ThreadChannels::WorkerToQtCapacity, + "ordinary worker messages preserve critical and terminal slots"); + + const ChannelSendStatus status = logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("coalesced-thread")}})}}}); + require( + status == ChannelSendStatus::CoalescedRescan && messageAdmitted(status) && + !wakeFailed(status) && channels.rescanPending() && + channels.workerToQtSizeApprox() == admitted, + "committed graph update coalesces visibly when its queue is saturated"); + + const std::uint64_t revision = graph.publishedRevision(); + const std::optional rescan = + takeGraphChanged(channels, admitted + 1); + require(rescan && rescan->rescanRequired && rescan->revision == revision && + !channels.rescanPending(), + "saturated graph update wakes Qt with the latest rescan revision"); + auto read = graph.tryRead(); + require(read && read->find({NodeKind::Thread, "coalesced-thread"}), + "coalescing never loses the already-committed current graph state"); +} + +void uiDetachAcknowledgementIsRevisionNeutral() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + NodeRef node; + { + auto write = graph.write(); + node = write.upsert({NodeKind::Item, "retired-item"}); + static_cast(write.finish()); + } + GraphChange removal; + { + auto write = graph.write(); + write.remove(node); + removal = write.finish(); + } + const std::uint64_t removalRevision = graph.publishedRevision(); + std::weak_ptr lifetime = node; + NodeRef acknowledgement = removal.removed.front(); + removal.affected.clear(); + removal.removed.clear(); + node.reset(); + + { + auto read = graph.tryRead(); + require(read && read->retiredNodes().size() == 1 && !lifetime.expired(), + "removed node remains pinned until Qt detaches it"); + } + const ChannelSendStatus status = + logic.acknowledgeUiDetached(std::move(acknowledgement)); + WorkerToQtMessage message; + require(status == ChannelSendStatus::Accepted && + graph.publishedRevision() == removalRevision && + channels.drainWorkerToQtWake().status == + EventFd::DrainStatus::Empty && + !channels.tryReceiveForQt(message), + "UiDetached releases retirement without a revision or notification"); + { + auto read = graph.tryRead(); + require(read && read->retiredNodes().empty(), + "UiDetached removes the graph retirement pin"); + } + require(lifetime.expired(), + "retired node dies after the acknowledgement releases its last pin"); +} + +void saturatedEffectsHaveCurrentGraphFallbacks() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + NodeRef first; + NodeRef second; + { + auto write = graph.write(); + first = write.upsert({NodeKind::Thread, "selection-first"}); + second = write.upsert({NodeKind::Thread, "selection-second"}); + static_cast(write.finish()); + } + while (true) { + UiEffect filler{UiEffectKind::ShowNotice, std::nullopt, "filler", {}}; + if (channels.sendUiEffect(filler) == ChannelSendStatus::QueueFull) + break; + } + + require(logic.showNotice("latest visible failure") == + ChannelSendStatus::CoalescedRescan, + "a saturated notice becomes current graph state and an explicit " + "rescan"); + require(logic.selectThread(first) == ChannelSendStatus::Accepted, + "the reserved critical slot admits the first selection"); + require(logic.selectThread(second) == ChannelSendStatus::CoalescedRescan, + "a second saturated critical selection has a graph fallback"); + { + auto read = graph.tryRead(); + const NodeRef notice = + read->find({NodeKind::Notice, "local-worker-notice"}); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + require(notice && + stringFieldEquals(read->state(notice), "message", + "latest visible failure") && + runtime && + read->related(runtime, RelationKind::UiSelectionTarget) == + std::vector{second}, + "Qt can reconstruct the latest saturated notice and exact stable " + "selection target from the shared graph"); + } + require(logic.sendWorkerStopped("terminal") == ChannelSendStatus::Accepted, + "terminal delivery still uses the final reserved slot"); +} + +void reverseInteractionResolutionUpdatesTheGraph() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply({DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", + ProtocolRequestId("approval-1"), + {{"threadId", Value("thread-1")}, + {"turnId", Value("turn-1")}, + {"itemId", Value("item-1")}}})); + require(takeGraphChanged(channels).has_value(), + "reverse interaction creation wakes Qt"); + + require(logic.resolveInteraction(ProtocolRequestId("approval-1"), true) == + ChannelSendStatus::Accepted, + "accepted reverse interaction is resolved"); + const std::optional removed = takeGraphChanged(channels); + require(removed && removed->removed.size() == 1 && + removed->removed.front()->id().kind == NodeKind::Interaction, + "accepted response removes the pending interaction atomically"); + if (removed && !removed->removed.empty()) + static_cast(logic.acknowledgeUiDetached(removed->removed.front())); + + static_cast(logic.apply({DecodedMessageKind::ServerRequest, + "item/fileChange/requestApproval", + ProtocolRequestId("approval-2"), + {{"threadId", Value("thread-1")}}})); + require(takeGraphChanged(channels).has_value(), + "second reverse interaction creation wakes Qt"); + require(logic.resolveInteraction(ProtocolRequestId("approval-2"), false, + "transport rejected response") == + ChannelSendStatus::Accepted, + "rejected response failure is recorded"); + require(takeGraphChanged(channels).has_value(), + "rejected response failure wakes Qt"); + auto read = graph.tryRead(); + const NodeRef failed = + read ? read->find({NodeKind::Interaction, + ProtocolRequestId("approval-2").canonical()}) + : NodeRef{}; + const auto state = read && failed ? read->state(failed) : nullptr; + const NodeRef thread = + read ? read->find({NodeKind::Thread, "thread-1"}) : NodeRef{}; + const Value *pendingCount = + read && thread ? field(read->state(thread), "pendingInteractionCount") + : nullptr; + require( + state && state->status == NodeStatus::Failed && + stringFieldEquals(state, "error", "transport rejected response") && + pendingCount && pendingCount->asUInt64() && + *pendingCount->asUInt64() == 1, + "failed response remains visible with its concrete error and " + "continues to count as unresolved attention"); + read.reset(); + + require(logic.rejectInteractionResponse( + failed, + {{"answers", Value(Value::Object{{"question", Value("yes")}})}}, + "controller changed before delivery") == + ChannelSendStatus::Accepted, + "an authored reverse response can be retained after rejection"); + static_cast(takeGraphChanged(channels)); + read = graph.tryRead(); + const auto retainedState = read && failed ? read->state(failed) : nullptr; + const Value *retained = field(retainedState, "retainedResponsePayload"); + pendingCount = read && thread + ? field(read->state(thread), "pendingInteractionCount") + : nullptr; + require(retained && retained->asObject() && + retained->asObject()->contains("answers") && + stringFieldEquals(retainedState, "error", + "controller changed before delivery") && + pendingCount && pendingCount->asUInt64() && + *pendingCount->asUInt64() == 1, + "the failed interaction owns the exact authored response for a " + "manual retry without automatic resend or losing thread attention"); +} + +void threadActivityAndPromptOrderingStayInTheGraph() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("parent")}, + {"updatedAt", Value(10)}, + {"recencyAt", Value(30)}})}}})); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", Value(Value::Object{{"id", Value("child")}, + {"parentThreadId", Value("parent")}, + {"updatedAt", Value(20)}, + {"recencyAt", Value(20)}})}}})); + static_cast(takeWorkerMessages(channels)); + + NodeRef parent; + NodeRef child; + { + auto read = graph.tryRead(); + parent = read->find({NodeKind::Thread, "parent"}); + child = read->find({NodeKind::Thread, "child"}); + } + NodeAction first{child, NodeActionKind::SubmitPrompt}; + first.promptText = "promote child root"; + static_cast(logic.admitPrompt(std::move(first), 20)); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + require( + signedFieldEquals(read->state(child), "localPromptActivityAt", 31) && + signedFieldEquals(read->state(parent), "localPromptActivityAt", + 31) && + signedFieldEquals(read->state(parent), "localActivityAt", 31), + "prompt admission advances beyond every provider sort key and " + "propagates to its visible root group"); + } + + static_cast(logic.applyDetailed( + {DecodedMessageKind::ServerNotification, + "turn/started", + std::nullopt, + Value::Object{ + {"threadId", Value("child")}, + {"turn", Value(Value::Object{{"id", Value("activity-turn")}})}}, + {}, + 40})); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + require( + signedFieldEquals(read->state(child), "localActivityAt", 40) && + signedFieldEquals(read->state(parent), "localActivityAt", 40) && + signedFieldEquals(read->state(parent), "localPromptActivityAt", 31), + "meaningful decoded traffic advances heading activity without " + "rewriting prompt ordering state"); + } + + static_cast(logic.applyDetailed( + {DecodedMessageKind::ClientResult, + "thread/read", + ProtocolRequestId("hydration"), + Value::Object{{"thread", Value(Value::Object{{"id", Value("child")}})}}, + {}, + std::nullopt})); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + require(signedFieldEquals(read->state(parent), "localActivityAt", 40), + "selection-driven hydration does not count as live activity"); + } +} + +void localPromptsAreGraphNodesAndDispatchPerThread() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + for (const std::string_view id : {"prompt-thread", "independent-thread"}) { + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{{"id", Value(id)}})}}})); + static_cast(takeWorkerMessages(channels)); + } + NodeRef thread; + NodeRef independent; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "prompt-thread"}); + independent = read->find({NodeKind::Thread, "independent-thread"}); + } + + NodeAction first; + first.target = thread; + first.kind = NodeActionKind::SubmitPrompt; + first.promptText = std::string(4096, 'p'); + first.promptText.front() = 'A'; + first.attachments.push_back({"/tmp/prompt.png", "prompt.png", "image/png", + std::vector(4096, 7)}); + first.attachments.push_back( + {"/tmp/report #?.txt", "report[1].txt", "text/plain", std::nullopt}); + first.payload.emplace("model", Value("gpt-current")); + const char *const textStorage = first.promptText.data(); + const std::uint8_t *const bytesStorage = + first.attachments.front().bytes->data(); + PromptTransition admitted = logic.admitPrompt(std::move(first)); + require(admitted.command && + admitted.command->kind == PromptCommandKind::StartTurn && + admitted.command->thread == thread && + admitted.command->promptText.data() == textStorage && + admitted.command->attachments.front().bytes->data() == + bytesStorage, + "first prompt moves its exact large text and attachment storage into " + "a start-turn command"); + const NodeRef firstPrompt = + admitted.command ? admitted.command->localPrompt : NodeRef{}; + const std::string firstClientId = + admitted.command ? admitted.command->clientUserMessageId : std::string{}; + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef localTurn = read->parent(firstPrompt); + const auto state = read->state(firstPrompt); + const Value *text = field(state, "text"); + const Value *dispatch = field(state, "dispatchState"); + require( + firstPrompt && localTurn && + localTurn->id().canonical.starts_with("local-turn:") && + read->parent(localTurn) == thread && text && text->asString() && + text->asString()->starts_with(admitted.command->promptText) && + text->asString()->ends_with("Attached files:\n- [report\\[1\\].txt]" + "(file:///tmp/report%20%23%3F.txt)") && + dispatch && dispatch->asString() && + *dispatch->asString() == "dispatching" && + boolFieldEquals(state, "showPendingAnimation", true) && + read->related(runtime, RelationKind::PendingPrompt) == + std::vector{firstPrompt} && + read->related(thread, RelationKind::PendingPrompt) == + std::vector{firstPrompt} && + read->related(localTurn, RelationKind::TurnRootItem) == + std::vector{firstPrompt}, + "admission stores one directly-related local prompt with the " + "same safe file Markdown sent on the wire and makes its You card the " + "owning turn root"); + } + + NodeAction queued; + queued.target = thread; + queued.kind = NodeActionKind::SubmitPrompt; + queued.promptText = "second exact prompt"; + PromptTransition second = logic.admitPrompt(std::move(queued)); + require(!second.command, + "a second prompt for the same thread remains queued while one " + "request is in flight"); + static_cast(takeWorkerMessages(channels)); + + NodeAction parallel; + parallel.target = independent; + parallel.kind = NodeActionKind::SubmitPrompt; + parallel.promptText = "independent prompt"; + PromptTransition independentAdmission = + logic.admitPrompt(std::move(parallel)); + require(independentAdmission.command && + independentAdmission.command->thread == independent, + "different threads dispatch independently"); + static_cast(takeWorkerMessages(channels)); + + require(logic.markPromptDispatched(firstPrompt, + ProtocolRequestId("turn-request-1")) == + ChannelSendStatus::Accepted, + "the direct bridge request marks only its exact local prompt"); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("prompt-thread")}, + {"turn", Value(Value::Object{{"id", Value("authoritative-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(takeWorkerMessages(channels)); + + PromptTransition completed = + logic.completePrompt(firstPrompt, true, {}, "authoritative-turn"); + require(completed.command && + completed.command->kind == PromptCommandKind::SteerTurn && + completed.command->expectedTurnId == "authoritative-turn" && + completed.command->promptText == "second exact prompt", + "a successful request releases exactly the next same-thread prompt " + "as a steer command"); + static_cast(takeWorkerMessages(channels)); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "item/started", std::nullopt, + Value::Object{{"threadId", Value("prompt-thread")}, + {"turnId", Value("authoritative-turn")}, + {"item", Value(Value::Object{ + {"id", Value("user-item")}, + {"type", Value("userMessage")}, + {"clientId", Value(firstClientId)}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef authoritative; + { + auto read = graph.tryRead(); + authoritative = read->find(scopedItemNodeId( + scopedTurnNodeId("prompt-thread", "authoritative-turn"), "user-item")); + require( + authoritative && + read->related(authoritative, RelationKind::PromptMaterialization) == + std::vector{firstPrompt} && + read->related(read->parent(authoritative), + RelationKind::TurnRootItem) == + std::vector{authoritative} && + read->state(firstPrompt)->status == NodeStatus::Running && + stringFieldEquals(read->state(firstPrompt), "dispatchState", + "awaitingMaterialization") && + boolFieldEquals(read->state(firstPrompt), + "showPendingAnimation", true), + "matching authoritative clientId directly relates the user item " + "to its active local visual identity and transfers canonical " + "turn-root ownership without stopping feedback before UI " + "materialization"); + } + + const ChannelSendStatus removed = logic.promptMaterialized(firstPrompt); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const std::vector pending = + read->related(runtime, RelationKind::PendingPrompt); + require( + removed == ChannelSendStatus::Accepted && + !read->find(firstPrompt->id()) && authoritative && + read->related(authoritative, RelationKind::PromptMaterialization) + .empty() && + std::ranges::find(pending, firstPrompt) == pending.end(), + "Qt materialization acknowledgement removes the local node and " + "all of its graph relations"); + } +} + +void earlyMaterializationWaitsForTheExactRequestResult() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{{"id", Value("early-materialization")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "early-materialization"}); + } + + NodeAction first{thread, NodeActionKind::SubmitPrompt}; + first.promptText = "first request"; + PromptTransition admitted = logic.admitPrompt(std::move(first)); + require(admitted.command.has_value(), "the first request is dispatched"); + if (!admitted.command) + return; + const NodeRef firstPrompt = admitted.command->localPrompt; + static_cast(takeWorkerMessages(channels)); + + NodeAction second{thread, NodeActionKind::SubmitPrompt}; + second.promptText = "second request"; + require(!logic.admitPrompt(std::move(second)).command, + "the second request waits behind the first request result"); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.markPromptDispatched( + firstPrompt, ProtocolRequestId("early-result"))); + static_cast(takeWorkerMessages(channels)); + + require(logic.promptMaterialized(firstPrompt) == ChannelSendStatus::Accepted, + "an early authoritative widget handoff is acknowledged"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef retained = read->find(firstPrompt->id()); + const Value *materialized = + retained ? field(read->state(retained), "uiMaterialized") : nullptr; + require(retained == firstPrompt && materialized && materialized->asBool() && + *materialized->asBool(), + "the current local node retains the dispatch slot until its exact " + "JSON-RPC result"); + } + + PromptTransition completed = logic.completePrompt(firstPrompt, true); + static_cast(takeWorkerMessages(channels)); + require(completed.command && + completed.command->promptText == "second request", + "the exact result advances one queued prompt after early " + "materialization"); + { + auto read = graph.tryRead(); + require(!read->find(firstPrompt->id()), + "the handed-off local node retires with the completed request"); + } +} + +void turnStartResultMakesTheAcceptedTurnActiveBeforeQueueAdvance() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("result-before-event")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "result-before-event"}); + } + + NodeAction first{thread, NodeActionKind::SubmitPrompt}; + first.promptText = "start"; + PromptTransition admitted = logic.admitPrompt(std::move(first)); + static_cast(takeWorkerMessages(channels)); + NodeAction second{thread, NodeActionKind::SubmitPrompt}; + second.promptText = "queued steering"; + static_cast(logic.admitPrompt(std::move(second))); + static_cast(takeWorkerMessages(channels)); + + PromptTransition next = logic.completePrompt(admitted.command->localPrompt, + true, {}, "turn-from-result"); + static_cast(takeWorkerMessages(channels)); + require(next.command && next.command->kind == PromptCommandKind::SteerTurn && + next.command->expectedTurnId == "turn-from-result", + "an accepted turn/start result marks its turn active before the " + "next exact queued prompt is selected"); + { + auto read = graph.tryRead(); + const NodeRef turn = + read->find(scopedTurnNodeId("result-before-event", "turn-from-result")); + require(turn && read->state(turn)->status == NodeStatus::Running, + "the result-created turn remains current until terminal traffic"); + } +} + +void combinedResultsPublishOneAtomicGraphChange() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{{"id", Value("atomic-result-thread")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "atomic-result-thread"}); + } + + NodeAction promptAction{thread, NodeActionKind::SubmitPrompt}; + promptAction.promptText = "atomic prompt"; + PromptTransition admitted = logic.admitPrompt(std::move(promptAction)); + require(admitted.command.has_value(), + "atomic prompt result setup admits the exact prompt"); + if (!admitted.command) + return; + const NodeRef localPrompt = admitted.command->localPrompt; + static_cast(takeWorkerMessages(channels)); + + const ProtocolRequestId turnRequestId("atomic-turn-result"); + WorkerApplyResult turnRequest = logic.applyDetailed( + {DecodedMessageKind::ClientRequest, "turn/start", turnRequestId, + Value::Object{{"threadId", Value("atomic-result-thread")}}}); + require(turnRequest.primary && + turnRequest.primary->id() == + NodeId{NodeKind::Operation, turnRequestId.canonical()}, + "turn request exposes its exact correlated operation"); + static_cast(takeWorkerMessages(channels)); + + const std::uint64_t beforePromptResult = graph.publishedRevision(); + Value::Array resultItems{Value(Value::Object{{"id", Value("reasoning")}, + {"type", Value("reasoning")}, + {"status", Value("running")}})}; + Value::Object resultTurn{{"id", Value("atomic-turn")}, + {"status", Value("inProgress")}, + {"items", Value(std::move(resultItems))}}; + DecodedMessage turnResult{ + DecodedMessageKind::ClientResult, "turn/start", turnRequestId, + Value::Object{{"turn", Value(std::move(resultTurn))}}, + turnRequest.primary}; + PromptTransition completed = logic.completePromptResult( + std::move(turnResult), localPrompt, true, {}, "atomic-turn"); + const std::vector promptMessages = + takeWorkerMessages(channels); + require(!completed.command && + graph.publishedRevision() == beforePromptResult + 1 && + promptMessages.size() == 1 && + std::holds_alternative(promptMessages.front()) && + std::get(promptMessages.front()).revision == + graph.publishedRevision(), + "operation retirement and prompt acceptance publish exactly one " + "graph revision and notification"); + { + auto read = graph.tryRead(); + const NodeRef turn = + read->find(scopedTurnNodeId("atomic-result-thread", "atomic-turn")); + const NodeRef reasoning = read->find(scopedItemNodeId( + scopedTurnNodeId("atomic-result-thread", "atomic-turn"), "reasoning")); + require(!read->find(turnRequest.primary->id()) && turn && reasoning && + read->children(turn) == + std::vector{localPrompt, reasoning} && + read->related(turn, RelationKind::TurnRootItem) == + std::vector{localPrompt} && + stringFieldEquals(read->state(localPrompt), "dispatchState", + "awaitingMaterialization") && + read->changedRevision(turn) == read->revision() && + read->changedRevision(localPrompt) == read->revision(), + "the single accepted-result revision contains the retired " + "operation, acknowledged prompt, and starting prompt ordered " + "ahead of provider reasoning with an explicit owning You root"); + } + + const ProtocolRequestId readRequestId("atomic-read-result"); + WorkerApplyResult readRequest = logic.applyDetailed( + {DecodedMessageKind::ClientRequest, "thread/read", readRequestId, + Value::Object{{"threadId", Value("atomic-result-thread")}}}); + require(static_cast(readRequest.primary), + "thread/read exposes its exact correlated operation"); + static_cast(takeWorkerMessages(channels)); + const std::uint64_t beforeReadResult = graph.publishedRevision(); + Value::Object hydratedThread{{"id", Value("atomic-result-thread")}, + {"name", Value("Hydrated atomically")}}; + DecodedMessage readResult{ + DecodedMessageKind::ClientResult, "thread/read", readRequestId, + Value::Object{{"thread", Value(std::move(hydratedThread))}}, + readRequest.primary}; + require(logic.completeThreadHydration(std::move(readResult), thread, + "ready") == ChannelSendStatus::Accepted, + "combined hydration result is admitted"); + const std::vector hydrationMessages = + takeWorkerMessages(channels); + require(graph.publishedRevision() == beforeReadResult + 1 && + hydrationMessages.size() == 1 && + std::holds_alternative(hydrationMessages.front()) && + std::get(hydrationMessages.front()).revision == + graph.publishedRevision(), + "thread/read replacement and hydration readiness publish exactly " + "one graph revision and notification"); + { + auto read = graph.tryRead(); + require( + !read->find(readRequest.primary->id()) && + stringFieldEquals(read->state(thread), "name", + "Hydrated atomically") && + stringFieldEquals(read->state(thread), "hydrationState", "ready") && + read->changedRevision(thread) == read->revision(), + "the one hydration revision exposes both provider state and " + "local readiness after retiring its operation"); + } +} + +void deletingAnAdmittedPromptPreservesExplicitRecovery() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{ + {"thread", + Value(Value::Object{{"id", Value("deleted-prompt-thread")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "deleted-prompt-thread"}); + } + + NodeAction action{thread, NodeActionKind::SubmitPrompt}; + action.promptText = "keep this exact admitted prompt"; + PromptTransition admitted = logic.admitPrompt(std::move(action)); + require(admitted.command.has_value(), + "prompt is admitted before its destination is deleted"); + if (!admitted.command) + return; + const NodeRef prompt = admitted.command->localPrompt; + NodeRef formerTurn; + { + auto read = graph.tryRead(); + formerTurn = read->parent(prompt); + } + static_cast(takeWorkerMessages(channels)); + + const ProtocolRequestId requestId("deleted-prompt-result"); + WorkerApplyResult request = logic.applyDetailed( + {DecodedMessageKind::ClientRequest, "turn/start", requestId, + Value::Object{{"threadId", Value("deleted-prompt-thread")}}}); + require(static_cast(request.primary), + "admitted prompt request retains its exact operation"); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.markPromptDispatched(prompt, requestId)); + static_cast(takeWorkerMessages(channels)); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("deleted-prompt-thread")}}})); + const std::vector deletionMessages = + takeWorkerMessages(channels); + NodeRef recoveryThread; + NodeRef recoveryTurn; + { + auto read = graph.tryRead(); + recoveryTurn = read->parent(prompt); + recoveryThread = recoveryTurn ? read->parent(recoveryTurn) : NodeRef{}; + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const Value *requiresRecovery = + field(read->state(prompt), "requiresExplicitRecovery"); + require( + deletionMessages.size() == 1 && + std::holds_alternative(deletionMessages.front()) && + !read->find(thread->id()) && !read->find(formerTurn->id()) && + read->find(prompt->id()) == prompt && recoveryThread && + recoveryTurn && + recoveryThread->id().canonical.starts_with( + "local-recovery-thread:removed:") && + stringFieldEquals(read->state(prompt), "text", + "keep this exact admitted prompt") && + stringFieldEquals(read->state(prompt), "dispatchState", + "uncertain") && + requiresRecovery && requiresRecovery->asBool() && + *requiresRecovery->asBool() && request.primary && + read->find(request.primary->id()) == request.primary && + read->related(runtime, RelationKind::PendingPrompt) == + std::vector{prompt} && + read->related(recoveryThread, RelationKind::PendingPrompt) == + std::vector{prompt}, + "thread deletion removes the former owners while reparenting the " + "same authored NodeRef and text to explicit recovery"); + } + + const std::uint64_t beforeLateResult = graph.publishedRevision(); + PromptTransition late = logic.completePromptResult( + {DecodedMessageKind::ClientResult, "turn/start", requestId, + Value::Object{ + {"threadId", Value("deleted-prompt-thread")}, + {"turn", Value(Value::Object{{"id", Value("late-deleted-turn")}, + {"status", Value("inProgress")}})}}, + request.primary}, + prompt, true, {}, "late-deleted-turn"); + const std::vector lateMessages = + takeWorkerMessages(channels); + require(!late.command && graph.publishedRevision() == beforeLateResult + 1 && + lateMessages.size() == 1 && + std::holds_alternative(lateMessages.front()), + "late exact result publishes only its operation retirement"); + { + auto read = graph.tryRead(); + require(!read->find(request.primary->id()) && + !read->find({NodeKind::Thread, "deleted-prompt-thread"}) && + !read->find(scopedTurnNodeId("deleted-prompt-thread", + "late-deleted-turn")) && + read->find(prompt->id()) == prompt && + read->parent(prompt) == recoveryTurn && + read->parent(recoveryTurn) == recoveryThread && + read->state(prompt)->status == NodeStatus::Failed && + stringFieldEquals(read->state(prompt), "text", + "keep this exact admitted prompt") && + stringFieldEquals(read->state(prompt), "dispatchState", + "uncertain"), + "a late result cannot recreate the deleted destination or " + "silently acknowledge its recovered prompt"); + } +} + +void activeAgentChildrenAreCurrentAndDeduplicated() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + NodeRef parent; + NodeRef activeChild; + NodeRef fieldActiveChild; + { + auto write = graph.write(); + parent = write.upsert({NodeKind::Thread, "agent-parent"}); + NodeRef turn = write.upsert({NodeKind::Turn, "agent-parent-turn"}); + NodeRef pending = write.upsert({NodeKind::Item, "pending-agent-item"}); + NodeRef running = write.upsert({NodeKind::Item, "running-agent-item"}); + NodeRef fieldActive = + write.upsert({NodeKind::Item, "field-active-agent-item"}); + NodeRef completed = write.upsert({NodeKind::Item, "completed-agent-item"}); + NodeRef unrelated = write.upsert({NodeKind::Item, "unrelated-agent-item"}); + activeChild = write.upsert({NodeKind::Thread, "active-agent-child"}); + fieldActiveChild = + write.upsert({NodeKind::Thread, "field-active-agent-child"}); + NodeRef inactiveChild = + write.upsert({NodeKind::Thread, "inactive-agent-child"}); + write.setParent(parent, turn); + for (const NodeRef &item : + {pending, running, fieldActive, completed, unrelated}) + write.setParent(turn, item); + write.setStatus(pending, NodeStatus::Pending); + write.setStatus(running, NodeStatus::Running); + write.setField(fieldActive, "status", Value("inProgress")); + write.setStatus(completed, NodeStatus::Completed); + write.relate(pending, RelationKind::AgentChildThread, activeChild); + write.relate(running, RelationKind::AgentChildThread, activeChild); + write.relate(fieldActive, RelationKind::AgentChildThread, fieldActiveChild); + write.relate(completed, RelationKind::AgentChildThread, inactiveChild); + write.relate(unrelated, RelationKind::StructuralChildThread, inactiveChild); + static_cast(write.finish()); + } + + const std::uint64_t before = graph.publishedRevision(); + const std::vector children = logic.activeAgentChildren(parent); + require(children == std::vector{activeChild, fieldActiveChild} && + graph.publishedRevision() == before && + channels.workerToQtSizeApprox() == 0, + "activeAgentChildren returns only live item relations, preserves " + "first-seen order, deduplicates NodeRefs, and is revision-neutral"); +} + +void inactiveThreadStartsInsteadOfSteeringStaleHistory() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("idle-prompt-thread")}})}}})); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("idle-prompt-thread")}, + {"turn", Value(Value::Object{{"id", Value("stale-running-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/status/changed", + std::nullopt, + Value::Object{ + {"threadId", Value("idle-prompt-thread")}, + {"status", Value(Value::Object{{"type", Value("idle")}})}}})); + static_cast(takeWorkerMessages(channels)); + + NodeRef thread; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "idle-prompt-thread"}); + } + NodeAction prompt{thread, NodeActionKind::SubmitPrompt}; + prompt.promptText = "new turn after idle"; + const PromptTransition transition = logic.admitPrompt(std::move(prompt)); + require(transition.command && + transition.command->kind == PromptCommandKind::StartTurn, + "prompt admission trusts the maintained active-turn relation and " + "never steers a stale Running child after the thread becomes idle"); +} + +void stalePromptTargetsRetainAuthoredInputInRecoveryNodes() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("removed-destination")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef staleThread; + { + auto read = graph.tryRead(); + staleThread = read->find({NodeKind::Thread, "removed-destination"}); + } + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/deleted", std::nullopt, + Value::Object{{"threadId", Value("removed-destination")}}})); + static_cast(takeWorkerMessages(channels)); + + NodeAction action{staleThread, NodeActionKind::SubmitPrompt}; + action.promptText = "retain this authored prompt"; + action.attachments.push_back( + {"/tmp/recovery.png", "recovery.png", "image/png", std::nullopt}); + PromptTransition retained = logic.admitPrompt(std::move(action)); + require(!retained.command, + "a stale target is never dispatched by canonical id"); + const std::vector messages = takeWorkerMessages(channels); + require(std::ranges::any_of(messages, + [](const WorkerToQtMessage &message) { + const auto *effect = + std::get_if(&message); + return effect && + effect->kind == UiEffectKind::ShowNotice; + }), + "the rejected stale target is reported visibly"); + + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const std::vector roots = + read->related(runtime, RelationKind::RootThread); + const NodeRef recovery = + roots.empty() || !roots.front()->id().canonical.starts_with( + "local-recovery-thread:") + ? NodeRef{} + : roots.front(); + const NodeRef turn = recovery && read->childCount(recovery) != 0 + ? read->childAt(recovery, 0) + : NodeRef{}; + const NodeRef prompt = + turn && read->childCount(turn) != 0 ? read->childAt(turn, 0) : NodeRef{}; + const auto state = prompt ? read->state(prompt) : nullptr; + const Value *attachments = state ? field(state, "attachments") : nullptr; + require(recovery && turn && prompt && state && + state->status == NodeStatus::Failed && + stringFieldEquals(state, "text", "retain this authored prompt") && + stringFieldEquals(state, "dispatchState", "failed") && + attachments && attachments->asArray() && + attachments->asArray()->size() == 1 && + read->related(runtime, RelationKind::PendingPrompt) == + std::vector{prompt}, + "worker-side rejection keeps exact authored text and attachment " + "metadata in a visible recovery graph node"); +} + +void recoveryOnlyTargetsCannotDispatchAndRetainAuthoredInput() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + NodeRef recoveryOnlyThread; + { + auto write = graph.write(); + NodeState state; + state.status = NodeStatus::Failed; + state.fields = {{"type", Value("localRecoveryThread")}, + {"local", Value(true)}, + {"recoveryOnly", Value(true)}, + {"name", Value("Unsent prompt")}}; + recoveryOnlyThread = write.upsert( + {NodeKind::Thread, "local-recovery-thread:existing"}, std::move(state)); + NodeRef runtime = write.upsert({NodeKind::Runtime, "runtime"}); + write.relate(runtime, RelationKind::RootThread, recoveryOnlyThread); + static_cast(write.finish()); + } + + NodeAction action{recoveryOnlyThread, NodeActionKind::SubmitPrompt}; + action.promptText = "do not dispatch this recovery text"; + action.attachments.push_back({"/tmp/recovery-again.txt", "recovery-again.txt", + "text/plain", std::nullopt}); + PromptTransition retained = logic.admitPrompt(std::move(action)); + require(!retained.command, + "a live recovery-only target cannot produce a provider command"); + const std::vector messages = takeWorkerMessages(channels); + require(std::ranges::any_of( + messages, + [](const WorkerToQtMessage &message) { + const auto *effect = std::get_if(&message); + return effect && effect->kind == UiEffectKind::ShowNotice && + effect->text.find("Restore") != std::string::npos; + }), + "worker-side recovery-only rejection is reported visibly"); + + auto read = graph.tryRead(); + NodeRef retainedPrompt; + for (const NodeRef &node : read->orderedNodes()) { + if (node->id().kind != NodeKind::Item) + continue; + const auto state = read->state(node); + if (stringFieldEquals(state, "authoredText", + "do not dispatch this recovery text")) { + retainedPrompt = node; + break; + } + } + const NodeRef retainedTurn = + retainedPrompt ? read->parent(retainedPrompt) : NodeRef{}; + const NodeRef retainedThread = + retainedTurn ? read->parent(retainedTurn) : NodeRef{}; + const auto state = retainedPrompt ? read->state(retainedPrompt) : nullptr; + const Value *attachments = state ? field(state, "attachments") : nullptr; + const Value *requiresRecovery = + state ? field(state, "requiresExplicitRecovery") : nullptr; + require(retainedPrompt && retainedThread && + retainedThread != recoveryOnlyThread && + retainedThread->id().canonical.starts_with( + "local-recovery-thread:") && + state->status == NodeStatus::Failed && + stringFieldEquals(state, "dispatchState", "failed") && + stringFieldEquals( + state, "error", + "Restore this unsent prompt before sending it again") && + attachments && attachments->asArray() && + attachments->asArray()->size() == 1 && requiresRecovery && + requiresRecovery->asBool() && *requiresRecovery->asBool(), + "recovery-only rejection keeps exact authored input in a separate " + "explicit recovery node"); +} + +void failedHydrationTargetsRetainAuthoredInput() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + NodeRef thread; + { + auto write = graph.write(); + NodeState state; + state.status = NodeStatus::NotLoaded; + state.fields = {{"hydrationState", Value("failed")}, + {"hydrationError", Value("history unavailable")}}; + thread = + write.upsert({NodeKind::Thread, "failed-hydration"}, std::move(state)); + static_cast(write.finish()); + } + + NodeAction action{thread, NodeActionKind::SubmitPrompt}; + action.promptText = "keep after a stale visible frame"; + action.attachments.push_back( + {"/tmp/retained.txt", "retained.txt", "text/plain", std::nullopt}); + PromptTransition transition = logic.admitPrompt(std::move(action)); + require(!transition.command, + "failed hydration is revalidated before provider dispatch"); + static_cast(takeWorkerMessages(channels)); + auto read = graph.tryRead(); + NodeRef retainedPrompt; + for (const NodeRef &node : read->orderedNodes()) { + if (node->id().kind == NodeKind::Item && + stringFieldEquals(read->state(node), "authoredText", + "keep after a stale visible frame")) { + retainedPrompt = node; + break; + } + } + const auto state = retainedPrompt ? read->state(retainedPrompt) : nullptr; + const Value *attachments = field(state, "attachments"); + require(retainedPrompt && state->status == NodeStatus::Failed && + stringFieldEquals(state, "dispatchState", "failed") && + attachments && attachments->asArray() && + attachments->asArray()->size() == 1, + "failed hydration keeps the moved prompt and attachment metadata " + "in explicit recovery state"); +} + +void firstPromptCreatesAndMigratesOneDraftThread() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + RuntimeAction action; + action.kind = RuntimeActionKind::CreateThread; + action.promptText = "first prompt exactly"; + action.payload = { + {"threadStart", Value(Value::Object{{"cwd", Value("/workspace")}, + {"model", Value("gpt-current")}})}, + {"turnStart", + Value(Value::Object{{"approvalPolicy", Value("on-request")}})}, + {"requestedName", Value("Named locally")}}; + PromptTransition admitted = logic.admitFirstPrompt(std::move(action)); + require(admitted.command && + admitted.command->kind == PromptCommandKind::CreateThread && + admitted.command->requestedName == "Named locally" && + admitted.command->options.contains("cwd") && + !admitted.command->options.contains("requestedName") && + admitted.command->turnOptions.contains("approvalPolicy"), + "new-thread admission separates exact thread, turn, and rename " + "parameters"); + const NodeRef draft = admitted.command ? admitted.command->thread : NodeRef{}; + const NodeRef localPrompt = + admitted.command ? admitted.command->localPrompt : NodeRef{}; + const std::vector admissionMessages = + takeWorkerMessages(channels); + require(std::ranges::any_of( + admissionMessages, + [&](const auto &message) { + const UiEffect *effect = std::get_if(&message); + return effect && effect->kind == UiEffectKind::SelectThread && + effect->target == std::optional(draft); + }), + "new-thread admission selects its materialized local draft NodeRef"); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + require(draft && draft->id().canonical.starts_with("local-thread:") && + read->related(runtime, RelationKind::RootThread).front() == + draft && + read->parent(read->parent(localPrompt)) == draft, + "the first prompt is immediately renderable under one draft " + "thread and provisional turn"); + } + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("created-thread")}, + {"name", Value("Provider default")}})}}})); + static_cast(takeWorkerMessages(channels)); + require(admitted.command && + logic.attachCreatedThread(*admitted.command, "created-thread") == + ChannelSendStatus::Accepted, + "thread/start result migrates the draft prompt atomically"); + const std::vector migrationMessages = + takeWorkerMessages(channels); + { + auto read = graph.tryRead(); + const NodeRef actual = read->find({NodeKind::Thread, "created-thread"}); + require(admitted.command->kind == PromptCommandKind::StartTurn && + admitted.command->thread == actual && + admitted.command->options.contains("approvalPolicy") && + !read->find(draft->id()) && + read->parent(read->parent(localPrompt)) == actual && + read->related(actual, RelationKind::PendingPrompt) == + std::vector{localPrompt} && + std::ranges::any_of( + migrationMessages, + [&](const auto &message) { + const UiEffect *effect = std::get_if(&message); + return effect && + effect->kind == UiEffectKind::SelectThread && + effect->target == std::optional(actual); + }), + "migration removes the draft shell, selects the canonical thread, " + "and changes the retained command to turn/start"); + } + + NodeGraph failedGraph; + ThreadChannels failedChannels; + WorkerLogic failedLogic(failedGraph, failedChannels); + RuntimeAction failingCreate; + failingCreate.kind = RuntimeActionKind::CreateThread; + failingCreate.correlation = "failing-draft"; + failingCreate.promptText = "first unsent draft"; + failingCreate.payload = { + {"requestedName", Value("Recover this name")}, + {"threadStart", + Value( + Value::Object{{"cwd", Value("/recovery-workspace")}, + {"baseInstructions", Value("base recovery")}, + {"developerInstructions", Value("developer recovery")}, + {"ephemeral", Value(true)}})}, + {"turnStart", Value(Value::Object{{"approvalPolicy", Value("never")}})}}; + PromptTransition firstDraft = + failedLogic.admitFirstPrompt(std::move(failingCreate)); + require(firstDraft.command.has_value(), + "failing draft is admitted before its provider failure"); + if (!firstDraft.command) + return; + NodeAction laterDraft; + laterDraft.kind = NodeActionKind::SubmitPrompt; + laterDraft.target = firstDraft.command->thread; + laterDraft.promptText = "second unsent draft"; + PromptTransition queuedDraft = failedLogic.admitPrompt(std::move(laterDraft)); + require(!queuedDraft.command, + "later input queues behind the in-flight draft creation"); + PromptTransition failed = failedLogic.failPrompt( + firstDraft.command->localPrompt, "thread creation failed"); + { + auto read = failedGraph.tryRead(); + const std::vector prompts = + read->related(firstDraft.command->thread, RelationKind::PendingPrompt); + const auto firstState = prompts.empty() ? std::shared_ptr{} + : read->state(prompts.front()); + const Value *threadOptions = field(firstState, "threadStartOptions"); + require(!failed.command && prompts.size() == 2 && + std::ranges::all_of(prompts, + [&](const NodeRef &prompt) { + return read->state(prompt)->status == + NodeStatus::Failed && + stringFieldEquals( + read->state(prompt), + "dispatchState", "failed"); + }), + "failed thread creation keeps every authored draft visible and " + "never auto-dispatches one against a local thread id"); + require(firstState && + stringFieldEquals(firstState, "requestedName", + "Recover this name") && + stringFieldEquals(firstState, "creationCorrelation", + "failing-draft") && + threadOptions && threadOptions->asObject() && + threadOptions->asObject()->contains("baseInstructions") && + threadOptions->asObject()->contains("developerInstructions") && + threadOptions->asObject()->contains("ephemeral"), + "failed creation retains every modal-authored option for explicit " + "recovery"); + } +} + +void creationCorrelationSharesExactlyOneDraft() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + RuntimeAction first; + first.kind = RuntimeActionKind::CreateThread; + first.correlation = "draft-token"; + first.promptText = "first correlated prompt"; + first.payload = { + {"threadStart", Value(Value::Object{{"cwd", Value("/workspace")}})}, + {"turnStart", + Value(Value::Object{{"approvalPolicy", Value("on-request")}})}}; + PromptTransition admitted = logic.admitFirstPrompt(std::move(first)); + require(admitted.command && + admitted.command->kind == PromptCommandKind::CreateThread, + "the first correlated action owns thread creation"); + if (!admitted.command) + return; + const NodeRef draft = admitted.command->thread; + static_cast(takeWorkerMessages(channels)); + + RuntimeAction second; + second.kind = RuntimeActionKind::CreateThread; + second.correlation = "draft-token"; + second.promptText = "second correlated prompt"; + second.payload = { + {"threadStart", Value(Value::Object{{"cwd", Value("/workspace")}})}, + {"turnStart", Value(Value::Object{{"model", Value("gpt-current")}})}}; + PromptTransition queued = logic.admitFirstPrompt(std::move(second)); + require(!queued.command, + "a second action for the same draft queues behind one creation"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const std::vector prompts = + read->related(draft, RelationKind::PendingPrompt); + require(read->related(runtime, RelationKind::RootThread) == + std::vector{draft} && + prompts.size() == 2, + "same-correlation actions create one local thread with both " + "stable prompt nodes"); + } + + require(logic.attachCreatedThread(*admitted.command, "correlated-thread") == + ChannelSendStatus::Accepted, + "the single draft is promoted to its canonical thread"); + static_cast(takeWorkerMessages(channels)); + PromptTransition next = logic.completePrompt(admitted.command->localPrompt, + true, {}, "correlated-turn"); + require(next.command && next.command->thread && + next.command->thread->id().canonical == "correlated-thread" && + (next.command->kind == PromptCommandKind::StartTurn || + next.command->kind == PromptCommandKind::SteerTurn) && + next.command->promptText == "second correlated prompt", + "promotion advances the second prompt only against the canonical " + "thread without a second thread/start"); + + RuntimeAction distinct; + distinct.kind = RuntimeActionKind::CreateThread; + distinct.correlation = "other-draft-token"; + distinct.promptText = "independent draft"; + PromptTransition separate = logic.admitFirstPrompt(std::move(distinct)); + require(separate.command && + separate.command->kind == PromptCommandKind::CreateThread && + separate.command->thread != next.command->thread, + "a distinct correlation creates an independent draft"); +} + +void providerGenerationResetIsAtomicAndKeepsOnlyRecoveryPrompts() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + static_cast(logic.connectionSettings( + {{"selected", Value("unix")}, {"endpoint", Value("local")}})); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.transportEvent("connected")); + static_cast(takeWorkerMessages(channels)); + static_cast( + logic.bridgeState("bridge", "controller", "bridge", 7, "ready")); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "thread/started", std::nullopt, + Value::Object{{"thread", Value(Value::Object{ + {"id", Value("reset-thread")}, + {"name", Value("Provider fact")}})}}})); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("reset-thread")}, + {"turn", Value(Value::Object{{"id", Value("reset-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef thread; + NodeRef turn; + { + auto read = graph.tryRead(); + thread = read->find({NodeKind::Thread, "reset-thread"}); + turn = read->find(scopedTurnNodeId("reset-thread", "reset-turn")); + } + NodeAction action; + action.kind = NodeActionKind::SubmitPrompt; + action.target = thread; + action.promptText = "recover this exact text"; + PromptTransition prompt = logic.admitPrompt(std::move(action)); + const NodeRef localPrompt = + prompt.command ? prompt.command->localPrompt : NodeRef{}; + static_cast(takeWorkerMessages(channels)); + static_cast( + logic.apply({DecodedMessageKind::ClientRequest, "model/list", + ProtocolRequestId("pending-catalog"), Value::Object{}})); + static_cast(takeWorkerMessages(channels)); + static_cast(logic.apply( + {DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", ProtocolRequestId("pending"), + Value::Object{{"threadId", Value("reset-thread")}}})); + static_cast(takeWorkerMessages(channels)); + NodeRef interaction; + { + auto read = graph.tryRead(); + interaction = read->find( + {NodeKind::Interaction, ProtocolRequestId("pending").canonical()}); + } + + const std::uint64_t before = graph.publishedRevision(); + require(logic.bridgeState("bridge", "controller", "bridge", 8, "ready", + "provider replaced") == ChannelSendStatus::Accepted, + "new provider generation is admitted"); + const std::vector resetMessages = + takeWorkerMessages(channels); + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef connection = read->find({NodeKind::Connection, "connection"}); + const NodeRef owner = read->find({NodeKind::Thread, "reset-thread"}); + const NodeRef recoveryTurn = read->parent(localPrompt); + const NodeRef recoveryOwner = + recoveryTurn ? read->parent(recoveryTurn) : NodeRef{}; + bool onlyAllowed = true; + for (const NodeRef &node : read->orderedNodes()) { + onlyAllowed &= node->id().kind == NodeKind::Runtime || + node->id().kind == NodeKind::Connection || + node->id().kind == NodeKind::Thread || + node->id().kind == NodeKind::Turn || + node->id().kind == NodeKind::Item || + node->id().kind == NodeKind::Interaction; + } + const auto promptState = read->state(localPrompt); + const Value *recovery = field(promptState, "requiresExplicitRecovery"); + const Value *settings = field(read->state(connection), "settings"); + require( + read->revision() == before + 1 && resetMessages.size() == 1 && + std::holds_alternative(resetMessages.front()) && + onlyAllowed && runtime && connection && !owner && + !read->find(scopedTurnNodeId("reset-thread", "reset-turn")) && + localPrompt && recoveryOwner && + recoveryOwner->id().canonical.starts_with( + "local-recovery-thread:") && + recoveryTurn->id().canonical.starts_with("local-recovery-turn:") && + !read->find({NodeKind::Operation, + ProtocolRequestId("pending-catalog").canonical()}) && + read->find({NodeKind::Interaction, + ProtocolRequestId("pending").canonical()}) == + interaction && + read->state(interaction)->status == NodeStatus::Failed && + stringFieldEquals(read->state(interaction), "error", + "provider replaced") && + field(read->state(interaction), "recoveryOnly") && + field(read->state(interaction), "recoveryOnly")->asBool() && + *field(read->state(interaction), "recoveryOnly")->asBool() && + read->related(interaction, RelationKind::InteractionTarget) + .empty() && + promptState->status == NodeStatus::Failed && + stringFieldEquals(promptState, "dispatchState", "uncertain") && + recovery && recovery->asBool() && *recovery->asBool() && settings && + settings->asObject() && + read->related(runtime, RelationKind::PendingPrompt) == + std::vector{localPrompt} && + read->related(runtime, RelationKind::PendingInteraction) == + std::vector{interaction} && + read->related(recoveryOwner, RelationKind::PendingPrompt) == + std::vector{localPrompt}, + "provider reset publishes one complete revision, removes stale " + "canonical owners and work, preserves settings, and retains only " + "explicit user-input recovery state"); + } + require(logic.generations() == WorkerGenerations{1, 8}, + "callback generation snapshot advances with the atomic reset"); + + static_cast(logic.apply( + {DecodedMessageKind::ServerNotification, "turn/started", std::nullopt, + Value::Object{ + {"threadId", Value("reset-thread")}, + {"turn", Value(Value::Object{{"id", Value("reset-turn")}, + {"status", Value("inProgress")}})}}})); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef reusedThread = read->find({NodeKind::Thread, "reset-thread"}); + const NodeRef reusedTurn = + read->find(scopedTurnNodeId("reset-thread", "reset-turn")); + const auto threadState = read->state(reusedThread); + const auto turnState = read->state(reusedTurn); + const NodeRef recoveryOwner = read->parent(read->parent(localPrompt)); + require( + reusedThread && reusedThread != thread && reusedTurn && + reusedTurn != turn && !field(threadState, "local") && + !field(threadState, "recoveryOnly") && !field(turnState, "local") && + recoveryOwner && recoveryOwner != reusedThread && + read->related(reusedThread, RelationKind::PendingPrompt).empty(), + "reused provider Thread/Turn ids allocate fresh nodes without " + "inheriting recovery state or stealing the retained prompt"); + } + + const WorkerApplyResult reusedRequest = logic.applyDetailed( + {DecodedMessageKind::ServerRequest, + "item/commandExecution/requestApproval", ProtocolRequestId("pending"), + Value::Object{{"threadId", Value("reset-thread")}, + {"turnId", Value("reset-turn")}}}); + static_cast(takeWorkerMessages(channels)); + const NodeRef currentInteraction = reusedRequest.primary; + { + auto read = graph.tryRead(); + const NodeRef runtime = read->find({NodeKind::Runtime, "runtime"}); + const NodeRef reusedThread = read->find({NodeKind::Thread, "reset-thread"}); + const auto currentState = + currentInteraction ? read->state(currentInteraction) : nullptr; + const Value *pendingCount = reusedThread ? field(read->state(reusedThread), + "pendingInteractionCount") + : nullptr; + require(currentInteraction && currentInteraction != interaction && + currentInteraction->id().canonical != + ProtocolRequestId("pending").canonical() && + read->find({NodeKind::Interaction, + ProtocolRequestId("pending").canonical()}) == + interaction && + read->state(interaction)->status == NodeStatus::Failed && + currentState->status == NodeStatus::Pending && + unsignedFieldEquals(currentState, "connectionGeneration", 1) && + unsignedFieldEquals(currentState, "providerGeneration", 8) && + runtime && + read->related(runtime, RelationKind::PendingInteraction) == + std::vector{interaction, currentInteraction} && + pendingCount && pendingCount->asUInt64() && + *pendingCount->asUInt64() == 1, + "a replacement provider can reuse a wire id without replacing the " + "older recovery interaction or conflating thread attention"); + } + + require(logic.resolveInteraction(currentInteraction, true) == + ChannelSendStatus::Accepted, + "the current generation interaction resolves by exact NodeRef"); + static_cast(takeWorkerMessages(channels)); + { + auto read = graph.tryRead(); + const NodeRef reusedThread = read->find({NodeKind::Thread, "reset-thread"}); + const Value *pendingCount = reusedThread ? field(read->state(reusedThread), + "pendingInteractionCount") + : nullptr; + require(read->find(interaction->id()) == interaction && + read->state(interaction)->status == NodeStatus::Failed && + !read->find(currentInteraction->id()) && pendingCount && + pendingCount->asUInt64() && *pendingCount->asUInt64() == 0, + "resolving the new generation leaves the older recovery record " + "intact and clears only current thread attention"); + } +} + +void providerTurnErrorsKeepTheirTypedNoticeEffect() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + require(logic.apply( + {DecodedMessageKind::ServerNotification, "error", std::nullopt, + Value::Object{ + {"threadId", Value("notice-error-thread")}, + {"turnId", Value("notice-error-turn")}, + {"error", Value(Value::Object{ + {"message", Value("provider retry failed")}})}, + {"willRetry", Value(false)}}}) == + ChannelSendStatus::Accepted, + "an addressed provider error publishes normally"); + const std::vector messages = takeWorkerMessages(channels); + const auto effect = + std::ranges::find_if(messages, [](const WorkerToQtMessage &message) { + const UiEffect *candidate = std::get_if(&message); + return candidate && candidate->kind == UiEffectKind::ShowNotice; + }); + require(effect != messages.end() && + std::get(*effect).text == "provider retry failed", + "the turn-error graph handler preserves the visible typed notice " + "effect and its provider message"); + + auto read = graph.tryRead(); + const NodeRef turn = + read->find(scopedTurnNodeId("notice-error-thread", "notice-error-turn")); + const auto state = turn ? read->state(turn) : nullptr; + const Value *willRetry = field(state, "willRetry"); + require(turn && field(state, "error") && willRetry && willRetry->asBool() && + !*willRetry->asBool(), + "the same worker transaction retains the addressed turn error " + "facts behind the notice effect"); +} + +void workerStoppedDeliveryIsExplicit() { + NodeGraph graph; + ThreadChannels channels; + WorkerLogic logic(graph, channels); + + require(logic.sendWorkerStopped("normal shutdown") == + ChannelSendStatus::Accepted, + "WorkerStopped is admitted normally"); + const EventFd::DrainResult wake = channels.drainWorkerToQtWake(); + WorkerToQtMessage message; + require(wake.status == EventFd::DrainStatus::Drained && wake.count == 1 && + channels.tryReceiveForQt(message) && + std::holds_alternative(message) && + std::get(message).reason == "normal shutdown", + "WorkerStopped arrives as the typed terminal message"); + + ThreadChannels closedChannels; + WorkerLogic closedLogic(graph, closedChannels); + closedChannels.close(); + const ChannelSendStatus failedWake = + closedLogic.sendWorkerStopped("wake already closed"); + require( + failedWake == ChannelSendStatus::QueueFull && + !messageAdmitted(failedWake) && !wakeFailed(failedWake) && + closedChannels.workerToQtSizeApprox() == 0, + "WorkerStopped rejects cleanly once both channel directions are closed"); +} + +} // namespace + +int main() { + protocolUpdatesPublishForUnlockedReads(); + connectionStateAndGenerationsStayCurrent(); + stateNeutralMessagesDoNotWakeQt(); + hydrationReadinessIsCurrentGraphState(); + hydrationRequiresUsableAuthoritativeItemType(); + graphNotificationSaturationCoalesces(); + uiDetachAcknowledgementIsRevisionNeutral(); + saturatedEffectsHaveCurrentGraphFallbacks(); + reverseInteractionResolutionUpdatesTheGraph(); + threadActivityAndPromptOrderingStayInTheGraph(); + localPromptsAreGraphNodesAndDispatchPerThread(); + earlyMaterializationWaitsForTheExactRequestResult(); + turnStartResultMakesTheAcceptedTurnActiveBeforeQueueAdvance(); + combinedResultsPublishOneAtomicGraphChange(); + deletingAnAdmittedPromptPreservesExplicitRecovery(); + activeAgentChildrenAreCurrentAndDeduplicated(); + inactiveThreadStartsInsteadOfSteeringStaleHistory(); + stalePromptTargetsRetainAuthoredInputInRecoveryNodes(); + recoveryOnlyTargetsCannotDispatchAndRetainAuthoredInput(); + failedHydrationTargetsRetainAuthoredInput(); + firstPromptCreatesAndMigratesOneDraftThread(); + creationCorrelationSharesExactlyOneDraft(); + providerGenerationResetIsAtomicAndKeepsOnlyRecoveryPrompts(); + providerTurnErrorsKeepTheirTypedNoticeEffect(); + workerStoppedDeliveryIsExplicit(); + + if (failures != 0) { + std::cerr << failures << " worker logic assertion(s) failed\n"; + return EXIT_FAILURE; + } + std::cout << "codexui worker logic tests passed\n"; + return EXIT_SUCCESS; +} diff --git a/ui-review/CODE-POLISH-ROADMAP.md b/ui-review/CODE-POLISH-ROADMAP.md index e36797c..6a20964 100644 --- a/ui-review/CODE-POLISH-ROADMAP.md +++ b/ui-review/CODE-POLISH-ROADMAP.md @@ -19,169 +19,74 @@ Complexity notation used below: | Symbol | Meaning | |---|---| -| `T` | Threads | | `C` | Visible conversation cards | | `P` | Retained prompt submissions | | `I` | Authoritative conversation items | -| `A` | Agents | | `R` | Repositories | | `F` | Changed files or watched paths | | `D` | Candidate directories | -| `Q` | Pending requests | -## High priority - -### Centralize thread and turn status classification - -**Effort: Medium** +## Cutover status -Define active, completed, failed and idle once, then use that definition for -thread dots, composer controls, conversation cards and the Inspector. This -prevents contradictory UI such as a completed gray thread still presenting -Steer and Stop. +The shared-node-graph cutover completed the architecture-dependent items from +the earlier roadmap: one canonical current graph now owns protocol state; +statuses share one native classification; resolved local prompts retire after +materialization; pending-request counts and child ownership are graph fields +and direct relations; the thread hierarchy renders recursively; and thread, +conversation, and Inspector scans are bounded across event-loop passes. The +legacy presentation model, serialized socketpair path, and its proposed cleanup +work no longer exist. -### Build an authoritative-item index +The remaining opportunities below are local widget or filesystem polish. They +must not introduce another model, projection layer, callback framework, or +execution thread. -**Effort: Medium** - -Index conversation items by stable key, client ID and position once per -projection. Prompt matching and anchor lookup can then use direct access -instead of repeatedly scanning the complete history. +## High priority -### Make prompt reconciliation linear +### Add direct client-message correlation lookup **Effort: Medium–High · Target: `O(P × I)` → approximately `O(P + I)`** -Every unresolved prompt currently searches authoritative history. Reuse the -projection index for exact identity, anchor and fallback matching while -preserving acknowledgement semantics and claimed-item ownership. - -### Compact fully resolved prompt submissions - -**Effort: Medium** - -Resolved submissions currently remain in future reconciliation work. Remove -them after their transition completes, or retain only the compact mapping -needed to preserve stable visual identity across reconstruction and navigation. +Canonical thread, turn, item, relation, and order lookup is already graph +native. Conversation reconciliation may still compare unresolved local prompts +with authoritative items. A concrete secondary `clientUserMessageId` lookup +could remove that repeated scan while preserving acknowledgement and stable-card +identity. ### Remove repeated Qt layout searches **Effort: High · Target: `O(C²)` → approximately `O(C)` for stable order** -`ConversationView` calls the linear `QLayout::indexOf()` operation for each -section and card during reconciliation. Retain known positions and avoid asking -the layout to rediscover an order CodexUI already owns. - -### Retain explicit section and card order - -**Effort: High** +`ConversationView` still uses linear `QLayout::indexOf()` checks while arranging +materialized sections and cards. Retaining positions for the bounded visible +window would avoid rediscovering order without changing graph authority, +placeholders, anchors, or focus ownership. -Compare desired order against retained order vectors and move widgets only at -changed positions. Content-only streaming updates should not traverse and -rearrange the entire layout; scroll anchors and command-output state must remain -stable. +### Keep protocol fixtures canonical -### Split shell integration scenarios - -**Effort: Medium** - -Separate start, steer, completion, hydration, recovery and navigation into -clearly named scenario functions. Smaller scenarios reduce accidental coupling -and make lifecycle failures attributable to one protocol sequence. - -### Provide canonical protocol fixtures - -**Effort: Low–Medium** +**Effort: Ongoing** -Fixture builders must guarantee unique turn IDs and valid combinations of -thread and turn status. This prevents impossible mock states from hiding real -defects or rejecting correct invariants. +Fixture builders must use unique scoped turn/item IDs and valid combinations +of thread and turn status. Protocol updates must reconcile the exact closed +157/11/83/1 method inventory rather than relying only on generated counts. ## Medium priority -### Consolidate per-thread runtime bookkeeping - -**Effort: Medium–High** - -Hydration, settings hydration, read revision, resume, dispatch and recovery are -currently represented by parallel maps and sets. Store them in one small -`ThreadRuntimeState` per thread to reduce synchronization mistakes and repeated -cleanup code without introducing a new subsystem. - -### Replace serialized JSON UI snapshots +### Precompute materialized thread-row positions **Effort: Medium** -Several render paths build and serialize JSON solely to detect visual changes. -Use small typed snapshot structures with equality instead; this removes -allocation, parsing-shaped code and untyped comparison logic. - -### Rebuild thread ordering in one pass - -**Effort: Low · Target: `O(T²)` → `O(T)`** +The graph topology and sorting passes are already bounded. If profiling shows +that `QListWidget` row lookup dominates large visible lists, retain positions +only for the materialized window and perform the minimum required moves. -`mergeThreadList()` repeatedly erases IDs from a vector. Use one membership set -and construct the resulting order once while preserving provider order and the -required retained tail. - -### Precompute thread-panel positions - -**Effort: Medium · Target: `O(T²)` → approximately `O(T)`** - -Repeated `QListWidget::row()` calls linearly rediscover current positions. -Retain or calculate row indices once per refresh, then perform only the moves -required by the desired order. - -### Aggregate request counts once - -**Effort: Low · Target: `O(T × Q)` → `O(T + Q)`** - -The thread panel repeatedly scans pending requests for individual threads. -Build one per-thread count map and reuse it for serialization and row updates. - -### Consolidate presentation helpers +### Consolidate repeated UI helpers **Effort: Low–Medium** -Status formatting, JSON string extraction and related classification are -repeated across source files. Move only genuinely shared semantics into the -existing presentation or UI support code. - -### Consolidate repeated styling - -**Effort: Low–Medium** - -Move repeated canonical colors, borders and semantic states into `UiStyle`. -Keep widget-specific geometry and genuinely exceptional presentation local to -the owning widget. - -## Thread-hierarchy follow-up - -These tasks belong with the planned structural parent/child thread work rather -than the current flat thread-panel polish. - -### Add a child-thread ownership index - -**Effort: Medium** - -Map each child thread directly to its owning agent and parent thread. Model -updates, removals and hydration must maintain this relationship consistently. - -### Use indexed agent correlation - -**Effort: Low–Medium · Target: repeated global agent scans → direct lookup** - -Child status and result updates currently scan child history and agents across -threads, potentially approaching `O(A²)` across many updates. Once the ownership -index exists, update the owning presentation directly. - -### Present structural thread hierarchy - -**Effort: High** - -Render child threads beneath their parent and support expansion, arbitrary -depth and navigation in both directions. This requires recursive presentation -state and dedicated hierarchy tests. +Move only genuinely shared status formatting, value extraction, colors, and +borders into the existing narrow `UiStatus`/`UiStyle` helpers. Widget-specific +geometry remains with the owning widget. ## Lower priority and profiling @@ -253,11 +158,12 @@ algorithms cover the identified problems. ### Add representative performance tests -**Effort: Medium–High** +**Effort: Ongoing** Exercise long histories, many visible cards, large thread lists and pending -requests. Prefer deterministic operation-count or benchmark evidence over -fragile wall-clock assertions where possible. +requests, extending the existing bounded-scan, mailbox-saturation, and Qt +heartbeat coverage. Prefer deterministic operation-count or benchmark evidence +over fragile wall-clock assertions where possible. ### Verify every refactoring step @@ -266,39 +172,3 @@ fragile wall-clock assertions where possible. Run focused tests for the affected invariant and the complete test suite after each step. Investigate every failure rather than classifying it as unrelated or flaky without evidence. - -## Recommended sequence - -1. Centralize status semantics and canonical fixtures. -2. Rebuild thread ordering and aggregate pending-request counts. -3. Index authoritative conversation items and simplify prompt reconciliation. -4. Compact resolved submissions. -5. Retain conversation order and remove quadratic Qt layout searches. -6. Consolidate per-thread runtime state and typed UI snapshots. -7. Address agent correlation with the future structural hierarchy. -8. Apply profiling-led Git, filesystem and Inspector improvements. - -This sequence starts with narrow correctness and low-risk algorithmic wins, -then approaches the scroll- and lifecycle-sensitive conversation work with -stronger fixtures and measurements already in place. - -## Delivery strategy - -Do not deliver the complete roadmap in one pull request. Use several scoped -pull requests, each containing multiple independently reviewable commits: - -1. Establish a reliable test baseline, provide canonical protocol fixtures, - split the shell integration scenarios and centralize status classification. -2. Build the authoritative-item index, make prompt reconciliation linear and - compact fully resolved submissions. -3. Optimize thread-list reconciliation and conversation layout ordering. -4. Consolidate `ThreadRuntimeState` and replace serialized UI fingerprints with - local typed snapshots. -5. Add the child-thread ownership index, indexed agent correlation and the - structural thread hierarchy. -6. Apply only measurement-supported repository, filesystem-watch, Inspector, - settings-catalog, helper and styling cleanup. - -Every commit must compile and pass the focused tests for its changed invariant. -Every pull request must pass the complete test suite before merge. Keep commits -small enough to review and bisect without depending on a later cleanup commit. diff --git a/ui-review/STATE-MATRIX.md b/ui-review/STATE-MATRIX.md index 248e767..d103256 100644 --- a/ui-review/STATE-MATRIX.md +++ b/ui-review/STATE-MATRIX.md @@ -5,29 +5,31 @@ | Connection | Disconnected | Conversation remains inspectable; mutation controls reflect unavailable controller transport. | | Connection | Connected observer | Read operations remain available; mutations require explicit controller ownership. | | Connection | Connected controller | Thread, turn, and request mutations are enabled. | +| Connection | Authored transport selection rejected or queue-full | No automatic retry occurs; reopening Transport is prefilled with the retained selection. | | Thread list | Background activity | Status changes without changing the user's selection. | | Thread list | Selected thread removed | Selection clears and the conversation returns to its empty state. | | New Thread | Draft | Dialog values and prompt remain local; one selected orange animated row appears immediately without creating an app-server thread. | | New Thread | Thread created, first prompt pending | The same row is rekeyed to the authoritative ID and remains animated through the first `turn/start` callback. | | New Thread | First prompt acknowledged | The same row switches to canonical styling without duplication or replacement. | | New Thread | Creation or first prompt failed | Animation stops and the retained row adopts explicit failure styling. | -| Prompt | Locally admitted | Composer clears immediately; a muted-blue card with a sweeping highlight appears in the destination thread. | +| Prompt | Locally admitted | Composer clears immediately; a muted-blue card appears in the destination thread, and its sweeping highlight starts only after one second without acknowledgment. | | Prompt | Additional prompt admitted | Composer remains enabled; the card is queued behind the in-flight prompt for that thread. | | Prompt | Authoritative item arrives before result | Exact `clientUserMessageId` correlation may bind the item, but the card remains pending until its operation callback. | -| Prompt | Acknowledged | The matching `turn.start` or `turn.steer` callback begins a 500-millisecond accepted transition; the authoritative item inherits the card's stable visual key. | +| Prompt | Acknowledged | The matching `turn/start` or `turn/steer` callback immediately ends pending feedback; the authoritative item inherits the card's stable visual key. | | Prompt | Failed | Animation stops and the card remains with an explicit error state. | -| Prompt | Disconnect before queued dispatch | Pending card remains unsent; bridge-open re-drives the same queued submission. | +| Prompt | Disconnect after admission | The authored prompt remains visible in explicit failed/uncertain recovery state; bridge-open never resends it automatically. | | Navigation | Switch away from pending prompt | Pending card and queue remain associated with their stable thread ID. | | Navigation | Return before acknowledgment | The same animated pending card is displayed. | -| Navigation | Return to materialized running thread | Retained Plan, Agents, Changes, and other per-thread presentation reappear without an automatic destructive read. | -| Thread | First selection in a connection generation | One full read hydrates the retained presentation before prompt dispatch; Reload is the explicit forced read. | +| Navigation | Return to materialized running thread | Plan, Agents, Changes, and other per-thread widget state reappears without an automatic destructive read. | +| Thread | First selection in a connection generation | One full read hydrates the thread's current graph nodes before prompt dispatch; Reload is the explicit forced read. | | Thread | Hydration failed | Submission leaves the composer draft intact and performs no dispatch; Reload must succeed before admission. | | Thread | Provider reports `notLoaded` | Resume completes before the queued prompt is dispatched. | -| Thread | Prompt reports thread not found | One resume-and-retry is allowed; a repeated failure becomes a terminal prompt error. | +| Thread | Prompt reports thread not found | The dispatch fails without an automatic retry; its authored input remains available for explicit recovery. | +| Thread | Deleted or provider generation reset during prompt work | The local prompt is detached from the invalid thread and retained with exact admitted text and attachment links in definite-failure or uncertain recovery state. | | Conversation | At bottom | New cards and stream updates smoothly follow the bottom with a short retargetable animation. | | Conversation | User scrolls during smooth follow | The animation stops immediately and automatic following pauses. | | Conversation | User scrolled upward | Automatic following pauses; a visible-card/pixel-offset anchor preserves the reading position through appends, reflow, and reconstruction. | -| Conversation | Nonvisual protocol update | The typed projection is unchanged, so no card, geometry, or scroll mutation occurs. | +| Conversation | Nonvisual protocol update | Visible node values are unchanged, so no card, geometry, or scroll mutation occurs. | | Conversation | Paused while history grows | The effective history window grows with appended cards so the visible anchor is not evicted. | | Conversation | User returns to bottom | Automatic following resumes. | | Composer | Short prompt | One-line compact height. | @@ -43,6 +45,7 @@ | Command text or output | Gesture reaches its scroll boundary | The nested view retains that gesture; a fresh outward gesture at the boundary scrolls the message view. | | Center chrome | Wheel or touchpad input | Message view scrolls unless a nested command view owns the current gesture. | | Info / State | Content exceeds viewport | Common styled vertical scrollbar appears as needed. | -| Info / Protocol | Content exceeds viewport | Styled log scrollbar appears; statistics remain below the expanding log. | +| Info / Protocol | Content exceeds viewport | Styled diagnostic scrollbar appears; statistics remain below the expanding viewer. | | Pending request | Unresolved | Thread and global attention surfaces identify required user action. | +| Pending request | Authored response rejected after admission | No automatic retry occurs; the request retains the authored response and reopens Review with that input. | | Pending request | Resolved | Actionable request disappears exactly once for its stable request identity. | diff --git a/ui-review/UI-INVENTORY.md b/ui-review/UI-INVENTORY.md index 034417b..2533939 100644 --- a/ui-review/UI-INVENTORY.md +++ b/ui-review/UI-INVENTORY.md @@ -23,14 +23,15 @@ - Thread title, workspace, and status context. - One transparent section per app-server turn, containing server-ordered user, Codex, plan, reasoning, Command execution, file-change, and collaboration - cards projected from `PresentationModel`. + cards rendered from the shared `NodeGraph`. - Stable keyed in-place reconciliation for authoritative cards and local prompt - cards; visually identical projections perform no widget or geometry update. -- Per-thread pending prompt cards with muted blue content and a brighter blue - highlight sweeping left and right until the correlated operation callback, - followed by a 500-millisecond accepted transition. -- Windowed materialization of long conversations with an explicit Load More - control. + cards; visually identical node state performs no widget or geometry update. +- Per-thread pending prompt cards with muted blue content; after one second of + pending work, a brighter blue highlight sweeps left and right until the + correlated operation callback ends pending feedback immediately. +- Lazy materialization for the viewport plus one viewport of overscan, with + measured placeholders farther away, at most eight card operations per event + pass, and an explicit Load More control. - Short, interruptible smooth bottom-follow only while the user remains at the bottom; paused reading uses a stable visible-card/pixel-offset anchor. - Wheel and touchpad forwarding from surrounding center chrome and splitter @@ -73,8 +74,10 @@ addition/deletion counts, live filesystem refresh, copy, and expanded viewing. - **Requests:** typed approval and input requests with explicit resolution. -- **Info / State:** retained normalized presentation domains. -- **Info / Protocol:** bounded frame log with the statistics summary below it. +- **Info / State:** bounded current shared-graph summary with selected-thread + detail. +- **Info / Protocol:** bounded current operation and unknown-protocol node + diagnostic with revision and node statistics below it. State and Protocol use the common styled, as-needed vertical scrollbars. Plan, Agents, Changes, and Requests retain their visible per-thread state across @@ -88,8 +91,11 @@ thread and tab navigation. ## Local presentation state -CodexUI locally owns visible selection, drafts, pending prompt cards, per-thread -submission queues, scroll-follow state, nested-output scroll state, splitter -sizes, tab selection, and focus. `PresentationModel` is the sole retained store -for normalized presentation domains; these local interaction values do not -replace AISuite or app-server domain authority. +Qt locally owns visible selection, drafts, scroll-follow state, nested-output +scroll state, splitter sizes, tab selection, focus, and other widget mechanics. +Pending prompts are graph nodes and per-thread operation ordering belongs to +worker logic, not to a second Qt model. The shared `NodeGraph` is the sole +current native store for protocol-derived domains; local interaction values do +not replace AISuite or app-server domain authority. Materialized rows and cards +associate through each node's optional opaque Qt attachment rather than a +permanent NodeId-to-widget registry. diff --git a/ui-review/UX-DESIGN-DECISIONS.md b/ui-review/UX-DESIGN-DECISIONS.md index 9120b46..af496ad 100644 --- a/ui-review/UX-DESIGN-DECISIONS.md +++ b/ui-review/UX-DESIGN-DECISIONS.md @@ -144,17 +144,23 @@ conversation. ## Conversation structure -`PresentationModel` is the retained normalized presentation source. The -conversation projects it into one transparent section per app-server turn, +The shared `NodeGraph` is the sole current native protocol-derived state. The +conversation renders it as one transparent section per app-server turn, with cards in server order. The first You card is the visible turn container; later process, Codex, and steering You cards are nested inside it. The outer turn remains foldable, and restoring it preserves every child's independent fold state. A pending steering card uses the animated blue identity and morphs in place to a softer blue authoritative steering surface. Stable turn/item and local-submission keys drive a single reconcile path for both first display and -updates. Retained cards mutate in place, and identical visible projections do +updates. Retained visible cards mutate in place, and identical node state does not trigger layout work. +Cards materialize lazily for the viewport and one viewport of overscan; a wider +retention margin avoids churn, and measured placeholders preserve geometry +farther away. At most eight card operations run in one event-loop pass. Each +materialized card uses its node's optional opaque Qt attachment, not a separate +permanent identity registry. + The active thread name and its smaller `workspace | state` metadata form one baseline-aligned lockup, following the application brand/titlebar pattern without sharing its font size. @@ -174,7 +180,8 @@ The upcoming-turn settings and composer remain anchored to the bottom. The prompt editor starts at one line, grows upward to its maximum, and then scrolls internally. A draft that fits has no hidden trailing scroll offset. Send and Steer require non-whitespace input, prompt focus uses a geometry-neutral blue -border, and submission preserves the exact authored text. The message view +border, and submission trims only leading and trailing whitespace under the +legacy input contract. The message view reserves the canonical composer height. Additional growth overlays, but does not resize, the viewport. The trailing allowance is represented as a logical extent equal to the overlap so the user can scroll the final card to the @@ -195,7 +202,7 @@ Local admission creates a calm blue prompt card with an emphasized border immediately. A brighter blue highlight starts sweeping left and right only after one second without app-server acknowledgment. The card belongs to its destination thread and persists through navigation. -Only the correlated `turn.start` or `turn.steer` completion callback +Only the correlated `turn/start` or `turn/steer` completion callback acknowledges it. Each request carries a unique `clientUserMessageId`; the matching callback stops delayed feedback immediately and permits normal message presentation as soon as the authoritative item is correlated. Failure produces @@ -223,14 +230,15 @@ and Info. Primary tabs use the shared full-size application typography and are never nested. Info presents State and Protocol as raised choice rows with chevrons; selecting one drills into its viewer, with an explicit back action to the choices. This expresses hierarchy through navigation rather than smaller -text. Both viewers use application scrollbars. In Protocol, the log expands -above a statistics summary placed at the bottom. +text. Both viewers use application scrollbars. Protocol's bounded current +operation and unknown-protocol diagnostic expands above its revision and node +statistics summary. Plan steps, agents, and pending requests are peer records and therefore use the same raised card surface, border, radius, and internal spacing. Summary surfaces are reserved for subordinate content within a record. Inspector scroll areas are frameless and transparent so the panel background remains continuous. -Plan, Agents, and Requests retain their last visible per-thread presentation +Plan, Agents, and Requests retain their last visible per-thread widget state across thread and tab navigation. Agent records start collapsed, with status, copy, and disclosure controls aligned at the right of the title row. Expanding reveals the retained metadata, prompt, result, and thread identities.