From 4741aabb94b9b358654e64c84f6ecb3eb56bd457 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 23 Aug 2026 00:33:55 +0200 Subject: [PATCH 1/3] Retain activity rows across partial projections --- src/ui/ConversationWidget.cpp | 11 +++--- src/ui/InspectorWidget.cpp | 49 +++++++++++++++++++++----- src/ui/InspectorWidget.h | 4 +++ src/ui/WorkbenchWidget.cpp | 24 +++++++++++-- src/ui/WorkbenchWidget.h | 2 ++ tests/ConversationLayoutTest.cpp | 59 +++++++++++++++++++++++++++----- 6 files changed, 124 insertions(+), 25 deletions(-) diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index 64955c6..456840f 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -3022,11 +3022,12 @@ void ConversationWidget::render(const sdk::State& state, state, threadId, *exactContentChanges); if (exactContentApplied && !structuralReconciliation) 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 - // necessarily fullyLoaded and exact Absent changes the selection. - if (!structuralReconciliation) + // A bounded replacement is not deletion authority. This proof also gates + // structural item upserts: their scope says that topology may have grown, + // not that an incomplete State is authoritative for deleting every item it + // omitted. Keep the same-thread widgets until the publication can account + // for every rendered descendant; requester-local Merge will make that + // true, while Replace is fullyLoaded and exact Absent changes selection. { const bool renderedTimelineRetained = !renderedTurnIds.isEmpty(); qsizetype recoveryInspectedItems = 0; diff --git a/src/ui/InspectorWidget.cpp b/src/ui/InspectorWidget.cpp index 13388dc..cf2f678 100644 --- a/src/ui/InspectorWidget.cpp +++ b/src/ui/InspectorWidget.cpp @@ -38,6 +38,7 @@ struct AgentPresentation struct CollaborationPresentation { + QString itemId; QString title; QString status; QString detail; @@ -550,6 +551,7 @@ std::vector collaborationPresentations(const sdk::Sta if (!collab) continue; CollaborationPresentation presentation; + presentation.itemId = fromUtf8(item->id.value); presentation.title = collab->tool ? humanize(fromUtf8(*collab->tool)) : QStringLiteral("Collaboration activity"); presentation.status = collab->status ? humanize(fromUtf8(*collab->status)) : itemStatus(*item); QStringList detail; @@ -667,6 +669,7 @@ void InspectorWidget::renderUnavailable(const QString& title, const QString& det unavailablePresentationKey = presentationKey; inspectedThreadId.clear(); dependentThreadIds.clear(); + presentedAgentActivityItemIds.clear(); selectedAgentItemId.clear(); planPresentationKey.clear(); agentsPresentationKey.clear(); @@ -752,6 +755,13 @@ void InspectorWidget::render(const sdk::State& state, } const auto* thread = state.thread(threadId.toStdString()); if (!thread) { + const auto capacity = state.capacityProvenance(); + const bool boundedSelectionUnresolved = threadId == inspectedThreadId + && ((capacity && capacity->omittedThreads > 0) + || (state.threadList().value + && !state.threadList().value->complete)); + if (boundedSelectionUnresolved) + return; renderUnavailable(QStringLiteral("Thread unavailable"), QStringLiteral("The selected thread is not retained in the current State.")); return; @@ -760,6 +770,7 @@ void InspectorWidget::render(const sdk::State& state, if (inspectedThreadId != threadId) { inspectedThreadId = threadId; dependentThreadIds.clear(); + presentedAgentActivityItemIds.clear(); selectedAgentItemId.clear(); planPresentationKey.clear(); agentsPresentationKey.clear(); @@ -950,16 +961,34 @@ void InspectorWidget::render(const sdk::State& state, agents = agentPresentations(state, *thread, *turn); collaborations = collaborationPresentations(state, *thread, *turn); } - dependentThreadIds.clear(); + QSet projectedAgentActivityItemIds; for (const AgentPresentation& agent : agents) { - if (!agent.agentThreadId.isEmpty()) - dependentThreadIds.insert(agent.agentThreadId); + for (const QString& itemId : agent.itemIds) + projectedAgentActivityItemIds.insert(itemId); } - const auto selected = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) { - return agent.itemIds.contains(selectedAgentItemId); - }); - if (selected == agents.end()) - selectedAgentItemId = agents.empty() ? QString{} : agents.front().itemIds.back(); + for (const CollaborationPresentation& collaboration : collaborations) + projectedAgentActivityItemIds.insert(collaboration.itemId); + const bool incompleteAgentsRegressed = !thread->fullyLoaded + && !presentedAgentActivityItemIds.isEmpty() + && std::ranges::any_of( + presentedAgentActivityItemIds, + [&projectedAgentActivityItemIds](const QString& itemId) { + return !projectedAgentActivityItemIds.contains(itemId); + }); + + bool agentsChanged = false; + if (!incompleteAgentsRegressed) { + presentedAgentActivityItemIds = projectedAgentActivityItemIds; + dependentThreadIds.clear(); + for (const AgentPresentation& agent : agents) { + if (!agent.agentThreadId.isEmpty()) + dependentThreadIds.insert(agent.agentThreadId); + } + const auto selected = std::find_if(agents.begin(), agents.end(), [this](const AgentPresentation& agent) { + return agent.itemIds.contains(selectedAgentItemId); + }); + if (selected == agents.end()) + selectedAgentItemId = agents.empty() ? QString{} : agents.front().itemIds.back(); QCryptographicHash agentsHash(QCryptographicHash::Sha256); addPresentationValue(agentsHash, turn != nullptr); @@ -986,6 +1015,7 @@ void InspectorWidget::render(const sdk::State& state, } } for (const auto& collaboration : collaborations) { + addPresentationValue(agentsHash, collaboration.itemId); addPresentationValue(agentsHash, collaboration.title); addPresentationValue(agentsHash, collaboration.status); addPresentationValue(agentsHash, collaboration.detail); @@ -998,7 +1028,7 @@ void InspectorWidget::render(const sdk::State& state, } } const QByteArray nextAgentsKey = agentsHash.result(); - const bool agentsChanged = nextAgentsKey != agentsPresentationKey; + agentsChanged = nextAgentsKey != agentsPresentationKey; if (agentsChanged) { agentsPresentationKey = nextAgentsKey; clearLayout(agentsContent); @@ -1151,6 +1181,7 @@ void InspectorWidget::render(const sdk::State& state, agentsContent->addStretch(); } } + } // Changes: only canonical projected metadata is shown. The installed view // does not expose path strings, typed change kinds, or line counts. diff --git a/src/ui/InspectorWidget.h b/src/ui/InspectorWidget.h index 831c73d..330ee5d 100644 --- a/src/ui/InspectorWidget.h +++ b/src/ui/InspectorWidget.h @@ -53,6 +53,10 @@ class InspectorWidget : public QWidget QVBoxLayout* infoContent = nullptr; QString inspectedThreadId; QSet dependentThreadIds; + // An incomplete latest-turn projection may omit previously rendered + // activities. Retain their identities until a complete projection has + // authority to remove them. + QSet presentedAgentActivityItemIds; QString selectedAgentItemId; QByteArray unavailablePresentationKey; QByteArray planPresentationKey; diff --git a/src/ui/WorkbenchWidget.cpp b/src/ui/WorkbenchWidget.cpp index 604d97f..83e85ab 100644 --- a/src/ui/WorkbenchWidget.cpp +++ b/src/ui/WorkbenchWidget.cpp @@ -476,6 +476,8 @@ void WorkbenchWidget::refreshLifecycle() if (frontendSession.lifecycle() != Lifecycle::Ready) { threadContextStatus->setText(QStringLiteral("No thread context")); threadContextStatus->setToolTip({}); + retainedAgentActivityThreadId.clear(); + retainedAgentActivityItemIds.clear(); agentActivityStatus->setText(QStringLiteral("No agent activity")); inspector->render(frontendSession.state(), {}, false, frontendSession.statusText()); } @@ -593,7 +595,9 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, const bool selectedMissingFromBoundedState = !selected && !selectedThreadId.isEmpty() && !awaitingSelectedThread - && omittedThreads > 0; + && (omittedThreads > 0 + || (state.threadList().value + && !state.threadList().value->complete)); if (refreshSelectedPresentation && !requiresStructuralReconciliation) { const QString context = ready && selected && selected->cwd ? QString::fromStdString(selected->cwd->value) @@ -612,7 +616,7 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, } if (refreshSelectedPresentation) { - std::size_t agentActivities = 0; + QSet projectedAgentActivityItemIds; if (const auto* turn = ready ? latestTurn(state, selected) : nullptr) { for (const auto& itemId : turn->orderedItems) { const auto* item = state.item(selected->id, turn->id, itemId); @@ -624,9 +628,23 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, ai::openai::codex::frontend::client::SubAgentActivitySemanticView>(semantic->details) || std::holds_alternative< ai::openai::codex::frontend::client::CollabAgentToolCallSemanticView>(semantic->details))) - ++agentActivities; + projectedAgentActivityItemIds.insert( + QString::fromStdString(item->id.value)); } } + if (retainedAgentActivityThreadId != selectedThreadId) { + retainedAgentActivityThreadId = selectedThreadId; + retainedAgentActivityItemIds.clear(); + } + if (selected) { + if (selected->fullyLoaded) + retainedAgentActivityItemIds = projectedAgentActivityItemIds; + else + retainedAgentActivityItemIds.unite(projectedAgentActivityItemIds); + } else if (!selectedMissingFromBoundedState) { + retainedAgentActivityItemIds.clear(); + } + const qsizetype agentActivities = retainedAgentActivityItemIds.size(); agentActivityStatus->setText(agentActivities == 0 ? QStringLiteral("No agent activity") : QStringLiteral("%1 agent activit%2") diff --git a/src/ui/WorkbenchWidget.h b/src/ui/WorkbenchWidget.h index 8154298..7b56e15 100644 --- a/src/ui/WorkbenchWidget.h +++ b/src/ui/WorkbenchWidget.h @@ -213,6 +213,8 @@ class WorkbenchWidget : public QWidget QString authoritativelyRemovedSelectedThreadId; QString selectedInspectorTurnId; QString projectedAgentThreadId; + QString retainedAgentActivityThreadId; + QSet retainedAgentActivityItemIds; QString newThreadIdAwaitingState; QString pendingPrompt; QList pendingAttachments; diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index 204a585..e5b4757 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -1880,12 +1880,22 @@ bool testIncompleteReplacementPreservesRenderedTimeline() ThreadFixture completeActivities{ "grouped-activity-retention", {{"grouped-activity-turn", - {{"grouped-activity-first", - frontend::ThreadItemKind::Reasoning, - "first retained activity"}, + {{"grouped-activity-first", + frontend::ThreadItemKind::CommandExecution, + "first retained command output", + "completed", + false, + false, + false, + "printf first"}, {"grouped-activity-second", - frontend::ThreadItemKind::Reasoning, - "second retained activity"}}}}}; + frontend::ThreadItemKind::CommandExecution, + "second retained command output", + "completed", + false, + false, + false, + "printf second"}}}}}; codexui::ConversationWidget groupedConversation; groupedConversation.resize(900, 700); groupedConversation.show(); @@ -1915,7 +1925,10 @@ bool testIncompleteReplacementPreservesRenderedTimeline() partialActivities.turns.front().messages.pop_back(); groupedConversation.render( makeState({partialActivities}), - QStringLiteral("grouped-activity-retention")); + QStringLiteral("grouped-activity-retention"), + false, + nullptr, + true); settleTimeline(); passed &= expect( activitySegment && activitySegment.data() == activitySegmentAddress @@ -1923,7 +1936,7 @@ bool testIncompleteReplacementPreservesRenderedTimeline() && 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"); + "a structural command upsert from an incomplete projection must preserve every rendered shell row, not only the row that owns its segment identity"); ThreadFixture largePrefix{"bounded-recovery-prefix", {{"bounded-recovery-turn", {}}}}; constexpr int largePrefixItems = 2'048; @@ -2877,15 +2890,45 @@ bool testInspectorThreadDependencies() && !inspector.dependsOnThread(QStringLiteral("unrelated")), "Inspector invalidation must include its selected parent and linked agent thread only"); + QPointer retainedAgentRow; + for (QPushButton* button : inspector.findChildren()) { + if (button->toolTip() == QStringLiteral("agent/reviewer")) { + retainedAgentRow = button; + break; + } + } + ThreadFixture partialParent = singleTurn("inspector-parent", 1); + partialParent.fullyLoaded = false; + const client::State partialWithoutActivity = makeState( + {partialParent, singleTurn("inspector-agent-child", 1)}); + inspector.render(partialWithoutActivity, + QStringLiteral("inspector-parent"), + true, + QStringLiteral("State synced")); + settleEvents(); + const auto partialLabels = inspector.findChildren(); + passed &= expect(retainedAgentRow + && inspector.dependsOnThread( + QStringLiteral("inspector-agent-child")) + && std::ranges::none_of( + partialLabels, + [](const QLabel* label) { + return label->text() + == QStringLiteral("No agent activity"); + }), + "an incomplete latest-turn projection must retain the Agents row and its linked-thread dependency"); + const client::State withoutActivity = makeState( {singleTurn("inspector-parent", 1), singleTurn("inspector-agent-child", 1)}); inspector.render(withoutActivity, QStringLiteral("inspector-parent"), true, QStringLiteral("State synced")); + settleEvents(); passed &= expect(inspector.dependsOnThread(QStringLiteral("inspector-parent")) && !inspector.dependsOnThread( - QStringLiteral("inspector-agent-child")), + QStringLiteral("inspector-agent-child")) + && !retainedAgentRow, "removing subagent activity must discard its stale linked-thread dependency"); return passed; } From 8b31de53d7b99642f0a54aceea13df7dbf1337e2 Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 23 Aug 2026 01:08:22 +0200 Subject: [PATCH 2/3] Reconstruct complete retained timelines --- src/ui/ConversationWidget.cpp | 66 +++++++++++-------------------- tests/ConversationLayoutTest.cpp | 67 ++++++++++++++++---------------- 2 files changed, 57 insertions(+), 76 deletions(-) diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index 456840f..ba0b678 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -49,8 +49,11 @@ namespace namespace sdk = ai::openai::codex::frontend::client; namespace frontend = ai::openai::codex::frontend; -constexpr qsizetype maximumRenderedTimelineTurns = 32; -constexpr qsizetype maximumRenderedTimelineItems = 256; +// Incomplete replacement proofs stay bounded even though a fully loaded +// thread materializes all retained history. A partial publication that would +// require more work simply keeps the existing presentation until recovery. +constexpr qsizetype recoveryInspectionItemBudget = 256; +constexpr qsizetype recoveryInspectionTurnBudget = 32; constexpr std::size_t maximumActivityItemsPerSegment = 16; constexpr qsizetype largeMessageEditorThreshold = 64 * 1024; constexpr int largeMessageEditorHeight = 240; @@ -2072,7 +2075,6 @@ struct TimelineWindow std::vector turns; qsizetype renderedItems = 0; qsizetype totalItems = 0; - bool earlierItemsOmitted = false; }; QString segmentStorageKey(const QString& turnId, const QString& segmentId) @@ -2166,34 +2168,22 @@ qsizetype timelineItemCount(const TimelineSegment& segment) return qMax(1, static_cast(segment.items.size())); } -TimelineWindow latestTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread) +TimelineWindow retainedTimelineWindow(const sdk::State& state, const sdk::ThreadState& thread) { TimelineWindow result; - qsizetype remainingItems = maximumRenderedTimelineItems; - for (qsizetype index = static_cast(thread.orderedTurns.size()); - index > 0 && remainingItems > 0 - && static_cast(result.turns.size()) < maximumRenderedTimelineTurns; - --index) + result.turns.reserve(thread.orderedTurns.size()); + for (qsizetype index = 0; + index < static_cast(thread.orderedTurns.size()); + ++index) { - const auto* turn = state.turn(thread.id, thread.orderedTurns.at(index - 1)); + const auto* turn = state.turn(thread.id, thread.orderedTurns.at(index)); if (!turn) continue; const qsizetype itemCount = qMax(1, static_cast(turn->orderedItems.size())); - const qsizetype selectedItems = qMin(itemCount, remainingItems); - const qsizetype firstItem = turn->orderedItems.empty() - ? 0 - : static_cast(turn->orderedItems.size()) - selectedItems; - result.turns.insert(result.turns.begin(), {turn, index, firstItem}); - result.renderedItems += selectedItems; - remainingItems -= selectedItems; + result.turns.push_back({turn, index + 1, 0}); + result.renderedItems += itemCount; } - 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); + result.totalItems = result.renderedItems; return result; } @@ -2208,9 +2198,9 @@ bool incompleteStateContainsRenderedTimeline( { inspectedItems = 0; constexpr qsizetype maximumRecoveryInspectedItems = - maximumRenderedTimelineItems + recoveryInspectionItemBudget + (static_cast(maximumActivityItemsPerSegment) - 1) - * maximumRenderedTimelineTurns; + * recoveryInspectionTurnBudget; for (const QString& renderedTurnId : renderedTurnIds) { const sdk::TurnState* retainedTurn = state.turn( @@ -2887,8 +2877,10 @@ ConversationWidget::ConversationWidget(QWidget* parent) : QWidget(parent) timelineHost = new QWidget; timelineHost->setObjectName(QStringLiteral("conversationTimeline")); - timelineHost->setProperty("maximumRenderedTurns", maximumRenderedTimelineTurns); - timelineHost->setProperty("maximumRenderedItems", maximumRenderedTimelineItems); + timelineHost->setProperty( + "recoveryInspectionTurnBudget", recoveryInspectionTurnBudget); + timelineHost->setProperty( + "recoveryInspectionItemBudget", recoveryInspectionItemBudget); timeline = new QVBoxLayout(timelineHost); timeline->setContentsMargins(0, 0, 0, 0); timeline->setSpacing(0); @@ -3200,7 +3192,7 @@ void ConversationWidget::render(const sdk::State& state, std::optional structuralWindow; if (structuralReconciliation) { - structuralWindow.emplace(latestTimelineWindow(state, *thread)); + structuralWindow.emplace(retainedTimelineWindow(state, *thread)); if (!structuralWindow->turns.empty()) currentTurn = structuralWindow->turns.back().turn; } @@ -3266,7 +3258,7 @@ void ConversationWidget::render(const sdk::State& state, { const TimelineWindow window = structuralWindow ? std::move(*structuralWindow) - : latestTimelineWindow(state, *thread); + : retainedTimelineWindow(state, *thread); std::vector entries; entries.reserve(static_cast(window.renderedItems)); for (const TimelineTurnSlice& slice : window.turns) @@ -3277,19 +3269,7 @@ void ConversationWidget::render(const sdk::State& state, } timelineHost->setProperty("renderedTimelineItems", window.renderedItems); timelineHost->setProperty("retainedTimelineItems", window.totalItems); - if (window.earlierItemsOmitted) - { - timelineWindowDetail->setText( - 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)); - timelineWindowNotice->show(); - } - else - { - timelineWindowNotice->hide(); - } + timelineWindowNotice->hide(); struct VisibleTimelineTurn { diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index e5b4757..e804e1a 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -629,21 +629,20 @@ bool testTurnWindow() QWidget* host = timeline(conversation); QFrame* notice = windowNotice(conversation); - const qsizetype maximumTurns = host ? host->property("maximumRenderedTurns").toLongLong() : 0; const QStringList turns = renderedTurnIds(conversation); bool passed = true; passed &= expect(state.thread("many-turns") != nullptr, "the long-turn fixture must produce public AISuite State"); - passed &= expect(host && maximumTurns == 32, "the conversation must publish its bounded live-turn budget"); - passed &= expect(turns.size() == maximumTurns, "the live timeline must remain within the turn budget"); - passed &= expect(!turns.isEmpty() && turns.front() == QStringLiteral("turn-many-turns-8") + passed &= expect(host && turns.size() == 40, + "a fully loaded conversation must materialize every retained turn"); + passed &= expect(!turns.isEmpty() && turns.front() == QStringLiteral("turn-many-turns-0") && turns.back() == QStringLiteral("turn-many-turns-39"), - "the turn window must preserve the exact canonical tail order and original identities"); - passed &= expect(notice && notice->isVisible(), - "a bounded timeline must truthfully disclose omitted earlier entries"); + "the timeline must preserve complete canonical order and original identities"); + passed &= expect(notice && !notice->isVisible(), + "a fully loaded timeline must not claim that retained entries are omitted"); passed &= expect(hasLabel(conversation, QStringLiteral("message many-turns 39")), "the newest retained turn must remain visible"); - passed &= expect(!hasLabel(conversation, QStringLiteral("message many-turns 0")), - "an entry outside the live turn window must not allocate a widget"); + passed &= expect(hasLabel(conversation, QStringLiteral("message many-turns 0")), + "the earliest retained turn must remain available after reconstruction"); return passed; } @@ -685,22 +684,23 @@ bool testHotTurnWindow() settleTimeline(); QWidget* host = timeline(conversation); - const qsizetype maximumItems = host ? host->property("maximumRenderedItems").toLongLong() : 0; const auto segments = conversation.findChildren(QStringLiteral("conversationSegment")); qsizetype renderedItems = 0; for (QWidget* item : segments) renderedItems += item->property("timelineItemCount").toLongLong(); bool passed = true; passed &= expect(state.thread("hot") != nullptr, "the hot-turn fixture must produce public AISuite State"); - passed &= expect(maximumItems == 256 && renderedItems == maximumItems, - "one oversized turn must remain within the global live-item budget"); - passed &= expect(segment(conversation, QStringLiteral("message:item-hot-0")) == nullptr, - "the oversized turn must not materialize its earliest out-of-window message"); + passed &= expect(host && renderedItems == 300 + && host->property("renderedTimelineItems").toLongLong() == 300 + && host->property("retainedTimelineItems").toLongLong() == 300, + "one oversized turn must materialize its complete retained history"); + passed &= expect(segment(conversation, QStringLiteral("message:item-hot-0")) != nullptr, + "the oversized turn must retain its earliest message after reconstruction"); passed &= expect(segment(conversation, QStringLiteral("message:item-hot-299")) != nullptr && hasLabel(conversation, QStringLiteral("message hot 299")), "the oversized turn must retain its exact newest message"); - passed &= expect(windowNotice(conversation) && windowNotice(conversation)->isVisible(), - "the oversized turn must show the presentation-window notice"); + passed &= expect(windowNotice(conversation) && !windowNotice(conversation)->isVisible(), + "complete retained history must not show a presentation-window notice"); passed &= expect(windowNotice(conversation) && windowNotice(conversation)->styleSheet().contains( QStringLiteral("QFrame#conversationWindowNotice")), @@ -721,11 +721,11 @@ bool testHotTurnWindow() boundedCards = boundedCards && cardItems <= 16; } passed &= expect(activityState.thread("activity") != nullptr && boundedCards && activityHost - && renderedActivities == activityHost->property("maximumRenderedItems").toLongLong() + && renderedActivities == 300 && renderedActivities == activityHost->property("renderedTimelineItems").toLongLong() && activityHost->property("retainedTimelineItems").toLongLong() - == renderedActivities + 1, - "a contiguous activity run must be chunked within the global item budget without an all-history count scan"); + == renderedActivities, + "a contiguous activity run must retain every canonical row in bounded-size cards"); QWidget* newestActivityRow = nullptr; for (QWidget* row : activityConversation.findChildren( QStringLiteral("conversationActivityRow"))) @@ -751,7 +751,7 @@ bool testHotTurnWindow() && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 0 && newestActivityDetails->property("deferredDetailBytes").toULongLong() == std::string_view("activity activity 299").size(), - "the bounded activity window must retain its newest detail without materializing collapsed text"); + "the complete activity timeline must retain its newest detail without materializing collapsed text"); if (newestActivityDisclosure) newestActivityDisclosure->click(); settleTimeline(); @@ -764,7 +764,7 @@ bool testHotTurnWindow() == QStringLiteral("activity activity 299") && newestActivityDetails && newestActivityDetails->property("detailMaterializationCount").toULongLong() == 1, - "expanding the newest bounded activity must materialize its exact retained detail once"); + "expanding the newest activity must materialize its exact retained detail once"); const auto activityCards = activityConversation.findChildren( QStringLiteral("conversationActivityCard")); passed &= expect(!activityCards.isEmpty() @@ -1039,7 +1039,7 @@ bool testPointerPreservingAppend() conversation.render(before, QStringLiteral("append")); settleTimeline(); - QPointer evicted = segment(conversation, QStringLiteral("message:item-append-0")); + QPointer retainedHead = segment(conversation, QStringLiteral("message:item-append-0")); QPointer survivor = segment(conversation, QStringLiteral("message:item-append-2")); QPointer readingAnchor = segment(conversation, QStringLiteral("message:item-append-10")); QWidget* survivorAddress = survivor.data(); @@ -1081,7 +1081,7 @@ bool testPointerPreservingAppend() ? scroll->viewport()->mapFromGlobal(readingAnchor->mapToGlobal(QPoint{})).y() : 0; bool passed = true; - passed &= expect(readingHistory && evicted && survivor + passed &= expect(readingHistory && retainedHead && survivor && survivor.data() == survivorAddress && qAbs(frozenAnchorY - anchorYBefore) <= 2 && !segment(conversation, QStringLiteral("message:item-append-256")) @@ -1095,17 +1095,17 @@ bool testPointerPreservingAppend() conversation.render(latest, QStringLiteral("append")); settleTimeline(); - passed &= expect(!evicted && survivor && survivor.data() == survivorAddress + passed &= expect(retainedHead && survivor && survivor.data() == survivorAddress && survivor.data() == segment(conversation, QStringLiteral("message:item-append-2")), - "rolling the bounded head must preserve every overlapping segment widget"); + "appending past the former window boundary must preserve the retained head and overlapping widgets"); passed &= expect(segment(conversation, QStringLiteral("message:item-append-256")) != nullptr && segment(conversation, QStringLiteral("message:item-append-257")) != nullptr && hasLabel(conversation, QStringLiteral("reflected prompt")) && hasLabel(conversation, QStringLiteral("updated final answer")), "the reflected prompt and final answer must append at the timeline tail"); - passed &= expect(host && host->property("renderedTimelineItems").toLongLong() - <= host->property("maximumRenderedItems").toLongLong(), - "appending at the rolling boundary must keep the live-item count bounded"); + passed &= expect(host && host->property("renderedTimelineItems").toLongLong() == 258 + && host->property("retainedTimelineItems").toLongLong() == 258, + "appending at the former rolling boundary must retain every canonical item"); codexui::ConversationWidget followingConversation; followingConversation.resize(900, 700); @@ -1970,8 +1970,8 @@ bool testIncompleteReplacementPreservesRenderedTimeline() QWidget* boundedHost = timeline(boundedConversation); const qlonglong maximumRecoveryScan = boundedHost - ? boundedHost->property("maximumRenderedItems").toLongLong() - + 15 * boundedHost->property("maximumRenderedTurns").toLongLong() + ? boundedHost->property("recoveryInspectionItemBudget").toLongLong() + + 15 * boundedHost->property("recoveryInspectionTurnBudget").toLongLong() : 0; passed &= expect( retainedTail && retainedTail.data() == retainedTailAddress @@ -1980,7 +1980,7 @@ bool testIncompleteReplacementPreservesRenderedTimeline() && 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"); + "incomplete replacement recovery must remain bounded even when the complete retained timeline is large"); return passed; } @@ -2805,9 +2805,10 @@ bool testThreadSwitchWindow() QScrollArea* scroll = conversation.findChild(); passed &= expect(segment(conversation, present) != nullptr && segment(conversation, absent) == nullptr, "thread switching must retain only the selected canonical window"); + const qlonglong expectedItems = selected == QStringLiteral("switch-a") ? 300 : 2; passed &= expect(host && host->property("renderedTimelineItems").toLongLong() - <= host->property("maximumRenderedItems").toLongLong(), - "every selected thread must remain within the live-item budget"); + == expectedItems, + "every selected thread must reconstruct its complete retained timeline"); passed &= expect(scroll && scroll->verticalScrollBar()->value() == scroll->verticalScrollBar()->maximum(), "a selected historical thread must settle at its newest retained entry"); return host ? host->height() : 0; From 5f749c54a3a722e4b7f1148569e37a1457f361ae Mon Sep 17 00:00:00 2001 From: Volker Christian Date: Sun, 23 Aug 2026 01:16:03 +0200 Subject: [PATCH 3/3] Restore retained history after reconnect --- src/ui/ConversationWidget.cpp | 26 ++++++++++--- src/ui/InspectorWidget.cpp | 65 +++++++++++++++++++++++++++++--- src/ui/WorkbenchWidget.cpp | 29 ++++++++------ tests/ConversationLayoutTest.cpp | 30 ++++++++++++--- 4 files changed, 121 insertions(+), 29 deletions(-) diff --git a/src/ui/ConversationWidget.cpp b/src/ui/ConversationWidget.cpp index ba0b678..0237920 100644 --- a/src/ui/ConversationWidget.cpp +++ b/src/ui/ConversationWidget.cpp @@ -3349,14 +3349,23 @@ void ConversationWidget::render(const sdk::State& state, else { removedTurnPrefix = renderedTurnIds.indexOf(visibleTurnIds.front()); - compatibleTurns = removedTurnPrefix >= 0 - && renderedTurnIds.size() - removedTurnPrefix <= visibleTurnIds.size(); + qsizetype visibleTurnPrefix = 0; + if (removedTurnPrefix < 0) + { + removedTurnPrefix = 0; + visibleTurnPrefix = visibleTurnIds.indexOf( + renderedTurnIds.front()); + } + compatibleTurns = visibleTurnPrefix >= 0 + && renderedTurnIds.size() - removedTurnPrefix + <= visibleTurnIds.size() - visibleTurnPrefix; for (qsizetype index = 0; - compatibleTurns && index < renderedTurnIds.size() - removedTurnPrefix; + compatibleTurns + && index < renderedTurnIds.size() - removedTurnPrefix; ++index) { const QString oldId = renderedTurnIds.at(removedTurnPrefix + index); - compatibleTurns = oldId == visibleTurnIds.at(index) + compatibleTurns = oldId == visibleTurnIds.at(visibleTurnPrefix + index) && renderedTurnWidgets.contains(oldId) && renderedTurnLabels.contains(oldId) && renderedTurnItemLayouts.contains(oldId) @@ -3378,8 +3387,12 @@ void ConversationWidget::render(const sdk::State& state, } } - for (const VisibleTimelineTurn& visibleTurn : visibleTurns) + for (qsizetype visibleTurnIndex = 0; + visibleTurnIndex < static_cast(visibleTurns.size()); + ++visibleTurnIndex) { + const VisibleTimelineTurn& visibleTurn = visibleTurns.at( + static_cast(visibleTurnIndex)); const auto* turn = visibleTurn.turn; const QString turnId = fromUtf8(turn->id.value); const auto windowSlice = std::ranges::find_if( @@ -3408,7 +3421,8 @@ void ConversationWidget::render(const sdk::State& state, turnLabel, statusLabel, [this, turnId] { emit turnDetailsRequested(turnId); }); - timeline->addWidget(turnWidget, 0, Qt::AlignTop); + timeline->insertWidget( + visibleTurnIndex, turnWidget, 0, Qt::AlignTop); renderedTurnWidgets.insert(turnId, turnWidget); renderedTurnLabels.insert(turnId, turnLabel); renderedTurnItemLayouts.insert(turnId, itemLayout); diff --git a/src/ui/InspectorWidget.cpp b/src/ui/InspectorWidget.cpp index cf2f678..09b7761 100644 --- a/src/ui/InspectorWidget.cpp +++ b/src/ui/InspectorWidget.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -570,6 +571,62 @@ std::vector collaborationPresentations(const sdk::Sta return result; } +std::vector retainedAgentPresentations( + const sdk::State& state, + const sdk::ThreadState& thread) +{ + std::vector result; + for (const auto& turnId : thread.orderedTurns) { + const auto* turn = state.turn(thread.id, turnId); + if (!turn) + continue; + for (AgentPresentation projected : agentPresentations(state, thread, *turn)) { + const auto existing = std::find_if( + result.begin(), + result.end(), + [&projected](const AgentPresentation& retained) { + if (!projected.agentThreadId.isEmpty()) + return retained.agentThreadId == projected.agentThreadId; + return retained.agentThreadId.isEmpty() + && !projected.agentPath.isEmpty() + && retained.agentPath == projected.agentPath; + }); + if (existing == result.end()) { + result.push_back(std::move(projected)); + continue; + } + existing->itemIds.append(projected.itemIds); + if (!projected.kind.isEmpty()) + existing->kind = std::move(projected.kind); + if (!projected.status.isEmpty()) + existing->status = std::move(projected.status); + if (!projected.summary.isEmpty()) + existing->summary = std::move(projected.summary); + if (!projected.duration.isEmpty()) + existing->duration = std::move(projected.duration); + } + } + return result; +} + +std::vector retainedCollaborationPresentations( + const sdk::State& state, + const sdk::ThreadState& thread) +{ + std::vector result; + for (const auto& turnId : thread.orderedTurns) { + const auto* turn = state.turn(thread.id, turnId); + if (!turn) + continue; + auto projected = collaborationPresentations(state, thread, *turn); + result.insert( + result.end(), + std::make_move_iterator(projected.begin()), + std::make_move_iterator(projected.end())); + } + return result; +} + void addPresentationValue(QCryptographicHash& hash, const QByteArray& value) { hash.addData(QByteArray::number(value.size())); @@ -957,10 +1014,8 @@ void InspectorWidget::render(const sdk::State& state, // representation flat rather than inferring a parent/child tree. std::vector agents; std::vector collaborations; - if (turn) { - agents = agentPresentations(state, *thread, *turn); - collaborations = collaborationPresentations(state, *thread, *turn); - } + agents = retainedAgentPresentations(state, *thread); + collaborations = retainedCollaborationPresentations(state, *thread); QSet projectedAgentActivityItemIds; for (const AgentPresentation& agent : agents) { for (const QString& itemId : agent.itemIds) @@ -1034,7 +1089,7 @@ void InspectorWidget::render(const sdk::State& state, clearLayout(agentsContent); if (agents.empty() && collaborations.empty()) { addEmpty(agentsContent, QStringLiteral("No agent activity"), - turn ? QStringLiteral("No collab or subagent activity is projected for the latest turn.") + turn ? QStringLiteral("No retained collab or subagent activity is projected for this thread.") : QStringLiteral("This thread has no retained turns.")); } else { if (!agents.empty()) { diff --git a/src/ui/WorkbenchWidget.cpp b/src/ui/WorkbenchWidget.cpp index 83e85ab..581498f 100644 --- a/src/ui/WorkbenchWidget.cpp +++ b/src/ui/WorkbenchWidget.cpp @@ -617,19 +617,24 @@ void WorkbenchWidget::refreshState(bool refreshSelectedPresentation, if (refreshSelectedPresentation) { QSet projectedAgentActivityItemIds; - if (const auto* turn = ready ? latestTurn(state, selected) : nullptr) { - for (const auto& itemId : turn->orderedItems) { - const auto* item = state.item(selected->id, turn->id, itemId); - if (!item) + if (ready && selected) { + for (const auto& turnId : selected->orderedTurns) { + const auto* turn = state.turn(selected->id, turnId); + if (!turn) continue; - const auto semantic = ai::openai::codex::frontend::client::itemSemanticView(*item); - if (semantic - && (std::holds_alternative< - ai::openai::codex::frontend::client::SubAgentActivitySemanticView>(semantic->details) - || std::holds_alternative< - ai::openai::codex::frontend::client::CollabAgentToolCallSemanticView>(semantic->details))) - projectedAgentActivityItemIds.insert( - QString::fromStdString(item->id.value)); + for (const auto& itemId : turn->orderedItems) { + const auto* item = state.item(selected->id, turn->id, itemId); + if (!item) + continue; + const auto semantic = ai::openai::codex::frontend::client::itemSemanticView(*item); + if (semantic + && (std::holds_alternative< + ai::openai::codex::frontend::client::SubAgentActivitySemanticView>(semantic->details) + || std::holds_alternative< + ai::openai::codex::frontend::client::CollabAgentToolCallSemanticView>(semantic->details))) + projectedAgentActivityItemIds.insert( + QString::fromStdString(item->id.value)); + } } } if (retainedAgentActivityThreadId != selectedThreadId) { diff --git a/tests/ConversationLayoutTest.cpp b/tests/ConversationLayoutTest.cpp index e804e1a..0404f90 100644 --- a/tests/ConversationLayoutTest.cpp +++ b/tests/ConversationLayoutTest.cpp @@ -1975,12 +1975,23 @@ bool testIncompleteReplacementPreservesRenderedTimeline() : 0; passed &= expect( retainedTail && retainedTail.data() == retainedTailAddress - && hasLabel(boundedConversation, - QStringLiteral("bounded appended tail")) + && !hasLabel(boundedConversation, + QStringLiteral("bounded appended tail")) + && hasLabelContaining(boundedConversation, + QStringLiteral("History recovery pending")) && boundedHost && boundedHost->property("recoveryInspectedTimelineItems").toLongLong() <= maximumRecoveryScan, - "incomplete replacement recovery must remain bounded even when the complete retained timeline is large"); + "incomplete replacement recovery must freeze safely within its inspection budget when the retained timeline is large"); + largePrefix.fullyLoaded = true; + boundedConversation.render( + makeState({largePrefix}), QStringLiteral("bounded-recovery-prefix")); + settleTimeline(); + passed &= expect( + retainedTail && retainedTail.data() == retainedTailAddress + && hasLabel(boundedConversation, + QStringLiteral("bounded appended tail")), + "authoritative recovery must append new history without replacing retained widgets"); return passed; } @@ -2876,9 +2887,16 @@ bool testInspectorThreadDependencies() activity.agentPath = "agent/reviewer"; activity.agentThreadId = "inspector-agent-child"; activity.agentKind = "spawn"; + ThreadFixture parent{ + "inspector-parent", + {{"turn-inspector-parent-agents", {activity}, std::nullopt}, + {"turn-inspector-parent-latest", + {{"inspector-parent-latest-message", + frontend::ThreadItemKind::AgentMessage, + "A newer turn without agent activity"}}, + std::nullopt}}}; const client::State state = makeState( - {{"inspector-parent", {{"turn-inspector-parent", {activity}, std::nullopt}}}, - singleTurn("inspector-agent-child", 1)}); + {parent, singleTurn("inspector-agent-child", 1)}); codexui::InspectorWidget inspector; inspector.render(state, @@ -2889,7 +2907,7 @@ bool testInspectorThreadDependencies() && inspector.dependsOnThread( QStringLiteral("inspector-agent-child")) && !inspector.dependsOnThread(QStringLiteral("unrelated")), - "Inspector invalidation must include its selected parent and linked agent thread only"); + "Inspector reconstruction must retain earlier-turn agents and their linked thread dependencies"); QPointer retainedAgentRow; for (QPushButton* button : inspector.findChildren()) {