From b20e2a84be2f8795662868faae8f60acf8fc8103 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 01:09:25 +0200 Subject: [PATCH 1/9] Expose per-turn network access --- src/ui/UpcomingTurnDock.cpp | 189 ++++++++++++++++++++++++++++--- src/ui/UpcomingTurnDock.h | 5 +- tests/Phase1ThreadTurnUxTest.cpp | 96 +++++++++++++--- 3 files changed, 259 insertions(+), 31 deletions(-) diff --git a/src/ui/UpcomingTurnDock.cpp b/src/ui/UpcomingTurnDock.cpp index 7fefc20..f7257f3 100644 --- a/src/ui/UpcomingTurnDock.cpp +++ b/src/ui/UpcomingTurnDock.cpp @@ -176,6 +176,25 @@ QString sandboxKey(const typed::SandboxPolicy& value) value); } +QString networkKey(const typed::SandboxPolicy& value) +{ + return std::visit( + [](const auto& item) -> QString { + using T = std::decay_t; + if constexpr (std::is_same_v) + return QStringLiteral("enabled"); + if constexpr (std::is_same_v + || std::is_same_v) + return item.networkAccessOrDefault() ? QStringLiteral("enabled") + : QStringLiteral("restricted"); + if constexpr (std::is_same_v) + return fromUtf8(item.networkAccessOrDefault().value); + if constexpr (std::is_same_v) + return QStringLiteral("unavailable"); + }, + value); +} + QString approvalKey(const typed::AskForApproval& value) { return std::visit( @@ -209,6 +228,34 @@ std::optional sandboxForKey(const QString& key) return std::nullopt; } +bool applyNetworkChoice(typed::SandboxPolicy& policy, const QString& network) +{ + return std::visit( + [&network](auto& item) { + using T = std::decay_t; + if constexpr (std::is_same_v) + return network == QStringLiteral("enabled"); + if constexpr (std::is_same_v + || std::is_same_v) { + if (network != QStringLiteral("restricted") + && network != QStringLiteral("enabled")) + return false; + item.networkAccess = network == QStringLiteral("enabled"); + return true; + } + if constexpr (std::is_same_v) { + const typed::NetworkAccess value{toUtf8(network)}; + if (!value.isKnown()) + return false; + item.networkAccess = value; + return true; + } + if constexpr (std::is_same_v) + return false; + }, + policy); +} + std::optional approvalForKey(const QString& key) { const typed::ApprovalPolicy value{toUtf8(key)}; @@ -248,6 +295,34 @@ void resetSandboxChoices(QComboBox* combo) addChoice(combo, QStringLiteral("External"), QStringLiteral("external")); } +void resetNetworkChoices(QComboBox* combo, const QString& access) +{ + combo->clear(); + if (access == QStringLiteral("default")) { + addChoice(combo, defaultSettingLabel(), QStringLiteral("default")); + return; + } + if (access == QStringLiteral("danger-full-access")) { + addChoice(combo, QStringLiteral("Enabled"), QStringLiteral("enabled")); + return; + } + if (access == QStringLiteral("workspace-write") + || access == QStringLiteral("read-only") + || access == QStringLiteral("external")) { + addChoice(combo, QStringLiteral("Restricted"), QStringLiteral("restricted")); + addChoice(combo, QStringLiteral("Enabled"), QStringLiteral("enabled")); + return; + } + addChoice(combo, unavailableSettingLabel(), QStringLiteral("unavailable")); +} + +bool networkIsEditable(const QString& access) +{ + return access == QStringLiteral("workspace-write") + || access == QStringLiteral("read-only") + || access == QStringLiteral("external"); +} + void resetApprovalChoices(QComboBox* combo) { combo->clear(); @@ -387,11 +462,13 @@ UpcomingTurnDock::UpcomingTurnDock(QWidget* parent) effort = compactCombo("upcomingReasoning"); personality = compactCombo("upcomingStyle"); sandbox = compactCombo("upcomingAccess"); + network = compactCombo("upcomingNetwork"); approval = compactCombo("upcomingApproval"); fieldSurfaces[static_cast(Field::Model)] = labelledControl(QStringLiteral("Model"), model); fieldSurfaces[static_cast(Field::Effort)] = labelledControl(QStringLiteral("Reasoning"), effort); fieldSurfaces[static_cast(Field::Personality)] = labelledControl(QStringLiteral("Style"), personality); fieldSurfaces[static_cast(Field::Sandbox)] = labelledControl(QStringLiteral("Access"), sandbox); + fieldSurfaces[static_cast(Field::Network)] = labelledControl(QStringLiteral("Network"), network); fieldSurfaces[static_cast(Field::Approval)] = labelledControl(QStringLiteral("Approval"), approval); cwd = new QLineEdit; cwd->setObjectName(QStringLiteral("upcomingWorkspace")); @@ -407,20 +484,29 @@ UpcomingTurnDock::UpcomingTurnDock(QWidget* parent) "QPushButton[changed=\"true\"]{background:#e5eeff;color:#2f6feb;border-color:#2f6feb;}")); auto* moreSurface = labelledControl(QStringLiteral("Additional"), more); - // Two stable rows keep every choice readable at the supported narrow - // window width. Model receives the extra column in the primary row while - // all controls retain the same caption-above-control alignment. - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Model)], 0, 0, 1, 2); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Effort)], 0, 2); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Personality)], 0, 3); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Sandbox)], 1, 0); + const std::array primaryFields{ + fieldSurfaces[static_cast(Field::Model)], + fieldSurfaces[static_cast(Field::Effort)], + fieldSurfaces[static_cast(Field::Sandbox)], + fieldSurfaces[static_cast(Field::Network)], + fieldSurfaces[static_cast(Field::Cwd)], + fieldSurfaces[static_cast(Field::Approval)], + fieldSurfaces[static_cast(Field::Personality)], + moreSurface, + }; + for (QWidget* field : primaryFields) + field->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); + + settingsGrid->addWidget(primaryFields[0], 0, 0); + settingsGrid->addWidget(primaryFields[1], 0, 1); + settingsGrid->addWidget(primaryFields[2], 0, 2); + settingsGrid->addWidget(primaryFields[3], 0, 3); + settingsGrid->addWidget(primaryFields[4], 1, 0); settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Approval)], 1, 1); - settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Cwd)], 1, 2); + settingsGrid->addWidget(fieldSurfaces[static_cast(Field::Personality)], 1, 2); settingsGrid->addWidget(moreSurface, 1, 3); - settingsGrid->setColumnStretch(0, 4); - settingsGrid->setColumnStretch(1, 4); - settingsGrid->setColumnStretch(2, 5); - settingsGrid->setColumnStretch(3, 4); + for (int column = 0; column < 4; ++column) + settingsGrid->setColumnStretch(column, 1); settingsLayout->addLayout(settingsGrid); settingsHint = plainLabel({}, "upcomingSettingsHint"); @@ -526,6 +612,7 @@ UpcomingTurnDock::UpcomingTurnDock(QWidget* parent) addChoice(effort, QStringLiteral("XHigh"), QStringLiteral("xhigh")); resetPersonalityChoices(personality); resetSandboxChoices(sandbox); + resetNetworkChoices(network, QStringLiteral("unavailable")); resetApprovalChoices(approval); resetReviewerChoices(reviewer); addChoice(serviceTier, defaultSettingLabel(), QStringLiteral("default")); @@ -543,7 +630,12 @@ UpcomingTurnDock::UpcomingTurnDock(QWidget* parent) connect(effort, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Effort, effort); }); connect(personality, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Personality, personality); }); - connect(sandbox, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Sandbox, sandbox); }); + connect(sandbox, &QComboBox::currentIndexChanged, this, [this] { + markComboChange(Field::Sandbox, sandbox); + refreshNetworkControl(true); + }); + connect(network, &QComboBox::currentIndexChanged, this, + [this] { markComboChange(Field::Network, network); }); connect(approval, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Approval, approval); }); connect(reviewer, &QComboBox::currentIndexChanged, this, [this] { markComboChange(Field::Reviewer, reviewer); }); connect(serviceTier, &QComboBox::currentIndexChanged, this, @@ -677,11 +769,19 @@ UpcomingTurnDraft UpcomingTurnDock::draft() const else if (typed::Personality{toUtf8(value)}.isKnown()) result.personality = typed::Personality{toUtf8(value)}; } - if (touched(Field::Sandbox)) { + if (touched(Field::Sandbox) || touched(Field::Network)) { if (key(sandbox) == QStringLiteral("default")) result.sandboxPolicy = typed::OptionalNullable::explicitNull(); - else if (const auto value = sandboxForKey(key(sandbox))) - result.sandboxPolicy = *value; + else { + std::optional value; + if (canonicalConfiguration + && sandboxKey(canonicalConfiguration->sandboxPolicy) == key(sandbox)) + value = canonicalConfiguration->sandboxPolicy; + else + value = sandboxForKey(key(sandbox)); + if (value && applyNetworkChoice(*value, key(network))) + result.sandboxPolicy = std::move(*value); + } } if (touched(Field::Approval)) { if (key(approval) == QStringLiteral("default")) @@ -766,6 +866,7 @@ void UpcomingTurnDock::resolveSubmittedSettings(const UpcomingTurnDraft& submitt case Field::Personality: return !submitted.personality.isOmitted(); case Field::Sandbox: + case Field::Network: return !submitted.sandboxPolicy.isOmitted(); case Field::Approval: return !submitted.approvalPolicy.isOmitted(); @@ -970,6 +1071,8 @@ void UpcomingTurnDock::refreshControls(bool resetAll) : missingValue; const QString sandboxValue = canonicalConfiguration ? sandboxKey(canonicalConfiguration->sandboxPolicy) : missingValue; + const QString networkValue = canonicalConfiguration ? networkKey(canonicalConfiguration->sandboxPolicy) + : missingValue; const QString approvalValue = canonicalConfiguration ? approvalKey(canonicalConfiguration->approvalPolicy) : missingValue; const QString reviewerValue = canonicalConfiguration ? fromUtf8(canonicalConfiguration->approvalsReviewer.value) @@ -989,6 +1092,7 @@ void UpcomingTurnDock::refreshControls(bool resetAll) effortValue, personalityValue, sandboxValue, + networkValue, approvalValue, reviewerValue, cwdValue, @@ -1008,8 +1112,10 @@ void UpcomingTurnDock::refreshControls(bool resetAll) || sandboxIsEditable(canonicalConfiguration->sandboxPolicy); const bool editableApproval = !canonicalConfiguration || approvalIsEditable(canonicalConfiguration->approvalPolicy); - if (!editableSandbox) + if (!editableSandbox) { setTouched(Field::Sandbox, false); + setTouched(Field::Network, false); + } if (!editableApproval) setTouched(Field::Approval, false); @@ -1045,6 +1151,7 @@ void UpcomingTurnDock::refreshControls(bool resetAll) : friendlyValue(sandboxValue), sandboxValue == QStringLiteral("default") || sandboxForKey(sandboxValue).has_value()); } + refreshNetworkControl(false); if (shouldRefresh(Field::Approval)) { const QSignalBlocker blocker(approval); @@ -1115,6 +1222,52 @@ void UpcomingTurnDock::refreshControls(bool resetAll) : QStringLiteral("This projected approval policy is read-only in CodexUI")); } +void UpcomingTurnDock::refreshNetworkControl(bool accessChangedByUser) +{ + const QString access = currentFieldKey(Field::Sandbox); + const QString previous = currentFieldKey(Field::Network); + QString target; + + if (networkIsEditable(access)) { + const bool previousIsChoice = previous == QStringLiteral("restricted") + || previous == QStringLiteral("enabled"); + if (previousIsChoice && (touched(Field::Network) || accessChangedByUser)) + target = previous; + else if (canonicalConfiguration + && sandboxKey(canonicalConfiguration->sandboxPolicy) == access) + target = networkKey(canonicalConfiguration->sandboxPolicy); + else + target = QStringLiteral("restricted"); + } else if (access == QStringLiteral("danger-full-access")) { + target = QStringLiteral("enabled"); + } else if (access == QStringLiteral("default")) { + target = QStringLiteral("default"); + } else { + target = QStringLiteral("unavailable"); + } + + { + const QSignalBlocker blocker(network); + resetNetworkChoices(network, access); + selectKey(network, target, friendlyValue(target), true); + } + + const bool editable = networkIsEditable(access); + fieldSurfaces[static_cast(Field::Network)]->setEnabled(editable); + QString tooltip; + if (access == QStringLiteral("danger-full-access")) + tooltip = QStringLiteral("Full access always includes network access"); + else if (access == QStringLiteral("default")) + tooltip = QStringLiteral("Network access follows the Codex default access policy"); + else if (!editable) + tooltip = QStringLiteral("Network access is unavailable for this access policy"); + fieldSurfaces[static_cast(Field::Network)]->setToolTip(tooltip); + + if (accessChangedByUser) + setTouched(Field::Network, + target != canonicalKeys[static_cast(Field::Network)]); +} + void UpcomingTurnDock::refreshModelControl() { const QString selectedKey = currentFieldKey(Field::Model); @@ -1512,6 +1665,8 @@ QString UpcomingTurnDock::currentFieldKey(Field field) const return comboKey(personality); case Field::Sandbox: return comboKey(sandbox); + case Field::Network: + return comboKey(network); case Field::Approval: return comboKey(approval); case Field::Reviewer: diff --git a/src/ui/UpcomingTurnDock.h b/src/ui/UpcomingTurnDock.h index b3fa870..6c9b5e1 100644 --- a/src/ui/UpcomingTurnDock.h +++ b/src/ui/UpcomingTurnDock.h @@ -34,7 +34,7 @@ class ExpandingPromptEditor; struct UpcomingTurnDraft { QString threadIdentity; - std::array presentationKeys{}; + std::array presentationKeys{}; ai::openai::codex::typed::OptionalNullable model; ai::openai::codex::typed::OptionalNullable effort; ai::openai::codex::typed::OptionalNullable personality; @@ -108,6 +108,7 @@ class UpcomingTurnDock final : public QWidget Effort, Personality, Sandbox, + Network, Approval, Reviewer, Cwd, @@ -118,6 +119,7 @@ class UpcomingTurnDock final : public QWidget }; void refreshControls(bool resetAll); + void refreshNetworkControl(bool accessChangedByUser); void refreshModelControl(); void refreshModelDependentControls(bool modelChangedByUser); [[nodiscard]] const ai::openai::codex::typed::Model* defaultModelDefinition() const; @@ -149,6 +151,7 @@ class UpcomingTurnDock final : public QWidget QComboBox* effort = nullptr; QComboBox* personality = nullptr; QComboBox* sandbox = nullptr; + QComboBox* network = nullptr; QComboBox* approval = nullptr; QLineEdit* cwd = nullptr; QPushButton* more = nullptr; diff --git a/tests/Phase1ThreadTurnUxTest.cpp b/tests/Phase1ThreadTurnUxTest.cpp index 6211bbd..8563b4f 100644 --- a/tests/Phase1ThreadTurnUxTest.cpp +++ b/tests/Phase1ThreadTurnUxTest.cpp @@ -392,15 +392,17 @@ bool testNarrowUpcomingTurnLayout() auto* effort = dock.findChild(QStringLiteral("upcomingReasoning")); auto* style = dock.findChild(QStringLiteral("upcomingStyle")); auto* access = dock.findChild(QStringLiteral("upcomingAccess")); + auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); + auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); auto* workspace = dock.findChild(QStringLiteral("upcomingWorkspace")); auto* more = dock.findChild(QStringLiteral("upcomingMore")); auto* status = dock.findChild(QStringLiteral("upcomingTurnStatus")); auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); bool passed = expect(settings && composer && model && effort && style && access - && workspace && more && status && send, + && network && approval && workspace && more && status && send, "the narrow upcoming-turn layout controls must be discoverable"); if (!settings || !composer || !model || !effort || !style || !access - || !workspace || !more || !status || !send) + || !network || !approval || !workspace || !more || !status || !send) return false; const auto inDock = [&dock](QWidget* widget) { @@ -410,20 +412,85 @@ bool testNarrowUpcomingTurnLayout() const QRect effortRect = inDock(effort); const QRect styleRect = inDock(style); const QRect accessRect = inDock(access); + const QRect networkRect = inDock(network); + const QRect approvalRect = inDock(approval); const QRect workspaceRect = inDock(workspace); const QRect moreRect = inDock(more); const QRect statusRect = inDock(status); const QRect sendRect = inDock(send); passed &= expect(settings->geometry().bottom() < composer->geometry().top() - && modelRect.bottom() < accessRect.top() - && effortRect.right() < styleRect.left() - && workspaceRect.right() < moreRect.left() + && modelRect.top() == effortRect.top() + && effortRect.top() == accessRect.top() + && accessRect.top() == networkRect.top() + && modelRect.bottom() < workspaceRect.top() + && std::abs(workspaceRect.top() - approvalRect.top()) <= 2 + && std::abs(approvalRect.top() - styleRect.top()) <= 2 + && std::abs(styleRect.top() - moreRect.top()) <= 2 + && modelRect.left() == workspaceRect.left() + && effortRect.left() == approvalRect.left() + && accessRect.left() == styleRect.left() + && networkRect.left() == moreRect.left() && statusRect.right() < sendRect.left(), "the two-row settings and composer actions must not overlap at narrow width"); - passed &= expect(modelRect.width() > effortRect.width() - && effortRect.width() >= 70 && styleRect.width() >= 70 - && accessRect.width() >= 70 && workspaceRect.width() >= 70, - "narrow settings must retain readable choice widths with extra space for the model"); + const std::array widths{ + modelRect.width(), effortRect.width(), accessRect.width(), networkRect.width(), + workspaceRect.width(), approvalRect.width(), styleRect.width(), moreRect.width()}; + const auto [minimumWidth, maximumWidth] = std::minmax_element(widths.begin(), widths.end()); + passed &= expect(*minimumWidth >= 70 && *maximumWidth - *minimumWidth <= 1, + "all eight primary settings must retain equal readable widths"); + return passed; +} + +bool testUpcomingTurnNetworkAccess() +{ + codexui::UpcomingTurnDock dock; + sdk::ExecutionConfiguration canonical = + configuration("gpt-test", typed::ReasoningEffort::high(), "/workspace"); + typed::WorkspaceWriteSandboxPolicy workspacePolicy; + workspacePolicy.networkAccess = false; + workspacePolicy.writableRoots = std::vector{ + typed::AbsolutePath{"/workspace/extra"}}; + workspacePolicy.excludeSlashTmp = true; + canonical.sandboxPolicy = workspacePolicy; + dock.setCanonicalConfiguration(canonical, QStringLiteral("thread-network")); + dock.setActionState(true, false, true, true, false, false); + + auto* access = dock.findChild(QStringLiteral("upcomingAccess")); + auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); + bool passed = expect(access && network, + "the access and network controls must be discoverable"); + if (!access || !network) + return false; + passed &= expect(access->currentData().toString() == QStringLiteral("workspace-write") + && network->currentData().toString() == QStringLiteral("restricted") + && network->isEnabled(), + "workspace access must expose its canonical network restriction"); + + network->setCurrentIndex(network->findData(QStringLiteral("enabled"))); + codexui::UpcomingTurnDraft draft = dock.draft(); + const auto* submittedWorkspace = draft.sandboxPolicy.hasValue() + ? std::get_if(&*draft.sandboxPolicy) + : nullptr; + passed &= expect(submittedWorkspace && submittedWorkspace->networkAccessOrDefault() + && submittedWorkspace->writableRoots == workspacePolicy.writableRoots + && submittedWorkspace->excludeSlashTmp == workspacePolicy.excludeSlashTmp, + "changing only network access must preserve the canonical workspace policy details"); + + access->setCurrentIndex(access->findData(QStringLiteral("read-only"))); + draft = dock.draft(); + const auto* readOnly = draft.sandboxPolicy.hasValue() + ? std::get_if(&*draft.sandboxPolicy) + : nullptr; + passed &= expect(readOnly && readOnly->networkAccessOrDefault(), + "network access must remain enabled when switching to read-only access"); + + access->setCurrentIndex(access->findData(QStringLiteral("danger-full-access"))); + draft = dock.draft(); + passed &= expect(network->currentData().toString() == QStringLiteral("enabled") + && !network->isEnabled() && draft.sandboxPolicy.hasValue() + && std::holds_alternative( + *draft.sandboxPolicy), + "full access must display its fixed enabled network policy"); return passed; } @@ -514,10 +581,11 @@ bool testUpcomingTurnActionStates() auto* send = dock.findChild(QStringLiteral("upcomingSendButton")); auto* stop = dock.findChild(QStringLiteral("upcomingStopButton")); auto* sandbox = dock.findChild(QStringLiteral("upcomingAccess")); + auto* network = dock.findChild(QStringLiteral("upcomingNetwork")); auto* approval = dock.findChild(QStringLiteral("upcomingApproval")); - bool passed = expect(editor && send && stop && sandbox && approval, + bool passed = expect(editor && send && stop && sandbox && network && approval, "the upcoming-turn action controls must be discoverable"); - if (!editor || !send || !stop || !sandbox || !approval) + if (!editor || !send || !stop || !sandbox || !network || !approval) return false; dock.setActionState(true, @@ -531,7 +599,8 @@ bool testUpcomingTurnActionStates() editor->setPlainText(QStringLiteral("redirect the active turn")); passed &= expect(!send->isHidden() && send->text() == QStringLiteral("Steer") && send->isEnabled() && !stop->isHidden() && stop->isEnabled() - && editor->isEnabled() && !sandbox->isEnabled() && !approval->isEnabled(), + && editor->isEnabled() && !sandbox->isEnabled() + && !network->isEnabled() && !approval->isEnabled(), "a running turn must permit steering and stopping while locking execution settings"); QString shortcutPrompt; bool shortcutSteering = false; @@ -589,7 +658,7 @@ bool testUpcomingTurnActionStates() dock.setActionState(true, false, true, true, false, false); passed &= expect(!send->isHidden() && editor->isEnabled() && !send->isEnabled() && !send->toolTip().isEmpty() - && sandbox->isEnabled() && approval->isEnabled(), + && sandbox->isEnabled() && network->isEnabled() && approval->isEnabled(), "an idle thread must not silently reinterpret a steer draft as a new turn"); editor->insertPlainText(QStringLiteral(" ")); passed &= expect(send->isEnabled() && send->toolTip().isEmpty(), @@ -1315,6 +1384,7 @@ int main(int argc, char** argv) passed &= testUpcomingTurnCanonicalRebase(); passed &= testAnchoredGrowingComposer(); passed &= testNarrowUpcomingTurnLayout(); + passed &= testUpcomingTurnNetworkAccess(); passed &= testTypedModelCatalog(); passed &= testUpcomingTurnActionStates(); passed &= testUnsupportedCanonicalSettingsFailSoft(); From c23b2e5197e30956106b93ac8f6cf4a6733057b9 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 01:36:36 +0200 Subject: [PATCH 2/9] Clarify inherent network access for full access --- src/ui/InspectorWidget.cpp | 2 +- src/ui/UpcomingTurnDock.cpp | 4 ++-- tests/Phase1ThreadTurnUxTest.cpp | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/InspectorWidget.cpp b/src/ui/InspectorWidget.cpp index 9c7d5b6..13388dc 100644 --- a/src/ui/InspectorWidget.cpp +++ b/src/ui/InspectorWidget.cpp @@ -312,7 +312,7 @@ QString approvalPolicyText(const typed::AskForApproval& policy) QString sandboxPolicyText(const typed::SandboxPolicy& policy) { if (std::holds_alternative(policy)) - return QStringLiteral("Danger full access"); + return QStringLiteral("Danger full access · Network included"); if (const auto* readOnly = std::get_if(&policy)) return readOnly->networkAccessOrDefault() ? QStringLiteral("Read only · Network enabled") : QStringLiteral("Read only · Network restricted"); diff --git a/src/ui/UpcomingTurnDock.cpp b/src/ui/UpcomingTurnDock.cpp index f7257f3..dc43a28 100644 --- a/src/ui/UpcomingTurnDock.cpp +++ b/src/ui/UpcomingTurnDock.cpp @@ -303,7 +303,7 @@ void resetNetworkChoices(QComboBox* combo, const QString& access) return; } if (access == QStringLiteral("danger-full-access")) { - addChoice(combo, QStringLiteral("Enabled"), QStringLiteral("enabled")); + addChoice(combo, QStringLiteral("Included"), QStringLiteral("enabled")); return; } if (access == QStringLiteral("workspace-write") @@ -1256,7 +1256,7 @@ void UpcomingTurnDock::refreshNetworkControl(bool accessChangedByUser) fieldSurfaces[static_cast(Field::Network)]->setEnabled(editable); QString tooltip; if (access == QStringLiteral("danger-full-access")) - tooltip = QStringLiteral("Full access always includes network access"); + tooltip = QStringLiteral("Full access includes network access; Codex does not provide a separate network override for this mode"); else if (access == QStringLiteral("default")) tooltip = QStringLiteral("Network access follows the Codex default access policy"); else if (!editable) diff --git a/tests/Phase1ThreadTurnUxTest.cpp b/tests/Phase1ThreadTurnUxTest.cpp index 8563b4f..64aeb55 100644 --- a/tests/Phase1ThreadTurnUxTest.cpp +++ b/tests/Phase1ThreadTurnUxTest.cpp @@ -487,10 +487,11 @@ bool testUpcomingTurnNetworkAccess() access->setCurrentIndex(access->findData(QStringLiteral("danger-full-access"))); draft = dock.draft(); passed &= expect(network->currentData().toString() == QStringLiteral("enabled") - && !network->isEnabled() && draft.sandboxPolicy.hasValue() + && network->currentText() == QStringLiteral("Included") && !network->isEnabled() + && draft.sandboxPolicy.hasValue() && std::holds_alternative( *draft.sandboxPolicy), - "full access must display its fixed enabled network policy"); + "full access must clearly display its inherent network access without offering an unsupported override"); return passed; } From eec336b4a1630d85916f0c7258031fcbd5dce160 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 08:41:13 +0200 Subject: [PATCH 3/9] Keep frontend updates responsive Move transport processing behind a bounded latest-state mailbox, render streaming content incrementally, and preserve stable scroll and reconnect presentation semantics. --- CMakeLists.txt | 2 +- src/app/FrontendSession.cpp | 35 +- src/app/FrontendSession.h | 10 +- src/app/FrontendSessionWorker.cpp | 154 +++++++-- src/app/FrontendSessionWorker.h | 16 +- src/ui/ConversationWidget.cpp | 482 ++++++++++++++++++++++---- src/ui/ConversationWidget.h | 13 + src/ui/WorkbenchWidget.cpp | 56 ++- src/ui/WorkbenchWidget.h | 27 ++ tests/ConversationLayoutTest.cpp | 496 +++++++++++++++++++++++++- tests/FrontendSessionTest.cpp | 555 +++++++++++++++++++++++++++++- tests/Phase1ThreadTurnUxTest.cpp | 38 ++ 12 files changed, 1756 insertions(+), 128 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index bd0de4c..ee69ff5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ include(GNUInstallDirs) set(CMAKE_AUTOMOC ON) -find_package(AISuite 0.5.0 CONFIG REQUIRED) +find_package(AISuite 0.6.0 CONFIG REQUIRED) find_package(Qt6 REQUIRED COMPONENTS Concurrent Network Widgets) qt_add_executable( diff --git a/src/app/FrontendSession.cpp b/src/app/FrontendSession.cpp index e4c0123..fa9e928 100644 --- a/src/app/FrontendSession.cpp +++ b/src/app/FrontendSession.cpp @@ -38,11 +38,25 @@ bool appendUniqueBounded(QStringList& destination, const QStringList& source) void mergeScope(detail::StateUpdateScope& destination, const detail::StateUpdateScope& source) { + // A newer exact update for an identity supersedes an older removal. Apply + // this before appending the source tombstones so remove-after-upsert still + // wins while upsert-after-remove cannot clear a live selection. + for (const QString& threadId : source.affectedThreadIds) { + if (!source.removedThreadIds.contains(threadId)) + destination.removedThreadIds.removeAll(threadId); + } destination.allThreadsAffected |= source.allThreadsAffected; destination.allInspectorsAffected |= source.allInspectorsAffected; destination.allSidebarThreadsAffected |= source.allSidebarThreadsAffected; destination.sidebarAffected |= source.sidebarAffected; destination.hasPresentationChange |= source.hasPresentationChange; + destination.removedThreadIdsOverflowed |= + source.removedThreadIdsOverflowed; + if (!appendUniqueBounded(destination.removedThreadIds, + source.removedThreadIds)) { + destination.removedThreadIdsOverflowed = true; + destination.allThreadsAffected = true; + } if (!destination.allThreadsAffected) { if (!appendUniqueBounded(destination.affectedThreadIds, source.affectedThreadIds) @@ -151,6 +165,8 @@ void mergeScope(detail::StateUpdateScope& destination, > detail::maximumCoalescedPresentationIdentities || destination.fullyAffectedThreadIds.size() > detail::maximumCoalescedPresentationIdentities + || destination.removedThreadIds.size() + > detail::maximumCoalescedPresentationIdentities || static_cast(destination.affectedItemContents.size()) > detail::maximumCoalescedPresentationIdentities) { destination.allThreadsAffected = true; @@ -166,6 +182,9 @@ void mergeScope(detail::StateUpdateScope& destination, if (destination.allThreadsAffected) { destination.affectedThreadIds.clear(); destination.fullyAffectedThreadIds.clear(); + // Keep exact removals even when the rest of the presentation scope + // degrades to an all-thread refresh. Global omission provenance makes + // a missing selected ID ambiguous without this bounded evidence. destination.affectedItemContents.clear(); destination.coalescedContentDeltaBytes = 0; } @@ -538,6 +557,16 @@ class FrontendSession::Impl latestState->state = std::move(publication.state); latestState->archivedStatus = publication.archivedStatus; mergeScope(latestState->scope, publication.scope); + // The newest immutable State is the final authority for any + // identity it actually retains. Capacity-omitted identities + // remain ambiguous and therefore keep their exact tombstone. + for (auto iterator = latestState->scope.removedThreadIds.begin(); + iterator != latestState->scope.removedThreadIds.end();) { + if (latestState->state.thread(iterator->toStdString())) + iterator = latestState->scope.removedThreadIds.erase(iterator); + else + ++iterator; + } } else { latestState = std::move(publication); } @@ -897,12 +926,12 @@ bool FrontendSession::ownsController() const noexcept return projection.value && projection.value->ownedByThisClient; } -void FrontendSession::loadThread(const QString& threadId) +void FrontendSession::loadThread(const QString& threadId, bool retryIncomplete) { if (impl->currentLifecycle != Lifecycle::Ready || threadId.isEmpty()) return; - impl->post([threadId](FrontendSessionWorker& worker) { - worker.loadThread(threadId); + impl->post([threadId, retryIncomplete](FrontendSessionWorker& worker) { + worker.loadThread(threadId, retryIncomplete); }); } diff --git a/src/app/FrontendSession.h b/src/app/FrontendSession.h index 9d74591..71d76ad 100644 --- a/src/app/FrontendSession.h +++ b/src/app/FrontendSession.h @@ -49,10 +49,18 @@ struct StateUpdateScope { QStringList affectedThreadIds; QStringList fullyAffectedThreadIds; + // Exact authoritative removals must survive mailbox coalescing. An + // omitted thread is otherwise indistinguishable from one deleted by an + // authoritative thread/read while the global snapshot remains bounded. + QStringList removedThreadIds; QStringList affectedInspectorThreadIds; QStringList affectedSidebarThreadIds; std::vector affectedItemContents; std::uint64_t coalescedContentDeltaBytes = 0; + // The bounded list omitted at least one exact removal identity. The UI + // must verify any missing retained selection instead of treating global + // snapshot omission as either presence or deletion. + bool removedThreadIdsOverflowed = false; bool allThreadsAffected = false; bool allInspectorsAffected = false; bool allSidebarThreadsAffected = false; @@ -104,7 +112,7 @@ class FrontendSession : public QObject [[nodiscard]] bool archivedThreadDiscoveryTerminal() const noexcept; [[nodiscard]] ArchivedThreadDiscoveryStatus archivedThreadDiscoveryStatus() const noexcept; [[nodiscard]] bool ownsController() const noexcept; - void loadThread(const QString& threadId); + void loadThread(const QString& threadId, bool retryIncomplete = false); [[nodiscard]] std::optional acquireController(OperationCompletion completion); [[nodiscard]] std::optional startThread(ThreadStartCompletion completion); [[nodiscard]] std::optional diff --git a/src/app/FrontendSessionWorker.cpp b/src/app/FrontendSessionWorker.cpp index d460ba6..792a6eb 100644 --- a/src/app/FrontendSessionWorker.cpp +++ b/src/app/FrontendSessionWorker.cpp @@ -178,8 +178,7 @@ StateUpdateScope stateUpdateScope(const sdk::StateUpdate& update) scope.allSidebarThreadsAffected = true; scope.sidebarAffected = true; } - else if constexpr (std::is_same_v - || std::is_same_v) + else if constexpr (std::is_same_v) { addFullyAffectedThread(value.threadId.value); addSidebarThread(value.threadId.value); @@ -189,6 +188,17 @@ StateUpdateScope stateUpdateScope(const sdk::StateUpdate& update) // its retained Inspector dependency set. addInspectorThread(value.threadId.value); } + else if constexpr (std::is_same_v) + { + addFullyAffectedThread(value.threadId.value); + if (!addUnique(scope.removedThreadIds, + value.threadId.value)) { + scope.removedThreadIdsOverflowed = true; + scope.allThreadsAffected = true; + } + addSidebarThread(value.threadId.value); + addInspectorThread(value.threadId.value); + } else if constexpr (std::is_same_v) { if (const auto* turn = update.state.turn(value.turnId)) { @@ -322,6 +332,12 @@ constexpr std::size_t maximumArchivedThreadListPages = 64; constexpr std::uint32_t modelListPageSize = 100; constexpr std::size_t maximumModelListPages = 64; +bool synchronizedStateOmitsThreads(const sdk::State& state) +{ + const auto capacity = state.capacityProvenance(); + return capacity && capacity->omittedThreads > 0; +} + QString operationError(const std::optional& error, const QString& fallback) { if (error && !error->message.empty()) @@ -354,6 +370,12 @@ FrontendSessionWorker::FrontendSessionWorker(QObject* parent) : QObject(parent) { sdk::ClientOptions options; + // CodexUI consumes includeTurns thread/read results through AISuite's + // authoritative State publication. This is an observed mechanism, not a + // representation request: requiring it validates the server's Welcome + // while leaving Hello's representation selection unchanged. + options.requiredCapabilities.push_back( + frontend::FrontendCapability::ThreadReadStateEffects); maximumFrameBytes = options.maximumInboundMessageBytes; options.credentialProvider = [] { return sdk::AuthenticationContext{ @@ -367,15 +389,7 @@ FrontendSessionWorker::FrontendSessionWorker(QObject* parent) handleConnectionStateChange(change); }; callbacks.onStateUpdated = [this](const sdk::StateUpdate& update) { - currentState = update.state; - for (const auto& change : update.changes) { - if (const auto* removed = std::get_if(&change)) - requestedThreadReads.erase(removed->threadId.value); - } - reconcileRequestedThreadReads(); - const detail::StateUpdateScope scope = detail::stateUpdateScope(update); - if (scope.hasPresentationChange) - emit stateChanged(scope); + handleStateUpdate(update); }; callbacks.onSynchronized = [this](const sdk::SynchronizationInfo& info) { reconnectDelayMs = initialReconnectDelayMs; @@ -383,7 +397,7 @@ FrontendSessionWorker::FrontendSessionWorker(QObject* parent) synchronizedCurrentConnection = true; automaticReconnectEnabled = true; currentState = info.state; - reconcileRequestedThreadReads(); + reconcileIncompleteThreadReadAttempts(); const bool clearReadyDiagnostic = currentLifecycle == Lifecycle::Ready && detail.isEmpty() && !diagnosticDetail.isEmpty(); @@ -452,7 +466,8 @@ void FrontendSessionWorker::reconnectToBackend() connection = Connection{}; clearInbound(); receiveContinuationScheduled = false; - requestedThreadReads.clear(); + threadReadsInFlight.clear(); + attemptedIncompleteThreadReads.clear(); if (socket.state() != QLocalSocket::UnconnectedState) { socket.abort(); resetReconnectPolicy(); @@ -577,29 +592,79 @@ bool FrontendSessionWorker::transportAffinityIsCurrentThread() const noexcept && outboundDrainTimer.thread() == current; } -void FrontendSessionWorker::loadThread(const QString& threadId) +void FrontendSessionWorker::loadThread(const QString& threadId, + bool retryIncomplete) { if (currentLifecycle != Lifecycle::Ready || threadId.isEmpty()) return; const std::string id = threadId.toStdString(); const auto* thread = currentState.thread(id); - if (!thread || thread->fullyLoaded || requestedThreadReads.contains(id)) + const bool missingFromBoundedState = !thread && synchronizedStateOmitsThreads(currentState); + if (retryIncomplete) + attemptedIncompleteThreadReads.erase(id); + const auto attempted = attemptedIncompleteThreadReads.find(id); + const bool attemptedCurrentRecoveryEpoch = + attempted != attemptedIncompleteThreadReads.end() + && attempted->second == incompleteReadRecoveryEpoch; + if ((!thread && !missingFromBoundedState) || (thread && thread->fullyLoaded) + || threadReadsInFlight.contains(id) + || attemptedCurrentRecoveryEpoch) + return; + if (threadReadsInFlight.size() + >= static_cast( + detail::maximumCoalescedPresentationIdentities)) return; - requestedThreadReads.insert(id); + threadReadsInFlight.insert(id); sdk::Submission submission = client->threads().read( {ai::openai::codex::typed::ThreadId{id}, true}, [this, id](const sdk::OperationResult& result) { - // A successful operation acknowledgement can precede the State - // projection. Keep suppressing duplicate reads until that thread is - // fully loaded (or a failure/removal/disconnect makes retry valid). - if (!result) - requestedThreadReads.erase(id); - else - reconcileRequestedThreadReads(); + threadReadsInFlight.erase(id); + reconcileIncompleteThreadReadAttempts(); + const auto* resolved = currentState.thread(id); + const bool remainsIncomplete = + (!resolved && synchronizedStateOmitsThreads(currentState)) + || (resolved && !resolved->fullyLoaded); + // CapacityExceeded is a negotiated result, not proof that another + // immediate full read can succeed. It consumes this replacement + // epoch just like a successful read whose projection remains + // incomplete; explicit user/reconnect recovery can still retry. + if (!result) { + if (remainsIncomplete) + rememberIncompleteThreadReadAttempt(id); + return; + } + const bool authoritativelyAbsent = result.value + && result.value->stateEffect + && result.value->stateEffect->authority + == frontend::ThreadReadStateEffectAuthority::Absent; + if (authoritativelyAbsent) + return; + if (remainsIncomplete) + rememberIncompleteThreadReadAttempt(id); }); if (!submission) - requestedThreadReads.erase(id); + threadReadsInFlight.erase(id); +} + +void FrontendSessionWorker::handleStateUpdate(const sdk::StateUpdate& update) +{ + currentState = update.state; + const bool stateReplaced = std::ranges::any_of( + update.changes, + [](const sdk::Change& change) { + return std::holds_alternative(change); + }); + if (stateReplaced) + ++incompleteReadRecoveryEpoch; + for (const auto& change : update.changes) { + if (const auto* removed = std::get_if(&change)) + attemptedIncompleteThreadReads.erase(removed->threadId.value); + } + reconcileIncompleteThreadReadAttempts(); + const detail::StateUpdateScope scope = detail::stateUpdateScope(update); + if (scope.hasPresentationChange) + emit stateChanged(scope); } std::optional FrontendSessionWorker::acquireController(OperationCompletion completion) @@ -1122,6 +1187,21 @@ void FrontendSessionWorker::compactInbound() noexcept inboundOffset = 0; } +void FrontendSessionWorker::rememberIncompleteThreadReadAttempt( + const std::string& threadId) +{ + // The UI has one active selection. Retain the newest bounded recovery + // identity if pathological rapid selection has left more unresolved + // omitted IDs than presentation can track. + if (!attemptedIncompleteThreadReads.contains(threadId) + && attemptedIncompleteThreadReads.size() + >= static_cast( + detail::maximumCoalescedPresentationIdentities)) + attemptedIncompleteThreadReads.clear(); + attemptedIncompleteThreadReads.insert_or_assign( + threadId, incompleteReadRecoveryEpoch); +} + void FrontendSessionWorker::scheduleSocketRead() { if (receiveContinuationScheduled || localShutdown) @@ -1139,18 +1219,20 @@ void FrontendSessionWorker::scheduleSocketRead() }); } -void FrontendSessionWorker::reconcileRequestedThreadReads() +void FrontendSessionWorker::reconcileIncompleteThreadReadAttempts() { const bool threadListComplete = currentState.threadList().value && currentState.threadList().value->complete; - for (auto iterator = requestedThreadReads.begin(); iterator != requestedThreadReads.end();) - { - const auto* thread = currentState.thread(*iterator); - if ((thread && thread->fullyLoaded) || (!thread && threadListComplete)) - iterator = requestedThreadReads.erase(iterator); - else - ++iterator; - } + const bool boundedStateOmitsThreads = synchronizedStateOmitsThreads(currentState); + const auto resolved = [&](const std::string& id) { + const auto* thread = currentState.thread(id); + return (thread && thread->fullyLoaded) + || (!thread && threadListComplete && !boundedStateOmitsThreads); + }; + std::erase_if(attemptedIncompleteThreadReads, + [&resolved](const auto& entry) { + return resolved(entry.first); + }); } void FrontendSessionWorker::beginArchivedThreadRefresh() @@ -1317,7 +1399,8 @@ void FrontendSessionWorker::socketDisconnected() clearOutbound(); clearInbound(); receiveContinuationScheduled = false; - requestedThreadReads.clear(); + threadReadsInFlight.clear(); + attemptedIncompleteThreadReads.clear(); if (!localShutdown) { if (recordPreReadyTransportFailure()) return; @@ -1341,7 +1424,8 @@ void FrontendSessionWorker::socketFailed(QLocalSocket::LocalSocketError) clearOutbound(); clearInbound(); receiveContinuationScheduled = false; - requestedThreadReads.clear(); + threadReadsInFlight.clear(); + attemptedIncompleteThreadReads.clear(); if (recordPreReadyTransportFailure()) return; if (automaticReconnectEnabled && currentLifecycle != Lifecycle::Failed) diff --git a/src/app/FrontendSessionWorker.h b/src/app/FrontendSessionWorker.h index a7513f9..e8bc732 100644 --- a/src/app/FrontendSessionWorker.h +++ b/src/app/FrontendSessionWorker.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -67,7 +68,7 @@ class FrontendSessionWorker : public QObject [[nodiscard]] bool ownsController() const noexcept; [[nodiscard]] std::uint64_t generation() const noexcept; [[nodiscard]] bool transportAffinityIsCurrentThread() const noexcept; - void loadThread(const QString& threadId); + void loadThread(const QString& threadId, bool retryIncomplete = false); [[nodiscard]] std::optional acquireController(OperationCompletion completion); [[nodiscard]] std::optional startThread(ThreadStartCompletion completion); [[nodiscard]] std::optional @@ -166,13 +167,16 @@ class FrontendSessionWorker : public QObject void socketBytesWritten(qint64 bytes); void socketDisconnected(); void socketFailed(QLocalSocket::LocalSocketError error); + void handleStateUpdate( + const ai::openai::codex::frontend::client::StateUpdate& update); void handleConnectionStateChange(const ai::openai::codex::frontend::client::ConnectionStateChange& change); void reportDiagnostic(QString message); void scheduleSocketRead(); void clearInbound() noexcept; [[nodiscard]] bool hasCompleteInboundFrame() const noexcept; void compactInbound() noexcept; - void reconcileRequestedThreadReads(); + void rememberIncompleteThreadReadAttempt(const std::string& threadId); + void reconcileIncompleteThreadReadAttempts(); void beginArchivedThreadRefresh(); void requestArchivedThreadPage(std::uint64_t generation, std::optional cursor); @@ -219,7 +223,13 @@ class FrontendSessionWorker : public QObject Lifecycle currentLifecycle = Lifecycle::Disconnected; QString detail; QString diagnosticDetail; - std::set requestedThreadReads; + std::set threadReadsInFlight; + // One successful incomplete read is enough for one immutable replacement + // epoch. A later StateReplaced publication may have evicted that + // requester-local cache population and therefore earns exactly one new + // recovery attempt without turning ordinary live revisions into polling. + std::map attemptedIncompleteThreadReads; + std::uint64_t incompleteReadRecoveryEpoch = 0; std::set archivedThreadListCursors; std::set modelListCursors; std::vector pendingModelCatalog; diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index 2322397..86f9a95 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -367,6 +367,7 @@ class StreamingMessageView final : public QTextEdit setProperty("streamAppendCount", 0); setProperty("fullReplacementCount", 0); setProperty("geometryInvalidationCount", 0); + setProperty("sourceMaterializationCount", 0); setProperty("markdownRenderMode", QStringLiteral("streaming-plain")); } @@ -526,6 +527,8 @@ QWidget* messageContentWidget(const QString& text, bool streaming) { auto* result = new WrappingLabel(text, true); result->setProperty("kind", "body"); + result->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); + result->setProperty("sourceMaterializationCount", 0); return result; } @@ -541,12 +544,16 @@ QWidget* messageContentWidget(const QString& text, bool streaming) result->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); result->setProperty("streamAppendCount", 0); result->setProperty("fullReplacementCount", 0); + result->setProperty("sourceMaterializationCount", 0); result->setProperty("markdownRenderMode", QStringLiteral("large-plain")); return result; } -QString messageContentText(const QWidget* content) +QString messageContentText(QWidget* content) { + content->setProperty( + "sourceMaterializationCount", + content->property("sourceMaterializationCount").toULongLong() + 1); if (const auto* label = dynamic_cast(content)) return label->content(); if (const auto* streaming = dynamic_cast(content)) @@ -556,11 +563,33 @@ QString messageContentText(const QWidget* content) return {}; } +qsizetype messageContentSize(const QWidget* content) +{ + if (const auto* label = dynamic_cast(content)) + return label->content().size(); + if (const auto* streaming = dynamic_cast(content)) + return streaming->content().size(); + if (const auto* editor = qobject_cast(content)) + return qMax(0, editor->document()->characterCount() - 1); + return 0; +} + +std::uint64_t messageContentUtf8Bytes(const QWidget* content) +{ + if (const auto* streaming = dynamic_cast(content)) + return streaming->utf8Bytes(); + return content->property("sourceUtf8Bytes").toULongLong(); +} + bool setMessageContentText(QWidget* content, const QString& text) { if (auto* label = dynamic_cast(content)) - return label->setContent(text); + { + const bool geometryChanged = label->setContent(text); + label->setProperty("sourceUtf8Bytes", static_cast(text.toUtf8().size())); + return geometryChanged; + } if (auto* streamingView = dynamic_cast(content)) return streamingView->replaceContent(text); else if (auto* editor = qobject_cast(content); editor && editor->toPlainText() != text) @@ -610,23 +639,33 @@ std::optional appendMessageContent(QWidget* content, return false; } +bool messageContentWidgetMatches(const QWidget* content, + qsizetype textSize, + bool streaming) +{ + const bool needsEditor = textSize > largeMessageEditorThreshold; + const bool hasEditor = qobject_cast(content) != nullptr; + const bool needsStreamingView = !needsEditor && streaming; + const bool hasStreamingView = dynamic_cast(content) != nullptr; + const bool hasMarkdownView = dynamic_cast(content) != nullptr; + return (needsEditor && hasEditor) + || (needsStreamingView && hasStreamingView) + || (!needsEditor && !needsStreamingView && hasMarkdownView); +} + QWidget* ensureMessageContentWidget(QVBoxLayout* layout, QWidget* content, const QString& text, bool streaming) { - const bool needsEditor = text.size() > largeMessageEditorThreshold; - const bool hasEditor = qobject_cast(content) != nullptr; - const bool needsStreamingView = !needsEditor && streaming; - const bool hasStreamingView = dynamic_cast(content) != nullptr; - const bool hasMarkdownView = dynamic_cast(content) != nullptr; - if ((needsEditor && hasEditor) - || (needsStreamingView && hasStreamingView) - || (!needsEditor && !needsStreamingView && hasMarkdownView)) + if (messageContentWidgetMatches(content, text.size(), streaming)) return content; QWidget* replacement = messageContentWidget(text, streaming); replacement->setObjectName(QStringLiteral("conversationMessageContent")); + replacement->setProperty( + "sourceMaterializationCount", + content->property("sourceMaterializationCount")); delete layout->replaceWidget(content, replacement); content->hide(); content->deleteLater(); @@ -2043,7 +2082,8 @@ QString segmentStorageKey(const QString& turnId, const QString& segmentId) std::vector timelineSegments(const sdk::State& state, const sdk::ThreadState& thread, const sdk::TurnState& turn, - qsizetype firstItem) + qsizetype firstItem, + qsizetype endItem) { std::vector result; if (turn.orderedItems.empty()) @@ -2066,8 +2106,11 @@ std::vector timelineSegments(const sdk::State& state, // Stable ordinal buckets keep activity-card identities from shifting on // every append while inspecting at most one partial bucket before the window. const qsizetype scanStart = firstItem - firstItem % activityWidth; + const qsizetype scanEnd = qMin( + qMax(scanStart, endItem), + static_cast(turn.orderedItems.size())); qsizetype activityBucket = -1; - for (qsizetype index = scanStart; index < static_cast(turn.orderedItems.size()); ++index) + for (qsizetype index = scanStart; index < scanEnd; ++index) { const auto& itemId = turn.orderedItems.at(index); const qsizetype itemBucket = index / activityWidth; @@ -2104,6 +2147,19 @@ std::vector timelineSegments(const sdk::State& state, return result; } +std::vector timelineSegments(const sdk::State& state, + const sdk::ThreadState& thread, + const sdk::TurnState& turn, + qsizetype firstItem) +{ + return timelineSegments( + state, + thread, + turn, + firstItem, + static_cast(turn.orderedItems.size())); +} + qsizetype timelineItemCount(const TimelineSegment& segment) { return qMax(1, static_cast(segment.items.size())); @@ -2142,16 +2198,105 @@ TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadSt return result; } +bool incompleteStateContainsRenderedTimeline( + const sdk::State& state, + const sdk::ThreadState& thread, + const QStringList& renderedTurnIds, + const QHash>& renderedTurnItemRanges, + const QHash& renderedSegmentIds, + const QHash& renderedSegmentItemIds, + qsizetype& inspectedItems) +{ + inspectedItems = 0; + constexpr qsizetype maximumRecoveryInspectedItems = + maximumRenderedTimelineItems + + (static_cast(maximumActivityItemsPerSegment) - 1) + * maximumRenderedTimelineTurns; + for (const QString& renderedTurnId : renderedTurnIds) + { + const sdk::TurnState* retainedTurn = state.turn( + thread.id, + ai::openai::codex::typed::TurnId{ + renderedTurnId.toStdString()}); + if (!retainedTurn) + return false; + + const auto range = renderedTurnItemRanges.constFind(renderedTurnId); + if (range == renderedTurnItemRanges.cend() + || range->first < 0 || range->second < range->first + || range->second + > static_cast(retainedTurn->orderedItems.size())) + return false; + const qsizetype activityWidth = + static_cast(maximumActivityItemsPerSegment); + const qsizetype scanStart = + range->first - range->first % activityWidth; + const qsizetype inspectedRange = range->second - scanStart; + if (inspectedRange < 0 + || inspectedRange + > maximumRecoveryInspectedItems - inspectedItems) + return false; + inspectedItems += inspectedRange; + + const std::vector retainedSegments = + timelineSegments( + state, + thread, + *retainedTurn, + range->first, + range->second); + QHash> retainedItemsBySegment; + retainedItemsBySegment.reserve( + static_cast(retainedSegments.size())); + for (const TimelineSegment& segment : retainedSegments) + { + QSet retainedItemIds; + retainedItemIds.reserve( + static_cast(segment.items.size())); + for (const sdk::ItemState* item : segment.items) + { + if (item) + retainedItemIds.insert(fromUtf8(item->id.value)); + } + retainedItemsBySegment.insert( + segment.id, std::move(retainedItemIds)); + } + for (const QString& renderedSegmentId : + renderedSegmentIds.value(renderedTurnId)) + { + const auto retained = + retainedItemsBySegment.constFind(renderedSegmentId); + if (retained == retainedItemsBySegment.cend()) + return false; + const QString storage = segmentStorageKey( + renderedTurnId, renderedSegmentId); + for (const QString& renderedItemId : + renderedSegmentItemIds.value(storage)) + { + if (!retained->contains(renderedItemId)) + return false; + } + } + } + return true; +} + QByteArray segmentPresentationKey(const sdk::State& state, const TimelineSegment& segment, bool typedPlanAvailable, - bool turnStreaming) + bool turnStreaming, + bool threadFullyLoaded) { QCryptographicHash hash(QCryptographicHash::Sha256); addPresentationValue(hash, segment.id); addPresentationValue(hash, segment.missing); addPresentationValue(hash, typedPlanAvailable); addPresentationValue(hash, turnStreaming); + // A bounded backend snapshot can preserve an empty turn shell while + // omitting its descendants. Completeness affects only that empty-state + // presentation; populated segments should keep their stable identity. + if (segment.items.empty()) + addPresentationValue(hash, threadFullyLoaded); for (const auto* item : segment.items) { addPresentationValue(hash, item != nullptr); @@ -2228,6 +2373,7 @@ QWidget* timelineSegmentWidget(const sdk::State& state, const TimelineSegment& segment, bool typedPlanAvailable, bool turnStreaming, + bool threadFullyLoaded, const ActivityExpansionState& activityExpansion, const std::function& layoutChanged) { @@ -2258,8 +2404,14 @@ QWidget* timelineSegmentWidget(const sdk::State& state, } else if (segment.items.empty()) { - addEmptyState(layout, QStringLiteral("No items in this turn"), - QStringLiteral("The synchronized turn currently has no retained items.")); + addEmptyState( + layout, + threadFullyLoaded ? QStringLiteral("No items in this turn") + : QStringLiteral("Conversation history incomplete"), + threadFullyLoaded + ? QStringLiteral("The synchronized turn currently has no retained items.") + : QStringLiteral( + "Some turns or items are unavailable in the current synchronized view.")); } else if (segment.items.size() == 1 && (segment.items.front()->kind.is(frontend::ThreadItemKind::UserMessage) @@ -2817,19 +2969,73 @@ void ConversationWidget::render(const sdk::State& state, const bool wasNearBottom = scrollBar->maximum() - previousScroll <= 72; const bool threadChanged = renderedThreadId != threadId || renderedNewThreadDraft != newThreadDraft; const auto* thread = threadId.isEmpty() ? nullptr : state.thread(threadId.toStdString()); + const auto capacityProvenance = state.capacityProvenance(); + const bool selectedThreadOmitted = !newThreadDraft && !thread + && !threadId.isEmpty() + && capacityProvenance + && capacityProvenance->omittedThreads > 0; + const bool selectedThreadUnresolved = !newThreadDraft && !thread + && !threadId.isEmpty() + && (selectedThreadOmitted + || (state.threadList().value + && !state.threadList().value->complete)); + const bool missingThreadPresentationChanged = + !thread && !threadChanged + && (renderedThreadFullyLoaded.has_value() + || renderedSelectedThreadOmitted != selectedThreadOmitted); + const bool threadCompletenessChanged = thread + && (!renderedThreadFullyLoaded + || *renderedThreadFullyLoaded + != thread->fullyLoaded); upcomingTurnDock->setCanonicalConfiguration( thread ? thread->executionConfiguration : std::optional{}, thread ? threadId : QString{}, newThreadDraft); - if (!thread && !threadChanged && threadId.isEmpty()) + // A bounded replacement is not deletion authority. Keep the same-thread + // widgets until an incomplete publication can account for every rendered + // descendant; requester-local Merge will make that true, while Replace is + // necessarily fullyLoaded and exact Absent changes the selection. + const bool renderedTimelineRetained = !renderedTurnIds.isEmpty(); + qsizetype recoveryInspectedItems = 0; + const bool incompleteTimelineRetained = + !thread || thread->fullyLoaded + || incompleteStateContainsRenderedTimeline( + state, + *thread, + renderedTurnIds, + renderedTurnItemRanges, + renderedSegmentIds, + renderedSegmentItemIds, + recoveryInspectedItems); + timelineHost->setProperty( + "recoveryInspectedTimelineItems", recoveryInspectedItems); + const bool incompleteTimelineRegressed = + !threadChanged && renderedTimelineRetained + && (selectedThreadUnresolved + || !incompleteTimelineRetained); + if (incompleteTimelineRegressed) + { + const QString recovery = QStringLiteral("History recovery pending"); + if (!threadDetail->text().contains(recovery)) + { + const QString detail = threadDetail->text(); + threadDetail->setText(detail.isEmpty() + ? recovery + : detail + QStringLiteral(" · ") + recovery); + threadDetail->setToolTip(threadDetail->text()); + } + return; + } + if (!thread && !threadChanged && !missingThreadPresentationChanged) return; if (!threadChanged && shouldFreezePresentation(threadId, newThreadDraft)) { markPresentationDeferred(); return; } - if (exactContentChanges && !threadChanged && thread && !newThreadDraft + if (exactContentChanges && !threadChanged && !threadCompletenessChanged + && thread && !newThreadDraft && updateExactMessageContent(state, threadId, *exactContentChanges)) return; if (threadChanged) @@ -2838,7 +3044,8 @@ void ConversationWidget::render(const sdk::State& state, deferredPresentationRequestScheduled = false; } const bool followLatest = threadChanged || wasNearBottom || followingLatest; - const bool exactContentOnly = exactContentChanges && !threadChanged && thread && !newThreadDraft + const bool exactContentOnly = exactContentChanges && !threadChanged + && !threadCompletenessChanged && thread && !newThreadDraft && !renderedSummaryKey.isEmpty(); const std::uint64_t generation = ++renderGeneration; bool timelineShrank = false; @@ -2851,6 +3058,11 @@ void ConversationWidget::render(const sdk::State& state, pendingViewportAnchor.clear(); renderedThreadId = threadId; renderedNewThreadDraft = newThreadDraft; + if (thread) + renderedThreadFullyLoaded = thread->fullyLoaded; + else + renderedThreadFullyLoaded.reset(); + renderedSelectedThreadOmitted = selectedThreadOmitted; if (threadChanged) { followingLatest = false; @@ -2868,7 +3080,9 @@ void ConversationWidget::render(const sdk::State& state, renderedTurnLabels.clear(); renderedTurnStatusLabels.clear(); renderedTurnItemLayouts.clear(); + renderedTurnItemRanges.clear(); renderedSegmentIds.clear(); + renderedSegmentItemIds.clear(); renderedSegmentKeys.clear(); renderedSegmentWidgets.clear(); clearLayout(timeline); @@ -2883,24 +3097,45 @@ void ConversationWidget::render(const sdk::State& state, renderedSummaryKey.clear(); turnFailure->hide(); } - contextPath->setText(newThreadDraft ? QStringLiteral("New thread draft") - : QStringLiteral("No thread selected")); + QString pathText; + QString titleText; + QString detailText; + QString emptyTitle; + QString emptyDetail; + if (newThreadDraft) { + pathText = QStringLiteral("New thread draft"); + titleText = QStringLiteral("New conversation"); + detailText = QStringLiteral( + "A real thread will be created when the first prompt is sent"); + emptyTitle = QStringLiteral("Start a new conversation"); + emptyDetail = QStringLiteral( + "Type a prompt below. Backend defaults will be used for the new thread."); + } else if (selectedThreadOmitted) { + pathText = QStringLiteral("History incomplete"); + titleText = QStringLiteral("Conversation history incomplete"); + detailText = QStringLiteral( + "This conversation is not available in the current synchronized view."); + emptyTitle = titleText; + emptyDetail = detailText; + } else { + pathText = QStringLiteral("No thread selected"); + titleText = QStringLiteral("No synchronized thread"); + detailText = QStringLiteral( + "Select a synchronized thread to view its conversation"); + emptyTitle = QStringLiteral("No thread selected"); + emptyDetail = QStringLiteral( + "Choose a synchronized thread from the sidebar."); + } + contextPath->setText(pathText); contextPath->setToolTip({}); - threadTitle->setText(newThreadDraft ? QStringLiteral("New conversation") - : QStringLiteral("No synchronized thread")); + threadTitle->setText(titleText); threadTitle->setToolTip({}); - threadDetail->setText(newThreadDraft - ? QStringLiteral("A real thread will be created when the first prompt is sent") - : QStringLiteral("Select a synchronized thread to view its conversation")); + threadDetail->setText(detailText); threadDetail->setToolTip({}); timelineWindowNotice->hide(); timelineHost->setProperty("renderedTimelineItems", 0); timelineHost->setProperty("retainedTimelineItems", 0); - addEmptyState(timeline, - newThreadDraft ? QStringLiteral("Start a new conversation") - : QStringLiteral("No thread selected"), - newThreadDraft ? QStringLiteral("Type a prompt below. Backend defaults will be used for the new thread.") - : QStringLiteral("Choose a synchronized thread from the sidebar.")); + addEmptyState(timeline, emptyTitle, emptyDetail); timelineGeometryChanged = true; } else @@ -2920,7 +3155,7 @@ void ConversationWidget::render(const sdk::State& state, .arg(thread->orderedTurns.size()) .arg(thread->orderedTurns.size() == 1 ? QString{} : QStringLiteral("s"))); if (!thread->fullyLoaded) - metadata.append(QStringLiteral("Loading conversation…")); + metadata.append(QStringLiteral("History incomplete")); threadDetail->setText(metadata.join(QStringLiteral(" · "))); threadDetail->setToolTip(threadDetail->text()); @@ -2959,13 +3194,18 @@ void ConversationWidget::render(const sdk::State& state, timelineWindowNotice->hide(); timelineHost->setProperty("renderedTimelineItems", 0); timelineHost->setProperty("retainedTimelineItems", 0); - if (threadChanged || !renderedTurnIds.isEmpty() || timeline->count() == 0) + if (threadChanged || threadCompletenessChanged + || !renderedTurnIds.isEmpty() || timeline->count() == 0) { clearTimelineState(); - addEmptyState(timeline, QStringLiteral("Ready for the first turn"), - thread->fullyLoaded - ? QStringLiteral("Use the upcoming-turn dock below to start this thread.") - : QStringLiteral("No turn projection is currently retained for this thread.")); + addEmptyState( + timeline, + thread->fullyLoaded ? QStringLiteral("Ready for the first turn") + : QStringLiteral("Conversation history incomplete"), + thread->fullyLoaded + ? QStringLiteral("Use the upcoming-turn dock below to start this thread.") + : QStringLiteral( + "Some turns or items are unavailable in the current synchronized view.")); timelineGeometryChanged = true; } } @@ -3020,12 +3260,14 @@ void ConversationWidget::render(const sdk::State& state, for (const QString& segmentId : renderedSegmentIds.take(turnId)) { const QString storage = segmentStorageKey(turnId, segmentId); + renderedSegmentItemIds.remove(storage); renderedSegmentKeys.remove(storage); renderedSegmentWidgets.remove(storage); } renderedTurnLabels.remove(turnId); renderedTurnStatusLabels.remove(turnId); renderedTurnItemLayouts.remove(turnId); + renderedTurnItemRanges.remove(turnId); if (QWidget* widget = renderedTurnWidgets.take(turnId)) { if (pendingViewportAnchor == widget @@ -3083,6 +3325,20 @@ void ConversationWidget::render(const sdk::State& state, { const auto* turn = visibleTurn.turn; const QString turnId = fromUtf8(turn->id.value); + const auto windowSlice = std::ranges::find_if( + window.turns, + [turn](const TimelineTurnSlice& slice) { + return slice.turn == turn; + }); + if (windowSlice != window.turns.cend()) + { + renderedTurnItemRanges.insert( + turnId, + qMakePair( + windowSlice->firstItem, + static_cast( + turn->orderedItems.size()))); + } QVBoxLayout* itemLayout = renderedTurnItemLayouts.value(turnId); QLabel* turnLabel = renderedTurnLabels.value(turnId); QLabel* statusLabel = renderedTurnStatusLabels.value(turnId); @@ -3158,6 +3414,7 @@ void ConversationWidget::render(const sdk::State& state, for (const QString& oldId : oldSegmentIds) { const QString storage = segmentStorageKey(turnId, oldId); + renderedSegmentItemIds.remove(storage); renderedSegmentKeys.remove(storage); renderedSegmentWidgets.remove(storage); } @@ -3199,12 +3456,21 @@ void ConversationWidget::render(const sdk::State& state, timelineGeometryChanged = true; } renderedSegmentKeys.remove(storage); + renderedSegmentItemIds.remove(storage); } } for (const TimelineSegment* segment : visibleTurn.segments) { const QString storage = segmentStorageKey(turnId, segment->id); + QStringList itemIds; + itemIds.reserve(static_cast(segment->items.size())); + for (const sdk::ItemState* item : segment->items) + { + if (item) + itemIds.append(fromUtf8(item->id.value)); + } + renderedSegmentItemIds.insert(storage, std::move(itemIds)); QWidget* oldWidget = renderedSegmentWidgets.value(storage); const ConversationContentUpdates* segmentContentChanges = nullptr; ConversationContentUpdates segmentContentStorage; @@ -3233,7 +3499,11 @@ void ConversationWidget::render(const sdk::State& state, const bool typedPlanAvailable = turn->plan.has_value(); const bool turnStreaming = turnStreamsMessages(*turn); const QByteArray segmentKey = segmentPresentationKey( - state, *segment, typedPlanAvailable, turnStreaming); + state, + *segment, + typedPlanAvailable, + turnStreaming, + thread->fullyLoaded); if (oldWidget && !explicitlyAffected && renderedSegmentKeys.value(storage) == segmentKey) continue; @@ -3279,6 +3549,7 @@ void ConversationWidget::render(const sdk::State& state, *segment, typedPlanAvailable, turnStreaming, + thread->fullyLoaded, expansion, [this] { activityLayoutChanged(); }); newWidget->setProperty("turnId", turnId); @@ -3359,22 +3630,36 @@ bool ConversationWidget::updateExactMessageContent( if (messageWidget->property("messageUser").toBool() || update.channel != sdk::ItemContentChannel::AgentText) return false; + const ai::openai::codex::typed::ThreadId typedThreadId{ + threadId.toStdString()}; + const ai::openai::codex::typed::TurnId typedTurnId{ + update.turnId.toStdString()}; + const ai::openai::codex::typed::ItemId typedItemId{ + update.itemId.toStdString()}; const auto expectedBytes = exactAppendResultBytes(*update.append); const auto descriptor = state.itemContentDescriptor( - ai::openai::codex::typed::ThreadId{threadId.toStdString()}, - ai::openai::codex::typed::TurnId{update.turnId.toStdString()}, - ai::openai::codex::typed::ItemId{update.itemId.toStdString()}, + typedThreadId, + typedTurnId, + typedItemId, update.channel); + const auto* turn = state.turn(typedThreadId, typedTurnId); + const auto* item = state.item(typedThreadId, typedTurnId, typedItemId); if (!expectedBytes || !descriptor || !descriptor->present - || descriptor->retainedUtf8Bytes != *expectedBytes) + || descriptor->retainedUtf8Bytes != *expectedBytes + || !turn || !item + || !item->kind.is(frontend::ThreadItemKind::AgentMessage)) return false; auto* content = messageWidget->findChild( QStringLiteral("conversationMessageContent")); if (!content) return false; - const QByteArray currentUtf8 = messageContentText(content).toUtf8(); - if (static_cast(currentUtf8.size()) - != update.append->baseContentBytes + const bool emptyCanonicalPlaceholder = + content->property("kind").toString() == QStringLiteral("meta") + && update.append->baseContentBytes == 0; + const std::uint64_t currentUtf8Bytes = emptyCanonicalPlaceholder + ? 0 + : messageContentUtf8Bytes(content); + if (currentUtf8Bytes != update.append->baseContentBytes || update.append->discardPrefixBytes > update.append->baseContentBytes) return false; @@ -3384,25 +3669,96 @@ bool ConversationWidget::updateExactMessageContent( if (!contentLayout) return false; QWidget* const previousContent = content; - // Content deltas are the authoritative streaming boundary. Some - // provider/result items report a terminal-looking status before - // their final append arrives, so status alone must not repeatedly - // send the growing text through the Markdown renderer. - content = ensureMessageContentWidget( - contentLayout, - content, - QString::fromUtf8(currentUtf8), - true); - const auto applied = appendMessageContent( - content, - update.append->baseContentBytes, - update.append->discardPrefixBytes, - update.append->delta); - if (!applied) - return false; - contentGeometryChanged = previousContent != content || *applied; - contentMayShrink = update.append->discardPrefixBytes - > update.append->deltaUtf8Bytes; + const bool authoritativeStreaming = + turnStreamsMessages(*turn) + || streamingMessageStatus(itemStatus(*item)); + if (!authoritativeStreaming) + { + auto* status = messageWidget->findChild( + QStringLiteral("conversationMessageStatus")); + auto* truncation = messageWidget->findChild( + QStringLiteral("conversationMessageTruncation")); + if (!status || !truncation) + return false; + const MessagePresentation presentation = messagePresentation( + *item, false, false); + content = ensureMessageContentWidget( + contentLayout, + content, + presentation.content, + false); + const bool rendererChanged = previousContent != content; + contentGeometryChanged = rendererChanged + || applyMessagePresentation( + status, + content, + truncation, + presentation); + // Markdown reparsing can reduce its preferred height even + // when the raw source only grew (for example, when this + // append closes an emphasis span or fenced block). + contentMayShrink = true; + } + else + { + bool mutationGeometryChanged = false; + if (emptyCanonicalPlaceholder) + { + // The visible copy is explanatory UI text, not canonical + // message content. Reset that small placeholder directly + // so the first real delta still enters the O(delta) path. + if (dynamic_cast(content)) + mutationGeometryChanged = setMessageContentText( + content, QString{}); + else + content = ensureMessageContentWidget( + contentLayout, content, QString{}, true); + if (content->property("kind").toString() + != QStringLiteral("body")) + { + content->setProperty("kind", QStringLiteral("body")); + content->style()->unpolish(content); + content->style()->polish(content); + } + } + else if (!dynamic_cast(content) + && !qobject_cast(content)) + { + content = ensureMessageContentWidget( + contentLayout, + content, + messageContentText(content), + true); + } + + const auto applied = appendMessageContent( + content, + update.append->baseContentBytes, + update.append->discardPrefixBytes, + update.append->delta); + if (!applied) + return false; + mutationGeometryChanged = mutationGeometryChanged || *applied; + + // Select the renderer from the post-append size. In + // particular, the append that crosses 64 KiB performs the + // one required source materialization immediately; it does + // not leave the small-message renderer alive until a later + // event happens to arrive. + if (!messageContentWidgetMatches( + content, messageContentSize(content), true)) + { + content = ensureMessageContentWidget( + contentLayout, + content, + messageContentText(content), + true); + } + contentGeometryChanged = previousContent != content + || mutationGeometryChanged; + contentMayShrink = update.append->discardPrefixBytes + > update.append->deltaUtf8Bytes; + } affectedStorage = messageStorage; } else diff --git a/src/ui/ConversationWidget.h b/src/ui/ConversationWidget.h index fabe2c3..d08f048 100644 --- a/src/ui/ConversationWidget.h +++ b/src/ui/ConversationWidget.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -129,6 +130,8 @@ class ConversationWidget : public QWidget QVBoxLayout* timeline = nullptr; QTimer* layoutSettleTimer = nullptr; QString renderedThreadId; + std::optional renderedThreadFullyLoaded; + bool renderedSelectedThreadOmitted = false; // Identity only; conversation content remains owned by immutable AISuite State. QByteArray renderedSummaryKey; QStringList renderedTurnIds; @@ -136,7 +139,17 @@ class ConversationWidget : public QWidget QHash renderedTurnLabels; QHash renderedTurnStatusLabels; QHash renderedTurnItemLayouts; + // Original bounded item ranges for the materialized tail of each turn. + // Incomplete replacement recovery rechecks only these ranges, so a large + // retained prefix or a burst of later appends cannot turn proof of the + // existing presentation into an unbounded GUI-thread scan. + QHash> renderedTurnItemRanges; QHash renderedSegmentIds; + // Exact item identities retained by each rendered segment. Activity cards + // group several descendants under one stable segment identity, so segment + // IDs alone cannot prove that an incomplete State still contains every + // rendered row. + QHash renderedSegmentItemIds; QHash renderedSegmentKeys; QHash renderedSegmentWidgets; QPointer pendingViewportAnchor; diff --git a/src/ui/WorkbenchWidget.cpp b/src/ui/WorkbenchWidget.cpp index ebf0eb4..c8525a1 100644 --- a/src/ui/WorkbenchWidget.cpp +++ b/src/ui/WorkbenchWidget.cpp @@ -302,6 +302,28 @@ WorkbenchWidget::~WorkbenchWidget() void WorkbenchWidget::scheduleStateRefresh(const detail::StateUpdateScope& scope) { + const bool selectedThreadExactlyRemoved = + scope.removedThreadIds.contains(selectedThreadId); + if (selectedThreadExactlyRemoved) + authoritativelyRemovedSelectedThreadId = selectedThreadId; + else if (authoritativelyRemovedSelectedThreadId == selectedThreadId + && (!selectedThreadId.isEmpty() + && (scope.affectedThreadIds.contains(selectedThreadId) + || frontendSession.state().thread( + selectedThreadId.toStdString())))) { + // Match mailbox ordering across the separate 16 ms presentation + // window: newer exact presence supersedes an already delivered + // tombstone before a later bounded omission can become ambiguous. + authoritativelyRemovedSelectedThreadId.clear(); + } + // If a pathological removal burst exceeded the bounded GUI mailbox, ask + // the backend for the one identity the presentation actually needs. An + // absent result publishes one exact tombstone; an omitted result restores + // the retained thread without guessing from global capacity provenance. + if (scope.removedThreadIdsOverflowed && !selectedThreadExactlyRemoved + && !selectedThreadId.isEmpty() + && frontendSession.state().thread(selectedThreadId.toStdString()) == nullptr) + frontendSession.loadThread(selectedThreadId, true); const bool currentSelectionAffected = scope.affectedThreadIds.contains(selectedThreadId); const bool awaitedSelectionAffected = !newThreadIdAwaitingState.isEmpty() && scope.affectedThreadIds.contains(newThreadIdAwaitingState); @@ -446,6 +468,9 @@ void WorkbenchWidget::scheduleStateRefresh(const detail::StateUpdateScope& scope void WorkbenchWidget::refreshLifecycle() { using Lifecycle = FrontendSession::Lifecycle; + const bool ready = frontendSession.lifecycle() == Lifecycle::Ready; + const bool becameReady = ready && !frontendWasReady; + frontendWasReady = ready; QString color = QStringLiteral("#667085"); QString title = QStringLiteral("App server disconnected"); QString detail = frontendSession.statusText(); @@ -501,6 +526,13 @@ void WorkbenchWidget::refreshLifecycle() } refreshControllerStatus(); refreshControls(); + // Inspector-projected selections deliberately survive reconnects. The + // worker clears its bounded read ownership on disconnect, so one explicit + // retry at the next Ready boundary restores that selection even when no + // later presentation event happens to arrive. + if (detail::shouldRetryProjectedSelectionAfterReady( + becameReady, selectedThreadId, projectedAgentThreadId)) + frontendSession.loadThread(selectedThreadId, true); } void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, @@ -527,7 +559,14 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, && frontendSession.archivedThreadDiscoveryComplete(); const bool threadDiscoveryTerminal = threadListComplete && frontendSession.archivedThreadDiscoveryTerminal(); + const auto capacityProvenance = state.capacityProvenance(); + const std::size_t omittedThreads = capacityProvenance + ? capacityProvenance->omittedThreads + : 0; const QString previousThreadId = selectedThreadId; + const bool selectedAuthoritativelyRemoved = + authoritativelyRemovedSelectedThreadId == selectedThreadId; + authoritativelyRemovedSelectedThreadId.clear(); if (!newThreadIdAwaitingState.isEmpty() && state.thread(newThreadIdAwaitingState.toStdString()) != nullptr) { @@ -538,8 +577,13 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, const bool awaitingSelectedThread = !newThreadIdAwaitingState.isEmpty() && selectedThreadId == newThreadIdAwaitingState; if (!selectedThreadId.isEmpty() - && state.thread(selectedThreadId.toStdString()) == nullptr && ready && threadDiscoveryTerminal - && !awaitingSelectedThread) + && state.thread(selectedThreadId.toStdString()) == nullptr + && detail::shouldClearMissingSelectedThread( + ready, + threadDiscoveryTerminal, + awaitingSelectedThread, + omittedThreads, + selectedAuthoritativelyRemoved)) selectedThreadId.clear(); if (selectedThreadId.isEmpty() && !threads.empty() && newThreadIdAwaitingState.isEmpty()) selectedThreadId = QString::fromStdString(threads.front().id.value); @@ -578,6 +622,10 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, const auto* selected = !selectedThreadId.isEmpty() ? state.thread(selectedThreadId.toStdString()) : nullptr; + const bool selectedMissingFromBoundedState = !selected + && !selectedThreadId.isEmpty() + && !awaitingSelectedThread + && omittedThreads > 0; if (refreshSelectedPresentation) { const QString context = ready && selected && selected->cwd ? QString::fromStdString(selected->cwd->value) @@ -639,7 +687,7 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, : QStringLiteral("Codex has no pending requests")); interactiveRequestDialog->synchronize(state); - if (selected && !selected->fullyLoaded && projectedAgentThreadId != selectedThreadId) + if ((selected && !selected->fullyLoaded) || selectedMissingFromBoundedState) frontendSession.loadThread(selectedThreadId); reconcileAutomaticResumeState(); @@ -672,6 +720,7 @@ void WorkbenchWidget::selectThread(const QString& threadId) selectedInspectorTurnId.clear(); selectedThreadId = threadId; projectedAgentThreadId.clear(); + frontendSession.loadThread(selectedThreadId, true); if (semanticSelectionChanged) conversation->setWriteStatus({}); refreshState(); @@ -695,6 +744,7 @@ void WorkbenchWidget::selectProjectedAgentThread(const QString& threadId) selectedInspectorTurnId.clear(); selectedThreadId = threadId; projectedAgentThreadId = threadId; + frontendSession.loadThread(selectedThreadId, true); if (semanticSelectionChanged) conversation->setWriteStatus({}); refreshState(); diff --git a/src/ui/WorkbenchWidget.h b/src/ui/WorkbenchWidget.h index cbd8236..68d7f64 100644 --- a/src/ui/WorkbenchWidget.h +++ b/src/ui/WorkbenchWidget.h @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,27 @@ namespace codexui { namespace detail { struct StateUpdateScope; + +[[nodiscard]] constexpr bool shouldClearMissingSelectedThread( + bool ready, + bool threadDiscoveryTerminal, + bool awaitingSelectedThread, + std::size_t omittedThreads, + bool authoritativelyRemoved) noexcept +{ + return authoritativelyRemoved + || (ready && threadDiscoveryTerminal && !awaitingSelectedThread + && omittedThreads == 0); +} + +[[nodiscard]] inline bool shouldRetryProjectedSelectionAfterReady( + bool becameReady, + const QString& selectedThreadId, + const QString& projectedAgentThreadId) noexcept +{ + return becameReady && !selectedThreadId.isEmpty() + && selectedThreadId == projectedAgentThreadId; +} } class ConversationWidget; @@ -183,6 +205,10 @@ class WorkbenchWidget : public QWidget QPushButton* reconnectButton = nullptr; InteractiveRequestDialog* interactiveRequestDialog = nullptr; QString selectedThreadId; + // Only the current selection matters to presentation reconciliation. One + // retained identity keeps the 16 ms GUI coalescing window strictly + // bounded even during a pathological removal burst. + QString authoritativelyRemovedSelectedThreadId; QString selectedInspectorTurnId; QString projectedAgentThreadId; QString newThreadIdAwaitingState; @@ -231,6 +257,7 @@ class WorkbenchWidget : public QWidget bool inspectorRefreshPending = false; bool sidebarRefreshPending = false; bool sidebarFullRefreshPending = false; + bool frontendWasReady = false; QStringList sidebarThreadRefreshPending; QSet sidebarThreadRefreshPendingSet; }; diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index 564924f..263ecda 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -68,6 +68,7 @@ struct ThreadFixture { std::string id; std::vector turns; + bool fullyLoaded = true; }; bool expect(bool condition, const char* message) @@ -129,7 +130,7 @@ frontend::Json messageJson(const std::string& threadId, const std::string& turnId, const MessageFixture& fixture) { - constexpr std::size_t initialCommandOutputBytes = 12U * 1024U; + constexpr std::size_t initialIncrementalContentBytes = 12U * 1024U; frontend::Json data = frontend::Json::object(); if (fixture.kind == frontend::ThreadItemKind::UserMessage) { data = frontend::Json{{"clientId", nullptr}, @@ -155,15 +156,26 @@ frontend::Json messageJson(const std::string& threadId, } const bool carriesCommandOutput = fixture.kind == frontend::ThreadItemKind::CommandExecution || fixture.kind == frontend::ThreadItemKind::FileChange; + const std::string initialAgentText = fixture.kind == frontend::ThreadItemKind::AgentMessage + ? fixture.text.substr( + 0, + std::min( + fixture.text.size(), + initialIncrementalContentBytes)) + : std::string{}; const std::string summary = fixture.kind == frontend::ThreadItemKind::UserMessage ? std::string{} : carriesCommandOutput ? fixture.text.substr(0, std::min(fixture.text.size(), 500)) + : fixture.kind == frontend::ThreadItemKind::AgentMessage + ? initialAgentText : fixture.text; const std::string initialCommandOutput = carriesCommandOutput ? fixture.text.substr( 0, - std::min(fixture.text.size(), initialCommandOutputBytes)) + std::min( + fixture.text.size(), + initialIncrementalContentBytes)) : std::string{}; return frontend::Json{{"id", fixture.id}, {"type", frontend::toString(fixture.kind)}, @@ -171,7 +183,7 @@ frontend::Json messageJson(const std::string& threadId, {"turnId", turnId}, {"status", fixture.status}, {"summary", summary}, - {"agentText", fixture.kind == frontend::ThreadItemKind::AgentMessage ? fixture.text : ""}, + {"agentText", initialAgentText}, {"reasoningText", fixture.kind == frontend::ThreadItemKind::Reasoning ? fixture.text @@ -185,7 +197,8 @@ frontend::Json messageJson(const std::string& threadId, {"extensions", frontend::Json::object()}}; } -client::State makeState(const std::vector& fixtures) +client::State makeState(const std::vector& fixtures, + std::size_t omittedThreads = 0) { client::ClientOptions options; options.requestedCapabilities = {frontend::FrontendCapability::CompleteThreadItems}; @@ -256,7 +269,7 @@ client::State makeState(const std::vector& fixtures) threads.push_back(frontend::Json{{"id", threadFixture.id}, {"title", threadFixture.id}, {"status", "idle"}, - {"fullyLoaded", true}, + {"fullyLoaded", threadFixture.fullyLoaded}, {"executionConfiguration", executionConfiguration}, {"turns", std::move(turns)}, {"extensions", frontend::Json::object()}}); @@ -273,6 +286,12 @@ client::State makeState(const std::vector& fixtures) {"omittedCodexExtensions", 0}, {"journal", {{"oldestReplayableAfter", 0}, {"currentSequence", 0}}}, {"sequenceExhausted", false}}; + if (omittedThreads > 0) { + state["capacityProvenance"] = { + {"omittedThreads", omittedThreads}, + {"truncated", true}, + }; + } if (!connection .receive(frontend::ServerMessage{ frontend::Snapshot{frontend::SequenceNumber{0}, std::move(state)}}) @@ -283,27 +302,38 @@ client::State makeState(const std::vector& fixtures) // Exercise the public negotiated append-v2 path instead of putting an // over-capacity scalar into a synthetic Snapshot. This mirrors how the - // real backend restores complete retained command output incrementally. - constexpr std::size_t initialCommandOutputBytes = 12U * 1024U; - constexpr std::size_t commandOutputDeltaBytes = 12U * 1024U; + // real backend restores complete retained message and command content + // incrementally. + constexpr std::size_t initialIncrementalContentBytes = 12U * 1024U; + constexpr std::size_t incrementalContentDeltaBytes = 12U * 1024U; std::uint64_t sequence = 0; for (const ThreadFixture& thread : fixtures) { for (const TurnFixture& turn : thread.turns) { for (const MessageFixture& item : turn.messages) { - if (item.kind != frontend::ThreadItemKind::CommandExecution - && item.kind != frontend::ThreadItemKind::FileChange) + const bool carriesCommandOutput = + item.kind == frontend::ThreadItemKind::CommandExecution + || item.kind == frontend::ThreadItemKind::FileChange; + const bool carriesAgentText = + item.kind == frontend::ThreadItemKind::AgentMessage; + if (!carriesCommandOutput && !carriesAgentText) continue; - std::size_t retained = std::min(item.text.size(), initialCommandOutputBytes); + std::size_t retained = std::min( + item.text.size(), initialIncrementalContentBytes); while (retained < item.text.size()) { const std::size_t deltaBytes = - std::min(commandOutputDeltaBytes, item.text.size() - retained); + std::min( + incrementalContentDeltaBytes, + item.text.size() - retained); frontend::FrontendEvent event{ frontend::SequenceNumber{++sequence}, "item.content.updated", frontend::Json{{"threadId", thread.id}, {"turnId", turn.id}, {"itemId", item.id}, - {"channel", "commandOutput"}, + {"channel", + carriesCommandOutput + ? "commandOutput" + : "agentText"}, {"content", ""}, {"contentDelta", item.text.substr(retained, deltaBytes)}, {"baseContentBytes", retained}, @@ -434,6 +464,8 @@ QString messageSourceText(const QWidget* widget) return messageSourceText(label); if (const auto* editor = qobject_cast(widget)) return editor->toPlainText(); + if (const auto* editor = qobject_cast(widget)) + return editor->toPlainText(); return {}; } @@ -465,6 +497,16 @@ bool hasLabel(codexui::ConversationWidget& conversation, const QString& text) return false; } +bool hasLabelContaining(codexui::ConversationWidget& conversation, + const QString& text) +{ + return std::ranges::any_of( + conversation.findChildren(), + [&text](const QLabel* label) { + return label->text().contains(text); + }); +} + QStringList renderedTurnIds(codexui::ConversationWidget& conversation) { QStringList result; @@ -1123,6 +1165,263 @@ bool testInPlaceMessageReplacement() return passed; } +bool testIncompleteThreadPresentation() +{ + ThreadFixture fixture{"bounded-thread", + {{"bounded-turn", {}}}, + false}; + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({fixture}), QStringLiteral("bounded-thread")); + settleTimeline(); + + bool passed = expect( + hasLabel(conversation, QStringLiteral("Conversation history incomplete")) + && hasLabel( + conversation, + QStringLiteral( + "Some turns or items are unavailable in the current synchronized view.")) + && !hasLabel(conversation, QStringLiteral("No items in this turn")), + "an incomplete bounded thread must distinguish its empty turn from a genuinely empty turn"); + + fixture.fullyLoaded = true; + conversation.render(makeState({fixture}), QStringLiteral("bounded-thread")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("No items in this turn")) + && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), + "an authoritative full-thread replacement must restore the genuine empty-turn presentation"); + + ThreadFixture noTurns{"bounded-thread-without-turns", {}, false}; + conversation.render(makeState({noTurns}), + QStringLiteral("bounded-thread-without-turns")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("Conversation history incomplete")) + && !hasLabel(conversation, QStringLiteral("Ready for the first turn")), + "an incomplete thread without retained turn shells must not masquerade as a new empty thread"); + + noTurns.fullyLoaded = true; + conversation.render(makeState({noTurns}), + QStringLiteral("bounded-thread-without-turns")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("Ready for the first turn")) + && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), + "a same-thread authoritative replacement must refresh the no-turn presentation"); + + noTurns.fullyLoaded = false; + conversation.render(makeState({noTurns}), + QStringLiteral("bounded-thread-without-turns")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("Conversation history incomplete")) + && !hasLabel(conversation, QStringLiteral("Ready for the first turn")), + "a same-thread bounded replacement must refresh the no-turn presentation"); + + conversation.render(makeState({}, 1), + QStringLiteral("bounded-thread-without-turns")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("Conversation history incomplete")) + && hasLabel( + conversation, + QStringLiteral( + "This conversation is not available in the current synchronized view.")) + && !hasLabel(conversation, QStringLiteral("No synchronized thread")) + && !hasLabel(conversation, QStringLiteral("No thread selected")), + "an explicitly omitted selected thread must not masquerade as no selection"); + + conversation.render(makeState({}), + QStringLiteral("bounded-thread-without-turns")); + settleTimeline(); + passed &= expect( + hasLabel(conversation, QStringLiteral("No synchronized thread")) + && hasLabel(conversation, QStringLiteral("No thread selected")) + && !hasLabel(conversation, QStringLiteral("Conversation history incomplete")), + "removing omission provenance must refresh a same-ID missing-thread presentation"); + return passed; +} + +bool testIncompleteReplacementPreservesRenderedTimeline() +{ + ThreadFixture complete{ + "replacement-retention", + {{"replacement-retention-turn", + {{"replacement-retention-item", + frontend::ThreadItemKind::AgentMessage, + "retained answer"}}}}}; + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({complete}), + QStringLiteral("replacement-retention")); + settleTimeline(); + + QPointer retainedSegment = segment( + conversation, QStringLiteral("message:replacement-retention-item")); + QPointer retainedContent = messageContent(retainedSegment); + QWidget* const retainedSegmentAddress = retainedSegment.data(); + QWidget* const retainedContentAddress = retainedContent.data(); + + conversation.render(makeState({}, 1), + QStringLiteral("replacement-retention")); + settleTimeline(); + bool passed = expect( + retainedSegment && retainedSegment.data() == retainedSegmentAddress + && retainedContent && retainedContent.data() == retainedContentAddress + && messageSourceText(retainedContent) == QStringLiteral("retained answer") + && hasLabelContaining(conversation, + QStringLiteral("History recovery pending")), + "an omitted same-thread replacement must retain the rendered timeline while recovery is pending"); + + ThreadFixture partialWithoutDescendants{ + "replacement-retention", {}, false}; + conversation.render(makeState({partialWithoutDescendants}), + QStringLiteral("replacement-retention")); + settleTimeline(); + passed &= expect( + retainedSegment && retainedSegment.data() == retainedSegmentAddress + && retainedContent && retainedContent.data() == retainedContentAddress + && messageSourceText(retainedContent) == QStringLiteral("retained answer"), + "an incomplete header-only replacement must not delete rendered descendants"); + + ThreadFixture merged = complete; + merged.fullyLoaded = false; + merged.turns.front().messages.push_back( + {"replacement-retention-new-item", + frontend::ThreadItemKind::AgentMessage, + "merged continuation"}); + conversation.render(makeState({merged}), + QStringLiteral("replacement-retention")); + settleTimeline(); + passed &= expect( + retainedSegment && retainedSegment.data() == retainedSegmentAddress + && retainedContent && retainedContent.data() == retainedContentAddress + && hasLabel(conversation, QStringLiteral("merged continuation")) + && !hasLabelContaining(conversation, + QStringLiteral("History recovery pending")), + "an incomplete requester-local Merge that accounts for rendered descendants must reconcile in place"); + + conversation.render(makeState({}), QString{}); + settleTimeline(); + passed &= expect( + !retainedSegment && !retainedContent + && hasLabel(conversation, QStringLiteral("No thread selected")) + && !hasLabel(conversation, QStringLiteral("retained answer")) + && !hasLabel(conversation, QStringLiteral("merged continuation")), + "an exact Absent transition must clear a populated rendered timeline"); + + conversation.render(makeState({complete}), + QStringLiteral("replacement-retention")); + settleTimeline(); + QPointer replaceAuthoritySegment = segment( + conversation, QStringLiteral("message:replacement-retention-item")); + ThreadFixture exactReplacement{ + "replacement-retention", {}, true}; + conversation.render(makeState({exactReplacement}), + QStringLiteral("replacement-retention")); + settleTimeline(); + passed &= expect( + !replaceAuthoritySegment + && hasLabel(conversation, QStringLiteral("Ready for the first turn")), + "a fully-loaded Replace remains authoritative to delete absent descendants"); + + ThreadFixture completeActivities{ + "grouped-activity-retention", + {{"grouped-activity-turn", + {{"grouped-activity-first", + frontend::ThreadItemKind::Reasoning, + "first retained activity"}, + {"grouped-activity-second", + frontend::ThreadItemKind::Reasoning, + "second retained activity"}}}}}; + codexui::ConversationWidget groupedConversation; + groupedConversation.resize(900, 700); + groupedConversation.show(); + groupedConversation.render( + makeState({completeActivities}), + QStringLiteral("grouped-activity-retention")); + settleTimeline(); + QPointer activitySegment = segment( + groupedConversation, + QStringLiteral("activities:grouped-activity-first")); + QPointer secondActivityRow; + if (activitySegment) + { + for (QWidget* row : activitySegment->findChildren( + QStringLiteral("conversationActivityRow"))) + { + if (row->property("itemId").toString() + == QStringLiteral("grouped-activity-second")) + secondActivityRow = row; + } + } + QWidget* const activitySegmentAddress = activitySegment.data(); + QWidget* const secondActivityRowAddress = secondActivityRow.data(); + + ThreadFixture partialActivities = completeActivities; + partialActivities.fullyLoaded = false; + partialActivities.turns.front().messages.pop_back(); + groupedConversation.render( + makeState({partialActivities}), + QStringLiteral("grouped-activity-retention")); + settleTimeline(); + passed &= expect( + activitySegment && activitySegment.data() == activitySegmentAddress + && secondActivityRow + && secondActivityRow.data() == secondActivityRowAddress + && hasLabelContaining(groupedConversation, + QStringLiteral("History recovery pending")), + "an incomplete activity group must preserve every rendered descendant, not only the row that owns its segment identity"); + + ThreadFixture largePrefix{"bounded-recovery-prefix", {{"bounded-recovery-turn", {}}}}; + constexpr int largePrefixItems = 2'048; + largePrefix.turns.front().messages.reserve(largePrefixItems + 1); + for (int index = 0; index < largePrefixItems; ++index) + { + largePrefix.turns.front().messages.push_back( + {"bounded-recovery-item-" + std::to_string(index), + frontend::ThreadItemKind::AgentMessage, + "prefix " + std::to_string(index)}); + } + codexui::ConversationWidget boundedConversation; + boundedConversation.resize(900, 700); + boundedConversation.show(); + boundedConversation.render( + makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); + settleTimeline(); + QPointer retainedTail = segment( + boundedConversation, + QStringLiteral("message:bounded-recovery-item-2047")); + QWidget* const retainedTailAddress = retainedTail.data(); + + largePrefix.fullyLoaded = false; + largePrefix.turns.front().messages.push_back( + {"bounded-recovery-appended", + frontend::ThreadItemKind::AgentMessage, + "bounded appended tail"}); + boundedConversation.render( + makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); + settleTimeline(); + QWidget* boundedHost = timeline(boundedConversation); + const qlonglong maximumRecoveryScan = + boundedHost + ? boundedHost->property("maximumRenderedItems").toLongLong() + + 15 * boundedHost->property("maximumRenderedTurns").toLongLong() + : 0; + passed &= expect( + retainedTail && retainedTail.data() == retainedTailAddress + && hasLabel(boundedConversation, + QStringLiteral("bounded appended tail")) + && boundedHost + && boundedHost->property("recoveryInspectedTimelineItems").toLongLong() + <= maximumRecoveryScan, + "incomplete replacement recovery must inspect only the previously rendered bounded tail, not a large retained prefix or later appends"); + return passed; +} + bool testStreamingPlainTextAndTerminalMarkdown() { ThreadFixture fixture{ @@ -1150,6 +1449,8 @@ bool testStreamingPlainTextAndTerminalMarkdown() QStringLiteral("**stream** [docs](https://example.com)\n\n`code`\n\n![secret](file:///etc/passwd)")}; bool passed = true; QString previous = QStringLiteral("**stream**"); + const qulonglong sourceMaterializationsBeforeStreaming = + content ? content->property("sourceMaterializationCount").toULongLong() : 0; for (const QString& update : streamedContent) { fixture.turns.front().messages.front().text = update.toStdString(); const QString delta = update.mid(previous.size()); @@ -1178,6 +1479,11 @@ bool testStreamingPlainTextAndTerminalMarkdown() && content->property("streamAppendCount").toULongLong() == static_cast(streamedContent.size()), "each verified streaming delta must use the cursor append path"); + passed &= expect( + content + && content->property("sourceMaterializationCount").toULongLong() + == sourceMaterializationsBeforeStreaming, + "verified streaming deltas must not materialize or transcode the accumulated message source"); const QString finalContent = streamedContent.back() + QStringLiteral("\n\n_final answer_"); @@ -1198,6 +1504,165 @@ bool testStreamingPlainTextAndTerminalMarkdown() return passed; } +bool testEmptyAgentMessageAcceptsFirstExactDelta() +{ + ThreadFixture fixture{ + "empty-streaming-message", + {{"turn-empty-streaming-message", + {{"item-empty-streaming-message", + frontend::ThreadItemKind::AgentMessage, + "", + "started"}}, + std::nullopt, + "inProgress", + true, + false}}}; + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({fixture}), + QStringLiteral("empty-streaming-message")); + settleTimeline(); + + QWidget* message = segment( + conversation, QStringLiteral("message:item-empty-streaming-message")); + QPointer content = messageContent(message); + QWidget* const contentAddress = content.data(); + bool passed = expect( + content && content->property("kind").toString() == QStringLiteral("meta") + && messageSourceText(content) + == QStringLiteral("No retained message content"), + "an empty canonical agent message must initially show explanatory placeholder text"); + + const QString firstContent = QStringLiteral("first **streamed** content"); + fixture.turns.front().messages.front().text = firstContent.toStdString(); + const auto exactChange = appendUpdate( + QStringLiteral("turn-empty-streaming-message"), + QStringLiteral("item-empty-streaming-message"), + client::ItemContentChannel::AgentText, + 0, + firstContent); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), QStringLiteral("empty-streaming-message"), exactChange); + settleEvents(); + + QWidget* const updatedContent = messageContent(message); + passed &= expect( + updatedContent && updatedContent == contentAddress + && updatedContent->property("kind").toString() == QStringLiteral("body") + && updatedContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain") + && messageSourceText(updatedContent) == firstContent + && updatedContent->property("streamAppendCount").toULongLong() == 1 + && updatedContent->property("sourceMaterializationCount").toULongLong() == 0, + "the first exact delta must replace only the placeholder and append without materializing the canonical source"); + return passed; +} + +bool testMaximumRetainedAgentMessageStaysIncremental() +{ + // AISuite's negotiated agentText append channel retains at most 32 KiB. + // CodexUI's large-message renderer threshold is 64 KiB, so a retained + // streaming agent message cannot validly cross it. + constexpr qsizetype maximumRetainedAgentText = 32 * 1024; + const QString initial(maximumRetainedAgentText - 1, QLatin1Char('a')); + ThreadFixture fixture{ + "streaming-threshold", + {{"turn-streaming-threshold", + {{"item-streaming-threshold", + frontend::ThreadItemKind::AgentMessage, + initial.toStdString(), + "started"}}}}}; + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({fixture}), QStringLiteral("streaming-threshold")); + settleTimeline(); + + QWidget* message = segment( + conversation, QStringLiteral("message:item-streaming-threshold")); + QPointer streamingContent = messageContent(message); + QWidget* const streamingAddress = streamingContent.data(); + bool passed = expect( + streamingContent + && streamingContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain"), + "a near-maximum retained agent message must start in the incremental renderer"); + + QString current = initial + QLatin1Char('b'); + fixture.turns.front().messages.front().text = current.toStdString(); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), + QStringLiteral("streaming-threshold"), + appendUpdate(QStringLiteral("turn-streaming-threshold"), + QStringLiteral("item-streaming-threshold"), + client::ItemContentChannel::AgentText, + static_cast(initial.toUtf8().size()), + QStringLiteral("b"))); + settleEvents(); + QWidget* const updatedContent = messageContent(message); + passed &= expect( + updatedContent && updatedContent == streamingAddress + && updatedContent->property("markdownRenderMode").toString() + == QStringLiteral("streaming-plain") + && messageSourceText(updatedContent) == current + && updatedContent->property("streamAppendCount").toULongLong() == 1 + && updatedContent->property("sourceMaterializationCount").toULongLong() == 0, + "the append reaching the retained agent-text maximum must stay incremental without materializing the accumulated source"); + return passed; +} + +bool testTerminalMarkdownAcceptsLateExactDelta() +{ + const QString initial = QStringLiteral("**finished** result"); + ThreadFixture fixture{ + "terminal-late-delta", + {{"turn-terminal-late-delta", + {{"item-terminal-late-delta", + frontend::ThreadItemKind::AgentMessage, + initial.toStdString(), + "completed"}}}}}; + codexui::ConversationWidget conversation; + conversation.resize(900, 700); + conversation.show(); + conversation.render(makeState({fixture}), QStringLiteral("terminal-late-delta")); + settleTimeline(); + + QWidget* message = segment( + conversation, QStringLiteral("message:item-terminal-late-delta")); + QPointer content = messageContent(message); + QWidget* const contentAddress = content.data(); + bool passed = expect( + content && qobject_cast(content) + && content->property("markdownRenderMode").toString() + == QStringLiteral("markdown"), + "a terminal agent message must start in the Markdown renderer"); + + const QString delta = QStringLiteral("\n\n_late terminal suffix_"); + const QString finalContent = initial + delta; + fixture.turns.front().messages.front().text = finalContent.toStdString(); + passed &= conversation.updateExactMessageContent( + makeState({fixture}), + QStringLiteral("terminal-late-delta"), + appendUpdate(QStringLiteral("turn-terminal-late-delta"), + QStringLiteral("item-terminal-late-delta"), + client::ItemContentChannel::AgentText, + static_cast(initial.toUtf8().size()), + delta)); + settleTimeline(); + + QWidget* const finalWidget = messageContent(message); + auto* finalLabel = qobject_cast(finalWidget); + passed &= expect( + finalWidget && finalWidget == contentAddress && finalLabel + && messageSourceText(finalLabel) == finalContent + && finalWidget->property("markdownRenderMode").toString() + == QStringLiteral("markdown") + && finalLabel->text().contains(QStringLiteral("late terminal suffix")), + "a late exact delta on a terminal item must remain in the final Markdown renderer"); + return passed; +} + bool testCompletedAgentMessageStreamsBeforeTerminalMarkdown() { ThreadFixture fixture{ @@ -2113,7 +2578,12 @@ int main(int argc, char** argv) passed &= testActivityDisclosureAndFullOutput(); passed &= testPointerPreservingAppend(); passed &= testInPlaceMessageReplacement(); + passed &= testIncompleteThreadPresentation(); + passed &= testIncompleteReplacementPreservesRenderedTimeline(); passed &= testStreamingPlainTextAndTerminalMarkdown(); + passed &= testEmptyAgentMessageAcceptsFirstExactDelta(); + passed &= testMaximumRetainedAgentMessageStaysIncremental(); + passed &= testTerminalMarkdownAcceptsLateExactDelta(); passed &= testCompletedAgentMessageStreamsBeforeTerminalMarkdown(); passed &= testTerminalMarkdownPromotionResettlesFollowedTail(); passed &= testCompleteAndLargeUserMessagePresentation(); diff --git a/tests/FrontendSessionTest.cpp b/tests/FrontendSessionTest.cpp index 72e6da3..68a6b7a 100644 --- a/tests/FrontendSessionTest.cpp +++ b/tests/FrontendSessionTest.cpp @@ -233,7 +233,10 @@ struct FrontendSessionWorkerTestAccess static bool synchronizeWithCapturedTransport( FrontendSessionWorker& session, - std::vector& messages) + std::vector& messages, + ai::openai::codex::frontend::Json threads = + ai::openai::codex::frontend::Json::array(), + std::size_t omittedThreads = 0) { namespace frontend = ai::openai::codex::frontend; namespace sdk = frontend::client; @@ -247,6 +250,21 @@ struct FrontendSessionWorkerTestAccess }); session.connection.transportConnected(); + // ThreadReadStateEffects is a required observed mechanism. It must not + // be mixed into Hello's representation-capability request list. + if (messages.empty()) + return false; + const auto decodedHello = frontend::Codec::decodeClient( + std::string_view(messages.front().compactJson)); + const auto* hello = decodedHello + ? std::get_if(&decodedHello.value()) + : nullptr; + if (!hello || !hello->capabilities + || std::ranges::find(*hello->capabilities, + frontend::FrontendCapability::ThreadReadStateEffects) + != hello->capabilities->end()) + return false; + const frontend::Json state{ {"backendRevision", std::uint64_t{1}}, {"lifecycle", "ready"}, @@ -257,21 +275,32 @@ struct FrontendSessionWorkerTestAccess {{"hasLoadedPage", true}, {"complete", true}, {"pagesLoaded", std::uint64_t{1}}}}, - {"threads", frontend::Json::array()}, + {"threads", std::move(threads)}, {"pendingRequests", frontend::Json::array()}, {"codexExtensions", frontend::Json::array()}, {"omittedCodexExtensions", std::uint64_t{0}}, + {"capacityProvenance", + {{"omittedThreads", omittedThreads}, + {"truncated", omittedThreads > 0}}}, {"journal", {{"oldestReplayableAfter", std::uint64_t{0}}, {"currentSequence", std::uint64_t{0}}}}, {"sequenceExhausted", false}, }; + const frontend::FrontendCapability threadReadStateEffects = + frontend::FrontendCapability::ThreadReadStateEffects; return session.connection .receive(frontend::ServerMessage{frontend::Welcome{ "archived-refresh-test", frontend::SessionRole::Observer, frontend::SequenceNumber{0}, - frontend::SyncMode::Snapshot}}) + frontend::SyncMode::Snapshot, + frontend::Json::object(), + frontend::CapabilityAdvertisement{ + {threadReadStateEffects}, + {threadReadStateEffects}, + {threadReadStateEffects}, + frontend::Json::object()}}}) .accepted && session.connection .receive(frontend::ServerMessage{ @@ -283,12 +312,49 @@ struct FrontendSessionWorkerTestAccess .accepted; } + static bool rejectsMissingThreadReadStateEffects( + FrontendSessionWorker& session, + std::vector& messages) + { + namespace frontend = ai::openai::codex::frontend; + namespace sdk = frontend::client; + + session.connection = session.client->openConnection({ + [&messages](FrontendSessionWorker::OutboundMessage message) { + messages.push_back(std::move(message)); + return FrontendSessionWorker::SendResult{ + sdk::SendStatus::Accepted, std::nullopt}; + }, + [](std::string) {}, + }); + session.connection.transportConnected(); + const auto result = session.connection.receive( + frontend::ServerMessage{frontend::Welcome{ + "missing-thread-read-effects", + frontend::SessionRole::Observer, + frontend::SequenceNumber{0}, + frontend::SyncMode::Snapshot, + frontend::Json::object(), + frontend::CapabilityAdvertisement{ + {}, {}, {}, frontend::Json::object()}}}); + return !result.accepted + && session.currentLifecycle == FrontendSessionWorker::Lifecycle::Failed + && !session.automaticReconnectEnabled; + } + static bool receive(FrontendSessionWorker& session, ai::openai::codex::frontend::ServerMessage message) { return session.connection.receive(std::move(message)).accepted; } + static void publishStateUpdate( + FrontendSessionWorker& session, + const ai::openai::codex::frontend::client::StateUpdate& update) + { + session.handleStateUpdate(update); + } + static void receiveWire(FrontendSessionWorker& session, QByteArray wire) { session.inboundBuffer = std::move(wire); @@ -406,6 +472,56 @@ bool expect(bool condition, const char* message) return condition; } +frontend::Json threadReadStateEffect(std::string_view authority, + bool sourcePartial = false, + std::uint64_t omittedTurns = 0, + std::uint64_t omittedItems = 0) +{ + const bool responseTruncated = omittedTurns != 0 || omittedItems != 0; + return frontend::Json{ + {"scope", "thread"}, + {"authority", authority}, + {"truncation", + {{"sourcePartial", sourcePartial}, + {"responseTruncated", responseTruncated}, + {"responseOmittedTurns", omittedTurns}, + {"responseOmittedItems", omittedItems}}}, + }; +} + +frontend::Json threadReadBody(std::string_view threadId, bool fullyLoaded) +{ + return frontend::Json{ + {"id", threadId}, + {"fullyLoaded", fullyLoaded}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}, + }; +} + +frontend::Json negotiatedThreadReadResult(std::string_view threadId, + std::string_view authority, + bool sourcePartial = false) +{ + if (authority == "absent") { + return frontend::Json{ + {"threadId", threadId}, + {"stateEffect", threadReadStateEffect(authority)}, + }; + } + const bool fullyLoaded = authority == "replace"; + return frontend::Json{ + {"thread", threadReadBody(threadId, fullyLoaded)}, + {"stateEffect", + threadReadStateEffect(authority, sourcePartial)}, + }; +} + +bool negotiatedThreadReadRequested(const frontend::Json& command) +{ + return command.value("threadReadStateEffectVersion", 0) == 1; +} + bool testPeerCredentials() { int sockets[2]{-1, -1}; @@ -520,6 +636,12 @@ bool testScopedItemPresentationChanges() sdk::ThreadUpsertedChange{ai::openai::codex::typed::ThreadId{"target-thread"}}); const auto threadScoped = codexui::detail::stateUpdateScope(threadUpdate); + sdk::StateUpdate removedThreadUpdate; + removedThreadUpdate.changes.push_back( + sdk::ThreadRemovedChange{ai::openai::codex::typed::ThreadId{"removed-thread"}}); + const auto removedThreadScoped = + codexui::detail::stateUpdateScope(removedThreadUpdate); + sdk::StateUpdate cursorUpdate; cursorUpdate.changes.push_back( sdk::CursorAdvancedChange{ai::openai::codex::frontend::SequenceNumber{43}}); @@ -620,6 +742,18 @@ bool testScopedItemPresentationChanges() && !threadScoped.allSidebarThreadsAffected && threadScoped.sidebarAffected, "a thread upsert must target only its conversation, Inspector dependencies, and Sidebar row"); + passed &= expect( + removedThreadScoped.affectedThreadIds + == QStringList{QStringLiteral("removed-thread")} + && removedThreadScoped.fullyAffectedThreadIds + == QStringList{QStringLiteral("removed-thread")} + && removedThreadScoped.removedThreadIds + == QStringList{QStringLiteral("removed-thread")} + && removedThreadScoped.affectedSidebarThreadIds + == QStringList{QStringLiteral("removed-thread")} + && removedThreadScoped.affectedInspectorThreadIds + == QStringList{QStringLiteral("removed-thread")}, + "an authoritative thread removal must preserve its exact identity through the GUI scope"); passed &= expect(!cursor.allThreadsAffected && !cursor.allInspectorsAffected && !cursor.allSidebarThreadsAffected && !cursor.sidebarAffected && cursor.hasPresentationChange, @@ -800,6 +934,346 @@ bool testInboundFrameCapacityTracksSdk() "the Qt JSONL receiver must accept the SDK's complete provider-derived server-message range"); } +std::vector +capturedCommands(const std::vector& messages, + std::string_view method); + +bool testIncompleteThreadReadIsBounded() +{ + codexui::FrontendSessionWorker incompatibleSession; + std::vector incompatibleOutbound; + bool passed = expect( + codexui::FrontendSessionWorkerTestAccess::rejectsMissingThreadReadStateEffects( + incompatibleSession, incompatibleOutbound), + "a backend without required thread-read State effects must fail the handshake without reconnecting"); + + codexui::FrontendSessionWorker session; + std::vector outbound; + frontend::Json threads = frontend::Json::array({ + frontend::Json{{"id", "partial-thread"}, + {"fullyLoaded", false}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}}, + frontend::Json{{"id", "retry-thread"}, + {"fullyLoaded", false}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}}, + frontend::Json{{"id", "complete-thread"}, + {"fullyLoaded", true}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}}, + }); + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( + session, outbound, std::move(threads)), + "the incomplete-thread recovery fixture must reach synchronized State"); + outbound.clear(); + + session.loadThread(QStringLiteral("complete-thread")); + // Without explicit omission provenance, absence from a complete thread + // list remains authoritative and must not trigger a speculative read. + session.loadThread(QStringLiteral("missing-thread")); + session.loadThread(QStringLiteral("partial-thread")); + session.loadThread(QStringLiteral("partial-thread")); + std::vector reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 1 + && negotiatedThreadReadRequested(reads.front()) + && reads.front().value("params", frontend::Json::object()) + == frontend::Json{{"threadId", "partial-thread"}, + {"includeTurns", true}}, + "only an incomplete retained thread may request one negotiated authoritative full read"); + if (reads.size() != 1 || !reads.front().contains("requestId")) + return false; + + const auto publishThreadCompleteness = [&session](std::uint64_t sequence, + bool fullyLoaded) { + frontend::FrontendEvent update{ + frontend::SequenceNumber{sequence}, + "thread.updated", + frontend::Json{{"thread", + {{"id", "partial-thread"}, + {"fullyLoaded", fullyLoaded}}}}, + }; + return codexui::FrontendSessionWorkerTestAccess::receive( + session, + frontend::ServerMessage{frontend::EventBatch{ + update.sequence, update.sequence, {std::move(update)}}}); + }; + passed &= expect( + publishThreadCompleteness(1, true) + && publishThreadCompleteness(2, false), + "intermediate State updates around an outstanding thread read must be accepted"); + session.loadThread(QStringLiteral("partial-thread")); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 1, + "State reconciliation must not release in-flight thread-read ownership before its operation completes"); + + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + session, + frontend::ServerMessage{frontend::Response::success( + reads.front()["requestId"].get(), + negotiatedThreadReadResult( + "partial-thread", "merge", true))}), + "the partial-thread Merge result must be accepted"); + session.loadThread(QStringLiteral("partial-thread")); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 1, + "a successful acknowledgement must remain de-duplicated until authoritative State completes the thread"); + + sdk::StateUpdate regressingReplacement; + regressingReplacement.state = session.state(); + regressingReplacement.changes.push_back(sdk::StateReplacedChange{}); + codexui::FrontendSessionWorkerTestAccess::publishStateUpdate( + session, regressingReplacement); + session.loadThread(QStringLiteral("partial-thread")); + session.loadThread(QStringLiteral("partial-thread")); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 2, + "a later replacement revision that regresses retained history must earn exactly one new automatic read"); + if (reads.size() != 2 || !reads.back().contains("requestId")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + session, + frontend::ServerMessage{frontend::Response::success( + reads.back()["requestId"].get(), + negotiatedThreadReadResult( + "partial-thread", "merge", true))}), + "the replacement-epoch recovery acknowledgement must be accepted"); + session.loadThread(QStringLiteral("partial-thread")); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 2, + "an unchanged incomplete replacement epoch must remain bounded after its successful read"); + + session.loadThread(QStringLiteral("partial-thread"), true); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 3, + "an explicit user retry may re-read a still-incomplete thread without enabling an automatic loop"); + if (reads.size() != 3 || !reads.back().contains("requestId")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + session, + frontend::ServerMessage{frontend::Response::success( + reads.back()["requestId"].get(), + negotiatedThreadReadResult( + "partial-thread", "merge", true))}), + "the explicit incomplete-thread retry acknowledgement must be accepted"); + + session.loadThread(QStringLiteral("retry-thread")); + reads = capturedCommands(outbound, "thread.read"); + if (!expect(reads.size() == 4 && reads.back().contains("requestId"), + "a different incomplete thread must receive its own bounded read")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + session, + frontend::ServerMessage{frontend::Response::failure( + reads.back()["requestId"].get(), + frontend::CommandError{frontend::ErrorCode::CapacityExceeded, + "thread read fence was overtaken"})}), + "the capacity-limited recovery read must be accepted as an operation response"); + sdk::StateUpdate liveRetryThreadUpdate; + liveRetryThreadUpdate.state = session.state(); + liveRetryThreadUpdate.changes.push_back( + sdk::ThreadUpsertedChange{ + ai::openai::codex::typed::ThreadId{"retry-thread"}}); + codexui::FrontendSessionWorkerTestAccess::publishStateUpdate( + session, liveRetryThreadUpdate); + session.loadThread(QStringLiteral("retry-thread")); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 4, + "a failed automatic read must consume the current replacement epoch instead of polling after live updates"); + + session.loadThread(QStringLiteral("retry-thread"), true); + reads = capturedCommands(outbound, "thread.read"); + passed &= expect( + reads.size() == 5 && reads.back().contains("requestId"), + "an explicit retry may re-read a capacity-limited recovery without enabling automatic polling"); + + codexui::FrontendSessionWorker authoritySession; + std::vector authorityOutbound; + frontend::Json authorityThreads = frontend::Json::array({ + frontend::Json{{"id", "replace-thread"}, + {"fullyLoaded", false}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}}, + }); + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( + authoritySession, + authorityOutbound, + std::move(authorityThreads), + 1), + "the negotiated authority fixture must reach synchronized State"); + std::optional authorityScope; + QObject::connect( + &authoritySession, + &codexui::FrontendSessionWorker::stateChanged, + [&authorityScope](const auto& scope) { authorityScope = scope; }); + authorityOutbound.clear(); + authoritySession.loadThread(QStringLiteral("replace-thread")); + std::vector authorityReads = capturedCommands( + authorityOutbound, "thread.read"); + if (!expect(authorityReads.size() == 1 + && negotiatedThreadReadRequested(authorityReads.front()) + && authorityReads.front().contains("requestId"), + "the complete authority fixture must negotiate one Replace read")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + authoritySession, + frontend::ServerMessage{frontend::Response::success( + authorityReads.front()["requestId"].get(), + negotiatedThreadReadResult( + "replace-thread", "replace"))}), + "the authoritative Replace result must be accepted"); + const auto* replaced = authoritySession.state().thread("replace-thread"); + authoritySession.loadThread(QStringLiteral("replace-thread")); + passed &= expect( + replaced && replaced->fullyLoaded + && capturedCommands(authorityOutbound, "thread.read").size() == 1, + "Replace must complete the cached thread and suppress further recovery reads"); + + authorityScope.reset(); + authoritySession.loadThread(QStringLiteral("absent-thread")); + authorityReads = capturedCommands(authorityOutbound, "thread.read"); + if (!expect(authorityReads.size() == 2 + && authorityReads.back().contains("requestId"), + "an omitted identity must receive one negotiated absence check")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + authoritySession, + frontend::ServerMessage{frontend::Response::success( + authorityReads.back()["requestId"].get(), + negotiatedThreadReadResult( + "absent-thread", "absent"))}) + && authoritySession.state().thread("absent-thread") == nullptr + && authorityScope + && authorityScope->removedThreadIds + == QStringList{QStringLiteral("absent-thread")}, + "Absent must publish one exact removal tombstone before completion"); + + codexui::FrontendSessionWorker omittedSession; + std::vector omittedOutbound; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( + omittedSession, + omittedOutbound, + frontend::Json::array(), + 1), + "the omitted-thread recovery fixture must reach synchronized State"); + passed &= expect( + omittedSession.state().capacityProvenance() + && omittedSession.state().capacityProvenance()->omittedThreads == 1, + "the recovery fixture must expose its bounded snapshot omission provenance"); + omittedOutbound.clear(); + omittedSession.loadThread(QStringLiteral("omitted-thread")); + omittedSession.loadThread(QStringLiteral("omitted-thread")); + std::vector omittedReads = capturedCommands( + omittedOutbound, "thread.read"); + passed &= expect( + omittedReads.size() == 1 + && omittedReads.front().value("params", frontend::Json::object()) + == frontend::Json{{"threadId", "omitted-thread"}, + {"includeTurns", true}}, + "a selected ID absent from an explicitly bounded snapshot must receive one recovery read"); + if (omittedReads.size() != 1 || !omittedReads.front().contains("requestId")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + omittedSession, + frontend::ServerMessage{frontend::Response::success( + omittedReads.front()["requestId"].get(), + negotiatedThreadReadResult( + "omitted-thread", "merge", true))}), + "the omitted-thread Merge result must be accepted"); + omittedSession.loadThread(QStringLiteral("omitted-thread")); + omittedReads = capturedCommands(omittedOutbound, "thread.read"); + passed &= expect( + omittedReads.size() == 1, + "a successful missing-thread recovery must remain bounded until State resolves the omission"); + omittedSession.loadThread(QStringLiteral("omitted-thread"), true); + omittedReads = capturedCommands(omittedOutbound, "thread.read"); + passed &= expect( + omittedReads.size() == 2, + "an explicit user retry may verify a still-omitted identity without enabling automatic polling"); + + const auto projectedThreads = [] { + return frontend::Json::array({ + frontend::Json{{"id", "projected-thread"}, + {"fullyLoaded", false}, + {"turns", frontend::Json::array()}, + {"extensions", frontend::Json::object()}}, + }); + }; + codexui::FrontendSessionWorker reconnectSession; + std::vector firstConnectionOutbound; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( + reconnectSession, + firstConnectionOutbound, + projectedThreads()), + "the projected-selection fixture must synchronize its first connection"); + firstConnectionOutbound.clear(); + reconnectSession.loadThread(QStringLiteral("projected-thread"), true); + std::vector firstConnectionReads = capturedCommands( + firstConnectionOutbound, "thread.read"); + if (!expect(firstConnectionReads.size() == 1 + && firstConnectionReads.front().contains("requestId"), + "a projected incomplete selection must issue one read on its first Ready boundary")) + return false; + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::receive( + reconnectSession, + frontend::ServerMessage{frontend::Response::success( + firstConnectionReads.front()["requestId"].get(), + negotiatedThreadReadResult( + "projected-thread", "merge", true))}), + "the first projected-selection read acknowledgement must be accepted"); + + codexui::FrontendSessionWorkerTestAccess::disconnectTransport( + reconnectSession); + std::vector secondConnectionOutbound; + const bool disconnectedForRetry = + reconnectSession.lifecycle() + == codexui::FrontendSessionWorker::Lifecycle::Failed + && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( + reconnectSession); + const bool synchronizedAgain = + codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport( + reconnectSession, + secondConnectionOutbound, + projectedThreads()); + passed &= expect( + disconnectedForRetry && synchronizedAgain + && reconnectSession.lifecycle() + == codexui::FrontendSessionWorker::Lifecycle::Ready, + "the projected-selection fixture must disconnect and synchronize a new Ready connection"); + secondConnectionOutbound.clear(); + // Workbench invokes the explicit retry once when the retained projected + // selection crosses the new Ready boundary. Ordinary presentation refreshes + // can immediately follow it and must not submit duplicates. + reconnectSession.loadThread(QStringLiteral("projected-thread"), true); + reconnectSession.loadThread(QStringLiteral("projected-thread")); + reconnectSession.loadThread(QStringLiteral("projected-thread")); + const std::vector secondConnectionReads = capturedCommands( + secondConnectionOutbound, "thread.read"); + passed &= expect( + secondConnectionReads.size() == 1, + "a projected selection retained across disconnect and a new Ready connection must issue exactly one recovery read"); + return passed; +} + std::vector capturedCommands(const std::vector& messages, std::string_view method = {}) @@ -1645,6 +2119,15 @@ bool testThreadedFacadeMailbox() codexui::FrontendSessionFacadeTestAccess::enqueueState( session, 7, std::move(scope)); } + { + codexui::detail::StateUpdateScope scope; + scope.affectedThreadIds = {QStringLiteral("removed-thread")}; + scope.fullyAffectedThreadIds = {QStringLiteral("removed-thread")}; + scope.removedThreadIds = {QStringLiteral("removed-thread")}; + scope.hasPresentationChange = true; + codexui::FrontendSessionFacadeTestAccess::enqueueState( + session, 7, std::move(scope)); + } { codexui::detail::StateUpdateScope scope; scope.affectedSidebarThreadIds = { @@ -1702,7 +2185,10 @@ bool testThreadedFacadeMailbox() passed &= expect( stateSignals == 1 && deliveredScope && deliveredScope->affectedThreadIds - == QStringList{QStringLiteral("streaming-thread")}, + == QStringList{QStringLiteral("streaming-thread"), + QStringLiteral("removed-thread")} + && deliveredScope->removedThreadIds + == QStringList{QStringLiteral("removed-thread")}, "the one latest State publication must retain the merged presentation scope"); if (deliveredScope) { const auto content = [&deliveredScope](QStringView itemId, @@ -1995,6 +2481,15 @@ bool testFacadeScopeBound() &codexui::FrontendSession::stateChanged, [&delivered](const auto& scope) { delivered = scope; }); + { + codexui::detail::StateUpdateScope scope; + scope.affectedThreadIds.push_back(QStringLiteral("removed-thread")); + scope.fullyAffectedThreadIds.push_back(QStringLiteral("removed-thread")); + scope.removedThreadIds.push_back(QStringLiteral("removed-thread")); + scope.hasPresentationChange = true; + codexui::FrontendSessionFacadeTestAccess::enqueueState( + session, 1, std::move(scope)); + } for (int index = 0; index < 1'100; ++index) { codexui::detail::StateUpdateScope scope; scope.affectedThreadIds.push_back(QStringLiteral("thread")); @@ -2013,8 +2508,55 @@ bool testFacadeScopeBound() bool passed = expect( delivered && delivered->allThreadsAffected && delivered->affectedThreadIds.empty() - && delivered->affectedItemContents.empty(), - "a blocked GUI must degrade an unbounded exact-scope burst to one bounded full refresh"); + && delivered->affectedItemContents.empty() + && delivered->removedThreadIds + == QStringList{QStringLiteral("removed-thread")}, + "a blocked GUI must degrade an unbounded exact-scope burst to one bounded full refresh while retaining exact removals"); + + delivered.reset(); + { + codexui::detail::StateUpdateScope removed; + removed.affectedThreadIds.push_back( + QStringLiteral("remove-then-upsert")); + removed.removedThreadIds.push_back( + QStringLiteral("remove-then-upsert")); + removed.hasPresentationChange = true; + codexui::FrontendSessionFacadeTestAccess::enqueueState( + session, 1, std::move(removed)); + + codexui::detail::StateUpdateScope upserted; + upserted.affectedThreadIds.push_back( + QStringLiteral("remove-then-upsert")); + upserted.fullyAffectedThreadIds.push_back( + QStringLiteral("remove-then-upsert")); + upserted.hasPresentationChange = true; + codexui::FrontendSessionFacadeTestAccess::enqueueState( + session, 1, std::move(upserted)); + } + QCoreApplication::processEvents(); + passed &= expect( + delivered && delivered->removedThreadIds.empty(), + "a newer exact upsert must supersede a coalesced removal tombstone"); + + delivered.reset(); + for (int index = 0; + index <= codexui::detail::maximumCoalescedPresentationIdentities; + ++index) { + codexui::detail::StateUpdateScope scope; + const QString threadId = QStringLiteral("removed-%1").arg(index); + scope.affectedThreadIds.push_back(threadId); + scope.removedThreadIds.push_back(threadId); + scope.hasPresentationChange = true; + codexui::FrontendSessionFacadeTestAccess::enqueueState( + session, 1, std::move(scope)); + } + QCoreApplication::processEvents(); + passed &= expect( + delivered && delivered->allThreadsAffected + && delivered->removedThreadIdsOverflowed + && delivered->removedThreadIds.size() + == codexui::detail::maximumCoalescedPresentationIdentities, + "a removal burst must expose bounded tombstone overflow so the selected identity can be verified explicitly"); delivered.reset(); for (int index = 0; @@ -2045,6 +2587,7 @@ int main(int argc, char* argv[]) return testPeerCredentials() && testScopedItemPresentationChanges() && testLifecycleAndDiagnostics() && testPreReadyReconnectBound() && testReceiveRejectionPreservesPreciseError() && testInboundFrameCapacityTracksSdk() + && testIncompleteThreadReadIsBounded() && testModelCatalogRefresh() && testModelCatalogRefreshFailureIsDiagnosed() && testArchivedThreadRefresh() && testArchivedThreadRefreshFailureIsTerminal() diff --git a/tests/Phase1ThreadTurnUxTest.cpp b/tests/Phase1ThreadTurnUxTest.cpp index 64aeb55..fb99156 100644 --- a/tests/Phase1ThreadTurnUxTest.cpp +++ b/tests/Phase1ThreadTurnUxTest.cpp @@ -5,6 +5,7 @@ #include "ui/ThreadSetupDialog.h" #include "ui/UpcomingTurnDock.h" #include "ui/UiStyle.h" +#include "ui/WorkbenchWidget.h" #include #include @@ -1372,6 +1373,41 @@ bool testArchivedThreadAssignmentPruningWaitsForCompleteDiscovery() return passed; } +bool testMissingSelectedThreadRetentionPolicy() +{ + using codexui::detail::shouldClearMissingSelectedThread; + return expect( + !shouldClearMissingSelectedThread(true, true, false, 1, false) + && shouldClearMissingSelectedThread(true, true, false, 0, false) + && shouldClearMissingSelectedThread(true, true, false, 1, true) + && shouldClearMissingSelectedThread(false, false, true, 1, true) + && !shouldClearMissingSelectedThread(false, true, false, 0, false) + && !shouldClearMissingSelectedThread(true, false, false, 0, false) + && !shouldClearMissingSelectedThread(true, true, true, 0, false), + "a missing selection must survive only incomplete discovery, pending creation, or unresolved snapshot omission"); +} + +bool testProjectedSelectionReconnectRecoveryPolicy() +{ + using codexui::detail::shouldRetryProjectedSelectionAfterReady; + return expect( + shouldRetryProjectedSelectionAfterReady( + true, + QStringLiteral("projected-thread"), + QStringLiteral("projected-thread")) + && !shouldRetryProjectedSelectionAfterReady( + false, + QStringLiteral("projected-thread"), + QStringLiteral("projected-thread")) + && !shouldRetryProjectedSelectionAfterReady( + true, + QStringLiteral("ordinary-thread"), + QStringLiteral("projected-thread")) + && !shouldRetryProjectedSelectionAfterReady( + true, QString{}, QString{}), + "only a retained projected-agent selection may receive one retry at a new Ready boundary"); +} + } // namespace int main(int argc, char** argv) @@ -1397,5 +1433,7 @@ int main(int argc, char** argv) passed &= testTargetedSidebarRefreshKeepsUnchangedRows(); passed &= testThreadOrganizationPersistenceAndSafeMoves(); passed &= testArchivedThreadAssignmentPruningWaitsForCompleteDiscovery(); + passed &= testMissingSelectedThreadRetentionPolicy(); + passed &= testProjectedSelectionReconnectRecoveryPolicy(); return passed ? 0 : 1; } From a323122d493b05c1726d383126e39d89464bb639 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 08:45:05 +0200 Subject: [PATCH 4/9] Pin CodexUI CI to AISuite 0.6 --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d361b4..e196c49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: true env: - AISUITE_REVISION: d70f8484766728d4ecb650da7c683ced39d33ed5 + AISUITE_REVISION: ff130d741ec50ed7dba1890f7981bc62f26f15db SNODEC_REVISION: bc43179dbee2b5a0286420a61d8f1ceaef01530d CMAKE_BUILD_PARALLEL_LEVEL: 2 CTEST_PARALLEL_LEVEL: 2 @@ -42,7 +42,7 @@ jobs: ref: ${{ env.SNODEC_REVISION }} path: _deps/snodec - - name: Check out required AISuite 0.5.0 + - name: Check out required AISuite 0.6.0 uses: actions/checkout@v5 with: repository: SNodeC/AISuite @@ -87,7 +87,7 @@ jobs: cmake --build _build/snodec --target all cmake --install _build/snodec - - name: Build and install AISuite 0.5.0 + - name: Build and install AISuite 0.6.0 run: | cmake -S _deps/aisuite -B _build/aisuite -G Ninja \ -DCMAKE_BUILD_TYPE=Debug \ @@ -101,7 +101,7 @@ jobs: -DAISUITE_ENABLE_CODEX_FRONTEND_RFCOMM=OFF cmake --build _build/aisuite --target all cmake --install _build/aisuite - grep -F 'set(PACKAGE_VERSION "0.5.0")' \ + grep -F 'set(PACKAGE_VERSION "0.6.0")' \ _stage/aisuite/lib/cmake/AISuite/AISuiteConfigVersion.cmake - name: Configure CodexUI From 7e19d8cf488b66436e8f7525f18a828dce637172 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 11:51:23 +0200 Subject: [PATCH 5/9] Bound live conversation rendering work --- src/ui/ConversationWidget.cpp | 106 +++++++++++++++++++------------ src/ui/ConversationWidget.h | 5 ++ tests/ConversationLayoutTest.cpp | 14 +++- 3 files changed, 81 insertions(+), 44 deletions(-) diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index 86f9a95..afb353a 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -2072,6 +2072,7 @@ struct TimelineWindow std::vector turns; qsizetype renderedItems = 0; qsizetype totalItems = 0; + bool earlierItemsOmitted = false; }; QString segmentStorageKey(const QString& turnId, const QString& segmentId) @@ -2168,15 +2169,6 @@ qsizetype timelineItemCount(const TimelineSegment& segment) TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread) { TimelineWindow result; - // Count from ordered IDs only; item lookup and presentation stay bounded - // to the selected tail below. - for (const auto& turnId : thread.orderedTurns) - { - const auto* turn = state.turn(thread.id, turnId); - if (turn) - result.totalItems += qMax(1, static_cast(turn->orderedItems.size())); - } - qsizetype remainingItems = maximumRenderedTimelineItems; for (qsizetype index = static_cast(thread.orderedTurns.size()); index > 0 && remainingItems > 0 @@ -2195,6 +2187,13 @@ TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadSt result.renderedItems += selectedItems; remainingItems -= selectedItems; } + result.earlierItemsOmitted = !result.turns.empty() + && (result.turns.front().turnNumber > 1 + || result.turns.front().firstItem > 0); + // Keep this presentation-only count a bounded lower bound. Computing the + // exact retained count required an all-turn scan on every live item event. + result.totalItems = result.renderedItems + + (result.earlierItemsOmitted ? 1 : 0); return result; } @@ -2969,7 +2968,12 @@ void ConversationWidget::render(const sdk::State& state, const bool wasNearBottom = scrollBar->maximum() - previousScroll <= 72; const bool threadChanged = renderedThreadId != threadId || renderedNewThreadDraft != newThreadDraft; const auto* thread = threadId.isEmpty() ? nullptr : state.thread(threadId.toStdString()); - const auto capacityProvenance = state.capacityProvenance(); + // Capacity provenance is only needed to classify a missing selection. + // Avoid decoding its complete diagnostic object for every live delta of + // a thread that is already present. + const auto capacityProvenance = thread + ? decltype(state.capacityProvenance()){} + : state.capacityProvenance(); const bool selectedThreadOmitted = !newThreadDraft && !thread && !threadId.isEmpty() && capacityProvenance @@ -2992,6 +2996,18 @@ void ConversationWidget::render(const sdk::State& state, : std::optional{}, thread ? threadId : QString{}, newThreadDraft); + if (!threadChanged && shouldFreezePresentation(threadId, newThreadDraft)) + { + markPresentationDeferred(); + return; + } + // Exact content appends cannot remove turns or items. Apply them before + // the bounded incomplete-history proof so streaming on a partial thread + // remains proportional to the changed bytes. + if (exactContentChanges && !threadChanged && !threadCompletenessChanged + && thread && !newThreadDraft + && updateExactMessageContent(state, threadId, *exactContentChanges)) + return; // A bounded replacement is not deletion authority. Keep the same-thread // widgets until an incomplete publication can account for every rendered // descendant; requester-local Merge will make that true, while Replace is @@ -3029,15 +3045,6 @@ void ConversationWidget::render(const sdk::State& state, } if (!thread && !threadChanged && !missingThreadPresentationChanged) return; - if (!threadChanged && shouldFreezePresentation(threadId, newThreadDraft)) - { - markPresentationDeferred(); - return; - } - if (exactContentChanges && !threadChanged && !threadCompletenessChanged - && thread && !newThreadDraft - && updateExactMessageContent(state, threadId, *exactContentChanges)) - return; if (threadChanged) { deferredPresentationPending = false; @@ -3222,14 +3229,13 @@ void ConversationWidget::render(const sdk::State& state, } timelineHost->setProperty("renderedTimelineItems", window.renderedItems); timelineHost->setProperty("retainedTimelineItems", window.totalItems); - if (window.renderedItems < window.totalItems) + if (window.earlierItemsOmitted) { timelineWindowDetail->setText( - QStringLiteral("Showing the latest %1 of %2 synchronized timeline entries. " + QStringLiteral("Showing the latest %1 synchronized timeline entries. " "Earlier entries remain in canonical AISuite State and are not " "materialized in this live view.") - .arg(window.renderedItems) - .arg(window.totalItems)); + .arg(window.renderedItems)); timelineWindowNotice->show(); } else @@ -3763,24 +3769,33 @@ bool ConversationWidget::updateExactMessageContent( } else { - QWidget* activityRow = nullptr; - for (const QString& segmentId : renderedSegmentIds.value(update.turnId)) - { - QWidget* candidate = renderedSegmentWidgets.value( - segmentStorageKey(update.turnId, segmentId)); - if (!candidate) - continue; - const auto rows = candidate->findChildren( - QStringLiteral("conversationActivityRow")); - const auto found = std::find_if( - rows.cbegin(), rows.cend(), [&update](const QWidget* row) { - return row->property("itemId").toString() == update.itemId; - }); - if (found == rows.cend()) - continue; - activityRow = *found; - affectedStorage = segmentStorageKey(update.turnId, segmentId); - break; + const QString activityIdentity = update.turnId + + QChar::Null + + update.itemId; + QWidget* activityRow = renderedActivityRows.value(activityIdentity); + if (!activityRow) { + for (const QString& segmentId : renderedSegmentIds.value(update.turnId)) + { + const QString storage = segmentStorageKey(update.turnId, segmentId); + QWidget* candidate = renderedSegmentWidgets.value(storage); + if (!candidate) + continue; + const auto rows = candidate->findChildren( + QStringLiteral("conversationActivityRow")); + const auto found = std::find_if( + rows.cbegin(), rows.cend(), [&update](const QWidget* row) { + return row->property("itemId").toString() == update.itemId; + }); + if (found == rows.cend()) + continue; + activityRow = *found; + affectedStorage = storage; + renderedActivityRows.insert(activityIdentity, activityRow); + renderedActivityRowSegments.insert(activityIdentity, storage); + break; + } + } else { + affectedStorage = renderedActivityRowSegments.value(activityIdentity); } if (!activityRow) continue; @@ -3912,8 +3927,15 @@ void ConversationWidget::settleTimelineLayout() void ConversationWidget::settleThreadSwitchLayout(std::uint64_t generation, int remainingPasses) { - if (generation != pinLatestGeneration || !pinLatestDuringLayout) + // An older generation must leave ownership to the newer settle pass. If + // the current generation was cancelled, however, no later callback owns + // the viewport freeze and updates must be restored here. + if (generation != pinLatestGeneration) + return; + if (!pinLatestDuringLayout) { + scrollArea->viewport()->setUpdatesEnabled(true); return; + } synchronizeTimelineHeight(true); scrollArea->widget()->layout()->activate(); scrollArea->widget()->adjustSize(); diff --git a/src/ui/ConversationWidget.h b/src/ui/ConversationWidget.h index d08f048..9cb50bd 100644 --- a/src/ui/ConversationWidget.h +++ b/src/ui/ConversationWidget.h @@ -152,6 +152,11 @@ class ConversationWidget : public QWidget QHash renderedSegmentItemIds; QHash renderedSegmentKeys; QHash renderedSegmentWidgets; + // Exact streaming updates are the hottest presentation path. Keep direct + // guarded identities instead of repeatedly walking every activity-card + // subtree for each content delta. + QHash> renderedActivityRows; + QHash renderedActivityRowSegments; QPointer pendingViewportAnchor; std::uint64_t renderGeneration = 0; std::uint64_t pinLatestGeneration = 0; diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index 263ecda..b35923f 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -664,8 +664,9 @@ bool testHotTurnWindow() passed &= expect(activityState.thread("activity") != nullptr && boundedCards && activityHost && renderedActivities == activityHost->property("maximumRenderedItems").toLongLong() && renderedActivities == activityHost->property("renderedTimelineItems").toLongLong() - && activityHost->property("retainedTimelineItems").toLongLong() == 300, - "a contiguous activity run must be chunked and remain within the same global item budget"); + && activityHost->property("retainedTimelineItems").toLongLong() + == renderedActivities + 1, + "a contiguous activity run must be chunked within the global item budget without an all-history count scan"); QWidget* newestActivityRow = nullptr; for (QWidget* row : activityConversation.findChildren( QStringLiteral("conversationActivityRow"))) @@ -2259,6 +2260,15 @@ bool testThreadSwitchWindow() QStringLiteral("message:item-switch-b-1")); passed &= expect(shortHeight > 0 && shortHeight < firstLongHeight && secondLongHeight > shortHeight, "thread switching must release a previous long timeline height before laying out a short thread"); + conversation.render(state, QStringLiteral("switch-b")); + conversation.render(state, QStringLiteral("switch-a")); + settleTimeline(); + auto* rapidSwitchScroll = conversation.findChild(); + passed &= expect( + rapidSwitchScroll && rapidSwitchScroll->viewport()->updatesEnabled() + && segment(conversation, + QStringLiteral("message:item-switch-a-299")), + "two thread switches inside one settle interval must restore viewport updates for the newest generation"); return passed; } From c680e3728048ba0b6397cb713df2bd2e88de0adf Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 11:52:16 +0200 Subject: [PATCH 6/9] Retain widgets across item upserts --- src/app/FrontendSessionWorker.cpp | 17 +++++++++++++---- tests/FrontendSessionTest.cpp | 10 ++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/app/FrontendSessionWorker.cpp b/src/app/FrontendSessionWorker.cpp index 792a6eb..60a16a3 100644 --- a/src/app/FrontendSessionWorker.cpp +++ b/src/app/FrontendSessionWorker.cpp @@ -213,11 +213,20 @@ StateUpdateScope stateUpdateScope(const sdk::StateUpdate& update) } else if constexpr (std::is_same_v) { - if (value.threadId) - markThreadAndInspector(value.threadId->value); + if (value.threadId) { + // A new or changed item requires bounded timeline + // reconciliation, but it does not invalidate the + // complete selected-thread presentation. Keeping it + // out of fullyAffectedThreadIds lets ConversationWidget + // retain and reconcile its existing segment widgets. + addThread(value.threadId->value); + addInspectorThread(value.threadId->value); + } else if (value.turnId) { - if (const auto* turn = update.state.turn(*value.turnId)) - markThreadAndInspector(turn->threadId.value); + if (const auto* turn = update.state.turn(*value.turnId)) { + addThread(turn->threadId.value); + addInspectorThread(turn->threadId.value); + } else { scope.allThreadsAffected = true; scope.allInspectorsAffected = true; diff --git a/tests/FrontendSessionTest.cpp b/tests/FrontendSessionTest.cpp index 68a6b7a..e597c01 100644 --- a/tests/FrontendSessionTest.cpp +++ b/tests/FrontendSessionTest.cpp @@ -668,13 +668,12 @@ bool testScopedItemPresentationChanges() && unresolvedTurn.hasPresentationChange, "a turn upsert without a unique parent lookup must conservatively refresh all threads"); passed &= expect(scoped.affectedThreadIds == QStringList{QStringLiteral("target-thread")} - && scoped.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} + && scoped.fullyAffectedThreadIds.empty() && scoped.affectedInspectorThreadIds == QStringList{QStringLiteral("target-thread")} && !scoped.allThreadsAffected && !scoped.allInspectorsAffected && !scoped.sidebarAffected && scoped.hasPresentationChange, - "a scoped item upsert must refresh its canonical conversation and Inspector"); + "a scoped item upsert must reconcile its canonical conversation and Inspector without invalidating retained widgets"); passed &= expect(streamed.affectedThreadIds == QStringList{QStringLiteral("target-thread")} && streamed.fullyAffectedThreadIds.empty() && streamed.affectedInspectorThreadIds.empty() @@ -713,11 +712,10 @@ bool testScopedItemPresentationChanges() && !oversizedAppend.affectedItemContents.front().append && oversizedAppend.coalescedContentDeltaBytes == 0, "an oversized append hint must degrade to an authoritative replacement without entering the GUI mailbox"); - passed &= expect(mixed.fullyAffectedThreadIds - == QStringList{QStringLiteral("target-thread")} + passed &= expect(mixed.fullyAffectedThreadIds.empty() && mixed.affectedItemContents.size() == 1 && !mixed.allThreadsAffected, - "a structural change mixed with exact content must require full thread reconciliation"); + "a structural item change mixed with exact content must retain bounded widget reconciliation"); passed &= expect(unscoped.affectedThreadIds.empty() && unscoped.allThreadsAffected && unscoped.fullyAffectedThreadIds.empty() && unscoped.affectedItemContents.empty() From a92eb82d7ac5762326fbee8771377d7068fb6b43 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 11:52:16 +0200 Subject: [PATCH 7/9] Bound reconnects until Ready is stable --- src/app/FrontendSessionWorker.cpp | 38 ++++++++++++++++++++---- src/app/FrontendSessionWorker.h | 3 ++ tests/FrontendSessionTest.cpp | 48 +++++++++++++++++++++++++------ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/src/app/FrontendSessionWorker.cpp b/src/app/FrontendSessionWorker.cpp index 60a16a3..df3aedc 100644 --- a/src/app/FrontendSessionWorker.cpp +++ b/src/app/FrontendSessionWorker.cpp @@ -401,9 +401,8 @@ FrontendSessionWorker::FrontendSessionWorker(QObject* parent) handleStateUpdate(update); }; callbacks.onSynchronized = [this](const sdk::SynchronizationInfo& info) { - reconnectDelayMs = initialReconnectDelayMs; - consecutivePreReadyDisconnects = 0; synchronizedCurrentConnection = true; + connectionStabilityTimer.start(stableConnectionDwellMs); automaticReconnectEnabled = true; currentState = info.state; reconcileIncompleteThreadReadAttempts(); @@ -438,6 +437,9 @@ FrontendSessionWorker::FrontendSessionWorker(QObject* parent) connect(&socket, &QLocalSocket::errorOccurred, this, &FrontendSessionWorker::socketFailed); reconnectTimer.setSingleShot(true); connect(&reconnectTimer, &QTimer::timeout, this, &FrontendSessionWorker::retryConnection); + connectionStabilityTimer.setSingleShot(true); + connect(&connectionStabilityTimer, &QTimer::timeout, + this, &FrontendSessionWorker::markConnectionStable); outboundDrainTimer.setSingleShot(true); connect(&outboundDrainTimer, &QTimer::timeout, this, &FrontendSessionWorker::drainSocketWrites); } @@ -453,6 +455,7 @@ void FrontendSessionWorker::shutdown() return; localShutdown = true; reconnectTimer.stop(); + connectionStabilityTimer.stop(); clearOutbound(); if (connection.isOpen()) connection.close("CodexUI is closing"); @@ -598,6 +601,7 @@ bool FrontendSessionWorker::transportAffinityIsCurrentThread() const noexcept QThread* current = QThread::currentThread(); return thread() == current && socket.thread() == current && reconnectTimer.thread() == current + && connectionStabilityTimer.thread() == current && outboundDrainTimer.thread() == current; } @@ -1398,6 +1402,11 @@ void FrontendSessionWorker::finishModelCatalogRefresh(QString diagnostic) void FrontendSessionWorker::socketDisconnected() { + const bool unstableReadyConnection = synchronizedCurrentConnection + && connectionStabilityTimer.isActive(); + connectionStabilityTimer.stop(); + if (unstableReadyConnection) + synchronizedCurrentConnection = false; if (connection.isOpen()) { if (localShutdown || !automaticReconnectEnabled) connection.transportDisconnected(); @@ -1423,6 +1432,11 @@ void FrontendSessionWorker::socketFailed(QLocalSocket::LocalSocketError) { if (localShutdown) return; + const bool unstableReadyConnection = synchronizedCurrentConnection + && connectionStabilityTimer.isActive(); + connectionStabilityTimer.stop(); + if (unstableReadyConnection) + synchronizedCurrentConnection = false; if (connection.isOpen()) { if (!automaticReconnectEnabled) connection.transportDisconnected(); @@ -1438,7 +1452,7 @@ void FrontendSessionWorker::socketFailed(QLocalSocket::LocalSocketError) if (recordPreReadyTransportFailure()) return; if (automaticReconnectEnabled && currentLifecycle != Lifecycle::Failed) - setLifecycle(Lifecycle::Failed, socket.errorString()); + setLifecycle(Lifecycle::Disconnected, socket.errorString()); scheduleReconnect(); } @@ -1448,8 +1462,12 @@ void FrontendSessionWorker::handleConnectionStateChange(const sdk::ConnectionSta if (!change.error->retryable) { automaticReconnectEnabled = false; reconnectTimer.stop(); + setLifecycle(Lifecycle::Failed, + QString::fromStdString(change.error->message)); + } else { + setLifecycle(Lifecycle::Disconnected, + QString::fromStdString(change.error->message)); } - setLifecycle(Lifecycle::Failed, QString::fromStdString(change.error->message)); return; } @@ -1519,12 +1537,22 @@ void FrontendSessionWorker::resetReconnectPolicy() { automaticReconnectEnabled = true; reconnectTimer.stop(); + connectionStabilityTimer.stop(); reconnectDelayMs = initialReconnectDelayMs; consecutivePreReadyDisconnects = 0; synchronizedCurrentConnection = false; preReadyFailureRecordedCurrentConnection = false; } +void FrontendSessionWorker::markConnectionStable() +{ + if (!localShutdown && synchronizedCurrentConnection + && currentLifecycle == Lifecycle::Ready) { + reconnectDelayMs = initialReconnectDelayMs; + consecutivePreReadyDisconnects = 0; + } +} + bool FrontendSessionWorker::recordPreReadyTransportFailure() { if (localShutdown || synchronizedCurrentConnection) @@ -1540,7 +1568,7 @@ bool FrontendSessionWorker::recordPreReadyTransportFailure() return false; failWithoutReconnect( - QStringLiteral("Backend connection failed before synchronization completed on %1 consecutive connections") + QStringLiteral("Backend connection failed before reaching a stable synchronized state on %1 consecutive connections") .arg(consecutivePreReadyDisconnects)); return true; } diff --git a/src/app/FrontendSessionWorker.h b/src/app/FrontendSessionWorker.h index e8bc732..ab25a0e 100644 --- a/src/app/FrontendSessionWorker.h +++ b/src/app/FrontendSessionWorker.h @@ -143,6 +143,7 @@ class FrontendSessionWorker : public QObject static constexpr int initialReconnectDelayMs = 250; static constexpr int maximumReconnectDelayMs = 5'000; static constexpr int maximumConsecutivePreReadyDisconnects = 5; + static constexpr int stableConnectionDwellMs = 10'000; static constexpr int outboundDrainRetryMs = 10; static constexpr qint64 maximumBufferedOutboundBytes = static_cast( 4U * (ai::openai::codex::frontend::DefaultFrontendMaximumInboundMessageBytes + 1U)); @@ -190,6 +191,7 @@ class FrontendSessionWorker : public QObject void scheduleReconnect(); void retryConnection(); void resetReconnectPolicy(); + void markConnectionStable(); [[nodiscard]] bool recordPreReadyTransportFailure(); void failWithoutReconnect(QString reason); [[nodiscard]] SendResult send(OutboundMessage&& message); @@ -209,6 +211,7 @@ class FrontendSessionWorker : public QObject QLocalSocket socket; QTimer reconnectTimer; + QTimer connectionStabilityTimer; QTimer outboundDrainTimer; QByteArray inboundBuffer; qsizetype inboundOffset = 0; diff --git a/tests/FrontendSessionTest.cpp b/tests/FrontendSessionTest.cpp index e597c01..b2a0169 100644 --- a/tests/FrontendSessionTest.cpp +++ b/tests/FrontendSessionTest.cpp @@ -56,6 +56,14 @@ struct FrontendSessionWorkerTestAccess return FrontendSessionWorker::maximumConsecutivePreReadyDisconnects; } + static void markUnstableSynchronized(FrontendSessionWorker& session) + { + session.synchronizedCurrentConnection = true; + session.preReadyFailureRecordedCurrentConnection = false; + session.connectionStabilityTimer.start( + FrontendSessionWorker::stableConnectionDwellMs); + } + static std::size_t maximumFrameBytes(const FrontendSessionWorker& session) { return session.maximumFrameBytes; @@ -796,10 +804,10 @@ bool testLifecycleAndDiagnostics() codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange(session, retryableChange); const int retryableSignalCount = lifecycleChanges; codexui::FrontendSessionWorkerTestAccess::handleConnectionStateChange(session, retryableChange); - passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed + passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Disconnected && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) && lifecycleChanges == retryableSignalCount && retryableSignalCount == 2, - "a retryable connection error must produce one failed transition and retain automatic reconnect"); + "a retryable connection error must remain visibly disconnected while automatic reconnect is active"); sdk::Error terminalError; terminalError.message = "terminal protocol failure"; @@ -843,8 +851,8 @@ bool testPreReadyReconnectBound() passed &= expect(session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Failed && !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == maximum - && session.statusText().contains(QStringLiteral("before synchronization completed")), - "repeated pre-synchronization disconnects stop at a visible terminal boundary"); + && session.statusText().contains(QStringLiteral("stable synchronized state")), + "repeated unstable connection attempts stop at a visible terminal boundary"); codexui::FrontendSessionWorkerTestAccess::resetReconnectPolicy(session); std::vector messages; @@ -852,11 +860,35 @@ bool testPreReadyReconnectBound() codexui::FrontendSessionWorkerTestAccess::synchronizeWithCapturedTransport(session, messages) && session.lifecycle() == codexui::FrontendSessionWorker::Lifecycle::Ready && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 0, - "the real SDK synchronization callback must reset the pre-ready retry budget"); + "the first SDK synchronization callback must enter Ready without inventing retry failures"); codexui::FrontendSessionWorkerTestAccess::disconnectTransport(session); passed &= expect(codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled(session) - && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 0, - "a disconnect after synchronization does not consume the pre-ready retry budget"); + && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects(session) == 1, + "a disconnect before the Ready dwell boundary must retain unstable-connection retry history"); + + codexui::FrontendSessionWorker unstableReadySession; + for (int attempt = 1; attempt < maximum; ++attempt) { + codexui::FrontendSessionWorkerTestAccess::markUnstableSynchronized( + unstableReadySession); + codexui::FrontendSessionWorkerTestAccess::disconnectTransport( + unstableReadySession); + passed &= expect( + codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( + unstableReadySession) + && codexui::FrontendSessionWorkerTestAccess::consecutivePreReadyDisconnects( + unstableReadySession) == attempt, + "a short-lived synchronized connection must retain exponential retry history"); + } + codexui::FrontendSessionWorkerTestAccess::markUnstableSynchronized( + unstableReadySession); + codexui::FrontendSessionWorkerTestAccess::disconnectTransport( + unstableReadySession); + passed &= expect( + !codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( + unstableReadySession) + && unstableReadySession.lifecycle() + == codexui::FrontendSessionWorker::Lifecycle::Failed, + "repeated post-synchronization flapping must stop at the same bounded retry boundary"); codexui::FrontendSessionWorker failedConnectSession; for (int attempt = 1; attempt < maximum; ++attempt) { @@ -1244,7 +1276,7 @@ bool testIncompleteThreadReadIsBounded() std::vector secondConnectionOutbound; const bool disconnectedForRetry = reconnectSession.lifecycle() - == codexui::FrontendSessionWorker::Lifecycle::Failed + == codexui::FrontendSessionWorker::Lifecycle::Disconnected && codexui::FrontendSessionWorkerTestAccess::automaticReconnectEnabled( reconnectSession); const bool synchronizedAgain = From 027cb552a4891670c1de3f8890edc56e66783b42 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 11:55:42 +0200 Subject: [PATCH 8/9] Publish staged attachments with private mode --- src/app/AttachmentManager.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/AttachmentManager.cpp b/src/app/AttachmentManager.cpp index f0b679e..cc61394 100644 --- a/src/app/AttachmentManager.cpp +++ b/src/app/AttachmentManager.cpp @@ -128,19 +128,21 @@ bool copyFileAtomically(const QString& sourcePath, } if (cancelled && cancelled()) return cancelDestination(); - if (!destination.commit()) { + // Apply the final private mode to QSaveFile's temporary inode before its + // atomic rename publishes that inode at destinationPath. + if (!destination.setPermissions(PrivateFilePermissions)) { + destination.cancelWriting(); if (errorMessage) *errorMessage = errorWithPath( - QStringLiteral("Unable to finish the staged attachment: %1") - .arg(destination.errorString()), + QStringLiteral("Unable to make the staged attachment private."), destinationPath); return false; } - if (!QFile::setPermissions(destinationPath, PrivateFilePermissions)) { - (void)QFile::remove(destinationPath); + if (!destination.commit()) { if (errorMessage) *errorMessage = errorWithPath( - QStringLiteral("Unable to make the staged attachment private."), + QStringLiteral("Unable to finish the staged attachment: %1") + .arg(destination.errorString()), destinationPath); return false; } From f7b931c9cf7fc258a16a715b97a13e73acef518e Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sat, 22 Aug 2026 12:02:22 +0200 Subject: [PATCH 9/9] Preserve structural item reconciliation --- src/app/FrontendSessionWorker.cpp | 17 ++++------------- tests/FrontendSessionTest.cpp | 10 ++++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/app/FrontendSessionWorker.cpp b/src/app/FrontendSessionWorker.cpp index df3aedc..00f53a1 100644 --- a/src/app/FrontendSessionWorker.cpp +++ b/src/app/FrontendSessionWorker.cpp @@ -213,20 +213,11 @@ StateUpdateScope stateUpdateScope(const sdk::StateUpdate& update) } else if constexpr (std::is_same_v) { - if (value.threadId) { - // A new or changed item requires bounded timeline - // reconciliation, but it does not invalidate the - // complete selected-thread presentation. Keeping it - // out of fullyAffectedThreadIds lets ConversationWidget - // retain and reconcile its existing segment widgets. - addThread(value.threadId->value); - addInspectorThread(value.threadId->value); - } + if (value.threadId) + markThreadAndInspector(value.threadId->value); else if (value.turnId) { - if (const auto* turn = update.state.turn(*value.turnId)) { - addThread(turn->threadId.value); - addInspectorThread(turn->threadId.value); - } + if (const auto* turn = update.state.turn(*value.turnId)) + markThreadAndInspector(turn->threadId.value); else { scope.allThreadsAffected = true; scope.allInspectorsAffected = true; diff --git a/tests/FrontendSessionTest.cpp b/tests/FrontendSessionTest.cpp index b2a0169..843e431 100644 --- a/tests/FrontendSessionTest.cpp +++ b/tests/FrontendSessionTest.cpp @@ -676,12 +676,13 @@ bool testScopedItemPresentationChanges() && unresolvedTurn.hasPresentationChange, "a turn upsert without a unique parent lookup must conservatively refresh all threads"); passed &= expect(scoped.affectedThreadIds == QStringList{QStringLiteral("target-thread")} - && scoped.fullyAffectedThreadIds.empty() + && scoped.fullyAffectedThreadIds + == QStringList{QStringLiteral("target-thread")} && scoped.affectedInspectorThreadIds == QStringList{QStringLiteral("target-thread")} && !scoped.allThreadsAffected && !scoped.allInspectorsAffected && !scoped.sidebarAffected && scoped.hasPresentationChange, - "a scoped item upsert must reconcile its canonical conversation and Inspector without invalidating retained widgets"); + "a scoped item upsert must refresh its canonical conversation and Inspector"); passed &= expect(streamed.affectedThreadIds == QStringList{QStringLiteral("target-thread")} && streamed.fullyAffectedThreadIds.empty() && streamed.affectedInspectorThreadIds.empty() @@ -720,10 +721,11 @@ bool testScopedItemPresentationChanges() && !oversizedAppend.affectedItemContents.front().append && oversizedAppend.coalescedContentDeltaBytes == 0, "an oversized append hint must degrade to an authoritative replacement without entering the GUI mailbox"); - passed &= expect(mixed.fullyAffectedThreadIds.empty() + passed &= expect(mixed.fullyAffectedThreadIds + == QStringList{QStringLiteral("target-thread")} && mixed.affectedItemContents.size() == 1 && !mixed.allThreadsAffected, - "a structural item change mixed with exact content must retain bounded widget reconciliation"); + "a structural change mixed with exact content must require full thread reconciliation"); passed &= expect(unscoped.affectedThreadIds.empty() && unscoped.allThreadsAffected && unscoped.fullyAffectedThreadIds.empty() && unscoped.affectedItemContents.empty()