diff --git a/src/codex/PresentationModel.cpp b/src/codex/PresentationModel.cpp index a27e732..4a90630 100644 --- a/src/codex/PresentationModel.cpp +++ b/src/codex/PresentationModel.cpp @@ -88,8 +88,10 @@ void retainRepositoryHints(ThreadPresentation &thread, bool isSpawnActivity(const nlohmann::json &activity) { const std::string type = stringValue(activity, "type"); - if (type == "subAgentActivity") - return true; + if (type == "subAgentActivity") { + const std::string kind = stringValue(activity, "kind"); + return kind.empty() || kind == "started"; + } if (type != "collabAgentToolCall") return false; const std::string tool = stringValue(activity, "tool"); @@ -111,11 +113,26 @@ std::string childThreadIdentity(const nlohmann::json &activity) { std::string agentIdentity(const nlohmann::json &activity, const nlohmann::json &scope) { - const std::string childThreadId = childThreadIdentity(activity); - if (!childThreadId.empty()) - return childThreadId; const std::string itemId = stringValue(scope, "itemId"); - return itemId.empty() ? stringValue(activity, "id") : itemId; + if (!itemId.empty()) + return itemId; + const std::string activityId = stringValue(activity, "id"); + if (!activityId.empty()) + return activityId; + return childThreadIdentity(activity); +} + +bool isStaleAgentReplay(const ThreadPresentation &owner, + const nlohmann::json &scope, + const nlohmann::json &activity, bool live) { + if (live) + return false; + const std::string id = agentIdentity(activity, scope); + const std::string childThreadId = childThreadIdentity(activity); + const auto agent = owner.agents.find(id); + return !childThreadId.empty() && agent != owner.agents.end() && + !agent->second.childThreadId.empty() && + agent->second.childThreadId != childThreadId; } void mergePreservingCompleteness(nlohmann::json &target, @@ -263,9 +280,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { } else if (action == "thread.read") { const nlohmann::json thread = memberValue(data, "thread", nlohmann::json::object()); - ThreadPresentation &hydrated = - upsertThread(thread, stringValue(event, "authority") == "replace"); - correlateAgentThread(hydrated.id); + upsertThread(thread, stringValue(event, "authority") == "replace"); } else if (action == "thread.create" || action == "thread.resume" || action == "thread.fork") { upsertThread(memberValue(data, "thread", nlohmann::json::object()), @@ -384,7 +399,7 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { if (thread != threads.end()) { thread->second.status = statusValue(memberValue(data, "status")); thread->second.raw["status"] = memberValue(data, "status"); - correlateAgentThread(thread->first); + updateOwningAgentStatus(thread->first, thread->second.status); } return; } @@ -461,7 +476,6 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { TurnPresentation &updated = upsertTurn(thread, turn, false); if (lifecycle == "started" && isActiveStatus(updated.status)) thread.status = "active"; - correlateAgentThread(threadId); return; } if (type == "plan.replaced") { @@ -479,7 +493,6 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { nlohmann::json minimalTurn{{"id", turnId}}; TurnPresentation &turn = upsertTurn(thread, minimalTurn, false); upsertItem(thread, turn, data["item"], true); - correlateAgentThread(threadId); } return; } @@ -544,6 +557,8 @@ void PresentationModel::applyValidatedEvent(const nlohmann::json &event) { appendIndexedText(item->raw, "content", data, "contentIndex"); else if (!field.empty()) appendText(item->raw, field.c_str(), identity); + if (stringValue(item->raw, "type") == "agentMessage") + updateOwningAgentResult(threadId, stringValue(item->raw, "text")); } const std::vector & @@ -557,6 +572,12 @@ PresentationModel::thread(const std::string &threadId) const noexcept { return iterator == threads.end() ? nullptr : &iterator->second; } +const ChildThreadOwnership *PresentationModel::childOwnership( + const std::string &childThreadId) const noexcept { + const auto iterator = childOwnerships.find(childThreadId); + return iterator == childOwnerships.end() ? nullptr : &iterator->second; +} + std::optional PresentationModel::activeTurnId(const std::string &threadId) const { const ThreadPresentation *value = thread(threadId); @@ -613,7 +634,7 @@ void PresentationModel::mergeThreadList(const nlohmann::json &listedThreads) { if (id.empty()) continue; upsertThread(raw, false, false); - if (listedIds.insert(id).second) + if (!childOwnerships.contains(id) && listedIds.insert(id).second) nextOrder.push_back(id); } @@ -676,7 +697,11 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, const auto turns = raw.find("turns"); if (turns != raw.end() && turns->is_array()) { + std::vector previouslyOwnedChildren; if (replaceTurns) { + previouslyOwnedChildren = result.childThreadOrder; + for (const std::string &childThreadId : previouslyOwnedChildren) + releaseChildOwnership(childThreadId, false); result.turnOrder.clear(); result.turns.clear(); result.agentOrder.clear(); @@ -687,6 +712,13 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, for (const auto &turn : *turns) upsertTurn(result, turn, replaceTurns); if (replaceTurns) { + for (const std::string &childThreadId : previouslyOwnedChildren) { + if (!childOwnerships.contains(childThreadId) && + threads.contains(childThreadId) && + std::find(orderedThreads.begin(), orderedThreads.end(), + childThreadId) == orderedThreads.end()) + orderedThreads.push_back(childThreadId); + } for (const auto &[turnId, terminalStatus] : terminalTurnStatuses) { const auto turn = result.turns.find(turnId); if (turn != result.turns.end() && isActiveStatus(turn->second.status)) { @@ -705,6 +737,10 @@ ThreadPresentation &PresentationModel::upsertThread(const nlohmann::json &raw, result.raw["status"] = previousThreadStatus; } } + if (turns != raw.end() && turns->is_array()) + synchronizeOwningAgent(id, replaceTurns); + else if (status != raw.end() && result.status != "notLoaded") + updateOwningAgentStatus(id, result.status); return result; } @@ -743,6 +779,7 @@ TurnPresentation &PresentationModel::upsertTurn(ThreadPresentation &thread, for (const auto &item : *items) upsertItem(thread, result, item); } + updateOwningAgentStatus(thread.id, result.status); return result; } @@ -755,6 +792,19 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, static ItemPresentation ignored; return ignored; } + const nlohmann::json scope{{"threadId", thread.id}, + {"turnId", turn.id}, + {"itemId", id}}; + const std::string incomingType = stringValue(raw, "type"); + if ((incomingType == "subAgentActivity" || + incomingType == "collabAgentToolCall") && + isStaleAgentReplay(thread, scope, raw, live)) { + const auto existing = turn.items.find(id); + if (existing != turn.items.end()) + return existing->second; + static ItemPresentation ignored; + return ignored; + } auto [iterator, inserted] = turn.items.try_emplace(id); ItemPresentation &result = iterator->second; if (inserted) { @@ -767,11 +817,10 @@ ItemPresentation &PresentationModel::upsertItem(ThreadPresentation &thread, const std::string type = stringValue(result.raw, "type"); retainRepositoryHints(thread, result.raw); if (type == "subAgentActivity" || type == "collabAgentToolCall") { - upsertAgentActivity( - thread, - {{"threadId", thread.id}, {"turnId", turn.id}, {"itemId", result.id}}, - result.raw, live); + upsertAgentActivity(thread, scope, result.raw, live); } + if (type == "agentMessage") + updateOwningAgentResult(thread.id, stringValue(result.raw, "text")); return result; } @@ -780,23 +829,34 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, const nlohmann::json &activity, bool live) { const std::string type = stringValue(activity, "type"); + if (type == "subAgentActivity" && !isSpawnActivity(activity)) { + const std::string childThreadId = childThreadIdentity(activity); + AgentPresentation *existing = owningAgent(childThreadId); + if (!existing) + return; + const std::string agentPath = stringValue(activity, "agentPath"); + if (!agentPath.empty()) + existing->raw["agentPath"] = agentPath; + if (stringValue(activity, "kind") == "interrupted") + updateOwningAgentStatus(childThreadId, "interrupted"); + return; + } if (type == "collabAgentToolCall" && !isSpawnActivity(activity)) { const nlohmann::json states = memberValue(activity, "agentsStates", nlohmann::json::object()); if (!states.is_object()) return; for (const auto &[childThreadId, state] : states.items()) { - const auto existing = owner.agents.find(childThreadId); - if (existing == owner.agents.end() || !state.is_object()) + AgentPresentation *existing = owningAgent(childThreadId); + if (!existing || !state.is_object()) continue; const std::string status = stringValue(state, "status"); const std::string message = stringValue(state, "message"); if (!status.empty()) - existing->second.status = status; + updateOwningAgentStatus(childThreadId, status); if (!message.empty()) - existing->second.raw["resultText"] = message; - existing->second.raw["agentState"] = state; - correlateAgentThread(childThreadId); + updateOwningAgentResult(childThreadId, message); + existing->raw["agentState"] = state; } return; } @@ -808,6 +868,8 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, const std::string id = agentIdentity(activity, scope); if (id.empty()) return; + if (isStaleAgentReplay(owner, scope, activity, live)) + return; auto [iterator, inserted] = owner.agents.try_emplace(id); AgentPresentation &agent = iterator->second; @@ -815,79 +877,282 @@ void PresentationModel::upsertAgentActivity(ThreadPresentation &owner, agent.id = id; owner.agentOrder.push_back(id); } + const bool changesChild = !childThreadId.empty() && + !agent.childThreadId.empty() && + agent.childThreadId != childThreadId; agent.itemId = stringValue(scope, "itemId"); agent.ownerTurnId = stringValue(scope, "turnId"); mergePreservingCompleteness(agent.raw, activity); - if (!childThreadId.empty()) - agent.childThreadId = childThreadId; + if (changesChild) { + agent.status.clear(); + agent.raw.erase("status"); + if (ItemPresentation *item = agentSourceItem(owner, agent)) + item->raw.erase("status"); + clearAgentResult(owner, agent); + agent.raw.erase("agentState"); + } const std::string activityStatus = stringValue(activity, "status"); const std::string activityKind = stringValue(activity, "kind"); - if (!activityStatus.empty()) - agent.status = activityStatus; - else if (live && activityKind == "started") - agent.status = "inProgress"; - else if (!activityKind.empty()) - agent.status = activityKind; + std::string candidateStatus = activityStatus; + if (candidateStatus.empty() && live && activityKind == "started") + candidateStatus = "inProgress"; + else if (candidateStatus.empty() && !activityKind.empty()) + candidateStatus = activityKind; + if (!candidateStatus.empty()) { + if (isTerminalTurnStatus(agent.status) && + isActiveStatus(candidateStatus)) + setAgentStatus(owner, agent, agent.status); + else + setAgentStatus(owner, agent, candidateStatus); + } + + if (!childThreadId.empty()) + assignChildOwnership(owner, agent, childThreadId, live); +} + +void PresentationModel::assignChildOwnership(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &childThreadId, + bool live) { + if (childThreadId == parent.id) + return; + std::string ancestorId = parent.id; + std::unordered_set visited; + while (visited.insert(ancestorId).second) { + const auto ancestor = childOwnerships.find(ancestorId); + if (ancestor == childOwnerships.end()) + break; + ancestorId = ancestor->second.parentThreadId; + if (ancestorId == childThreadId) + return; + } + if (!agent.childThreadId.empty() && agent.childThreadId != childThreadId) { + const auto previous = childOwnerships.find(agent.childThreadId); + if (previous != childOwnerships.end() && + previous->second.parentThreadId == parent.id && + previous->second.agentId == agent.id) + releaseChildOwnership(agent.childThreadId, true); + } + + const auto previous = childOwnerships.find(childThreadId); + if (previous != childOwnerships.end() && + (previous->second.parentThreadId != parent.id || + previous->second.agentId != agent.id)) { + const auto previousParent = threads.find(previous->second.parentThreadId); + // A child read contains inherited ancestor and sibling activity. Those + // replayed items are historical context, not a new ownership authority. + // Replace/removal releases a vanished owner before reconstruction, while + // live activity may still authoritatively rebind an existing child. + if (!live && previousParent != threads.end()) { + const auto previousAgent = + previousParent->second.agents.find(previous->second.agentId); + if (previousAgent != previousParent->second.agents.end() && + previousAgent->second.childThreadId == childThreadId) + return; + } + releaseChildOwnership(childThreadId, false); + } + + agent.childThreadId = childThreadId; + agent.raw["childThreadId"] = childThreadId; + childOwnerships[childThreadId] = {parent.id, agent.id}; + if (std::find(parent.childThreadOrder.begin(), parent.childThreadOrder.end(), + childThreadId) == parent.childThreadOrder.end()) + parent.childThreadOrder.push_back(childThreadId); + + auto [child, inserted] = threads.try_emplace(childThreadId); + if (inserted) + child->second.id = childThreadId; + std::erase(orderedThreads, childThreadId); + synchronizeOwningAgent(childThreadId); +} - if (!agent.childThreadId.empty()) { - auto [child, childInserted] = threads.try_emplace(agent.childThreadId); - if (childInserted) - child->second.id = agent.childThreadId; - child->second.agentThread = true; - std::erase(orderedThreads, agent.childThreadId); - correlateAgentThread(agent.childThreadId); +void PresentationModel::releaseChildOwnership(const std::string &childThreadId, + bool promoteToRoot) { + const std::string releasedChildId = childThreadId; + const auto ownership = childOwnerships.find(releasedChildId); + if (ownership == childOwnerships.end()) + return; + const ChildThreadOwnership previous = ownership->second; + const auto parent = threads.find(previous.parentThreadId); + if (parent != threads.end()) { + std::erase(parent->second.childThreadOrder, releasedChildId); + const auto agent = parent->second.agents.find(previous.agentId); + if (agent != parent->second.agents.end() && + agent->second.childThreadId == releasedChildId) { + agent->second.childThreadId.clear(); + agent->second.raw.erase("childThreadId"); + } } + childOwnerships.erase(ownership); + if (promoteToRoot && threads.contains(releasedChildId) && + std::find(orderedThreads.begin(), orderedThreads.end(), releasedChildId) == + orderedThreads.end()) + orderedThreads.push_back(releasedChildId); +} + +AgentPresentation * +PresentationModel::owningAgent(const std::string &childThreadId) { + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return nullptr; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return nullptr; + const auto agent = parent->second.agents.find(ownership->second.agentId); + return agent == parent->second.agents.end() ? nullptr : &agent->second; } -void PresentationModel::correlateAgentThread(const std::string &childThreadId) { +ItemPresentation * +PresentationModel::agentSourceItem(ThreadPresentation &parent, + const AgentPresentation &agent) { + const auto turn = parent.turns.find(agent.ownerTurnId); + if (turn == parent.turns.end()) + return nullptr; + const auto item = turn->second.items.find(agent.itemId); + return item == turn->second.items.end() ? nullptr : &item->second; +} + +void PresentationModel::setAgentStatus(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &status) { + agent.status = status; + agent.raw["status"] = status; + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw["status"] = status; +} + +void PresentationModel::setAgentResult(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &resultText) { + agent.raw["resultText"] = resultText; + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw["resultText"] = resultText; +} + +void PresentationModel::clearAgentResult(ThreadPresentation &parent, + AgentPresentation &agent) { + agent.raw.erase("resultText"); + if (ItemPresentation *item = agentSourceItem(parent, agent)) + item->raw.erase("resultText"); +} + +void PresentationModel::updateOwningAgentStatus( + const std::string &childThreadId, const std::string &status) { + if (status.empty()) + return; + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; + const auto agent = parent->second.agents.find(ownership->second.agentId); + if (agent == parent->second.agents.end()) + return; + if (isTerminalTurnStatus(agent->second.status) && isActiveStatus(status)) { + setAgentStatus(parent->second, agent->second, agent->second.status); + return; + } + setAgentStatus(parent->second, agent->second, status); +} + +void PresentationModel::updateOwningAgentResult( + const std::string &childThreadId, const std::string &resultText) { + if (resultText.empty()) + return; + const auto ownership = childOwnerships.find(childThreadId); + if (ownership == childOwnerships.end()) + return; + const auto parent = threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; + const auto agent = parent->second.agents.find(ownership->second.agentId); + if (agent != parent->second.agents.end()) + setAgentResult(parent->second, agent->second, resultText); +} + +void PresentationModel::synchronizeOwningAgent( + const std::string &childThreadId, bool clearMissingResult) { + AgentPresentation *agent = owningAgent(childThreadId); const auto child = threads.find(childThreadId); - if (child == threads.end()) + if (!agent || child == threads.end()) return; - std::string childStatus = child->second.status; + // A thread-level lifecycle is authoritative. Turn status is only a fallback + // for incremental payloads that do not carry the thread lifecycle; an old + // or interrupted turn must not make an idle child appear active. + std::string childStatus = + child->second.status == "notLoaded" ? std::string{} + : child->second.status; std::string resultText; - for (const std::string &turnId : child->second.turnOrder) { - const auto turn = child->second.turns.find(turnId); + for (auto turnId = child->second.turnOrder.rbegin(); + turnId != child->second.turnOrder.rend(); ++turnId) { + const auto turn = child->second.turns.find(*turnId); if (turn == child->second.turns.end()) continue; - if (!turn->second.status.empty()) + if (childStatus.empty() && !turn->second.status.empty()) childStatus = turn->second.status; - for (const std::string &itemId : turn->second.itemOrder) { - const auto item = turn->second.items.find(itemId); + for (auto itemId = turn->second.itemOrder.rbegin(); + itemId != turn->second.itemOrder.rend(); ++itemId) { + const auto item = turn->second.items.find(*itemId); if (item == turn->second.items.end() || stringValue(item->second.raw, "type") != "agentMessage") continue; - const std::string text = stringValue(item->second.raw, "text"); - if (!text.empty()) - resultText = text; - } - } - - for (auto &[ownerId, owner] : threads) { - static_cast(ownerId); - for (auto &[agentId, agent] : owner.agents) { - static_cast(agentId); - if (agent.childThreadId != childThreadId) - continue; - if (!childStatus.empty()) - agent.status = childStatus; + resultText = stringValue(item->second.raw, "text"); if (!resultText.empty()) - agent.raw["resultText"] = resultText; - agent.raw["childThreadId"] = childThreadId; + break; } + if (!resultText.empty() && !childStatus.empty()) + break; } + updateOwningAgentStatus(childThreadId, childStatus); + const auto ownership = childOwnerships.find(childThreadId); + const auto parent = ownership == childOwnerships.end() + ? threads.end() + : threads.find(ownership->second.parentThreadId); + if (parent == threads.end()) + return; + if (resultText.empty() && clearMissingResult) + clearAgentResult(parent->second, *agent); + else if (!resultText.empty()) + setAgentResult(parent->second, *agent, resultText); } void PresentationModel::removeThread(const std::string &threadId) { - threads.erase(threadId); + const auto thread = threads.find(threadId); + if (thread == threads.end()) + return; + const std::vector children = thread->second.childThreadOrder; + const auto root = std::find(orderedThreads.begin(), orderedThreads.end(), + threadId); + const std::size_t rootIndex = + root == orderedThreads.end() + ? orderedThreads.size() + : static_cast(std::distance(orderedThreads.begin(), root)); + for (const std::string &childThreadId : children) + releaseChildOwnership(childThreadId, false); + releaseChildOwnership(threadId, false); + threads.erase(thread); std::erase(orderedThreads, threadId); + std::size_t insertion = std::min(rootIndex, orderedThreads.size()); + for (const std::string &childThreadId : children) { + if (!threads.contains(childThreadId) || + childOwnerships.contains(childThreadId)) + continue; + orderedThreads.insert(orderedThreads.begin() + + static_cast(insertion), + childThreadId); + ++insertion; + } } void PresentationModel::clearProviderState() { orderedThreads.clear(); threads.clear(); + childOwnerships.clear(); pendingRequests.clear(); models = nlohmann::json::array(); retainedGlobalDomains.clear(); diff --git a/src/codex/PresentationModel.h b/src/codex/PresentationModel.h index 551dd08..d178313 100644 --- a/src/codex/PresentationModel.h +++ b/src/codex/PresentationModel.h @@ -38,6 +38,13 @@ struct AgentPresentation { nlohmann::json raw = nlohmann::json::object(); }; +struct ChildThreadOwnership { + std::string parentThreadId; + std::string agentId; + + bool operator==(const ChildThreadOwnership &) const = default; +}; + struct ThreadPresentation { std::string id; std::string title; @@ -57,8 +64,8 @@ struct ThreadPresentation { std::uint64_t settingsRevision = 0; std::vector agentOrder; std::unordered_map agents; + std::vector childThreadOrder; bool archived = false; - bool agentThread = false; }; struct PendingRequestPresentation { @@ -98,6 +105,8 @@ class PresentationModel final { [[nodiscard]] const std::vector &threadOrder() const noexcept; [[nodiscard]] const ThreadPresentation * thread(const std::string &threadId) const noexcept; + [[nodiscard]] const ChildThreadOwnership * + childOwnership(const std::string &childThreadId) const noexcept; [[nodiscard]] std::optional activeTurnId(const std::string &threadId) const; [[nodiscard]] std::size_t pendingRequestCount() const noexcept; @@ -125,7 +134,25 @@ class PresentationModel final { void upsertAgentActivity(ThreadPresentation &owner, const nlohmann::json &scope, const nlohmann::json &activity, bool live = true); - void correlateAgentThread(const std::string &childThreadId); + void assignChildOwnership(ThreadPresentation &parent, + AgentPresentation &agent, + const std::string &childThreadId, bool live); + void releaseChildOwnership(const std::string &childThreadId, + bool promoteToRoot); + void synchronizeOwningAgent(const std::string &childThreadId, + bool clearMissingResult = false); + AgentPresentation *owningAgent(const std::string &childThreadId); + ItemPresentation *agentSourceItem(ThreadPresentation &parent, + const AgentPresentation &agent); + void setAgentStatus(ThreadPresentation &parent, AgentPresentation &agent, + const std::string &status); + void setAgentResult(ThreadPresentation &parent, AgentPresentation &agent, + const std::string &resultText); + void clearAgentResult(ThreadPresentation &parent, AgentPresentation &agent); + void updateOwningAgentStatus(const std::string &childThreadId, + const std::string &status); + void updateOwningAgentResult(const std::string &childThreadId, + const std::string &resultText); void removeThread(const std::string &threadId); void clearProviderState(); void retainDomainEvent(const std::string &type, const nlohmann::json &data, @@ -137,6 +164,7 @@ class PresentationModel final { std::vector orderedThreads; std::unordered_map threads; + std::unordered_map childOwnerships; std::unordered_map pendingRequests; ConnectionPresentation connectionState; nlohmann::json models = nlohmann::json::array(); diff --git a/src/codex/ShellWidget.cpp b/src/codex/ShellWidget.cpp index 0419689..c26b983 100644 --- a/src/codex/ShellWidget.cpp +++ b/src/codex/ShellWidget.cpp @@ -208,7 +208,8 @@ struct ShellWidget::Impl final { void renderConversation(); void refreshSettings(); void refreshStatus(); - void hydrateHistoricalAgents(); + void hydrateHistoricalChildren(const std::string &parentThreadId, + bool retryFailed = false); void showNotice(QString message, bool error = true); void resetRuntimeForConnection(); @@ -216,6 +217,7 @@ struct ShellWidget::Impl final { void beginNewThread(); void readThread(const std::string &threadId, bool forced = false); void ensureThreadHydrated(const std::string &threadId); + void hydrateThreadForSelection(const std::string &threadId); void ensureThreadSettingsHydrated(const std::string &threadId); void resumeThreadForSettings(const std::string &threadId); void renameThread(const std::string &threadId); @@ -576,7 +578,11 @@ void ShellWidget::Impl::handleEvent(const nlohmann::json &event) { ensureThreadSettingsHydrated(selectedThreadId); } - hydrateHistoricalAgents(); + if (!staleReadResult && kind == "result" && action == "thread.read" && + event.value("ok", false)) + hydrateHistoricalChildren(eventThreadId); + else if (kind == "event" && type == "agents.activity.upsert") + hydrateHistoricalChildren(eventThreadId); scheduleRender(); } @@ -820,19 +826,27 @@ void ShellWidget::Impl::refreshStatus() { middleRegion->composer().setSettingsEnabled(canSubmit && !snapshot.active); } -void ShellWidget::Impl::hydrateHistoricalAgents() { - const ThreadPresentation *thread = model.thread(selectedThreadId); +void ShellWidget::Impl::hydrateHistoricalChildren( + const std::string &parentThreadId, bool retryFailed) { + const ThreadPresentation *thread = model.thread(parentThreadId); if (!thread) return; - for (const std::string &id : thread->agentOrder) { - const auto agent = thread->agents.find(id); - if (agent == thread->agents.end() || agent->second.childThreadId.empty() || - agent->second.status != "started") + for (const std::string &childThreadId : thread->childThreadOrder) { + const ChildThreadOwnership *ownership = + model.childOwnership(childThreadId); + if (!ownership || ownership->parentThreadId != parentThreadId) + continue; + const auto agent = thread->agents.find(ownership->agentId); + if (agent == thread->agents.end() || + !isActiveStatus(agent->second.status)) continue; - // Historical child hydration shares the same monotonic read boundary as - // user-selected threads, so a pre-reconnect result cannot replace newer - // child/agent presentation state. - readThread(agent->second.childThreadId); + const auto runtime = runtimeByThread.find(childThreadId); + const bool failed = runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed; + // Background activity never retries a failed read. Explicit navigation + // supplies a new bounded retry boundary without creating a retry loop. + if (!failed || retryFailed) + readThread(childThreadId, failed); } } @@ -840,8 +854,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { if (threadId.empty()) return; if (threadId == selectedThreadId) { - ensureThreadHydrated(threadId); - ensureThreadSettingsHydrated(threadId); + hydrateThreadForSelection(threadId); return; } selectedThreadId = std::move(threadId); @@ -850,8 +863,7 @@ void ShellWidget::Impl::selectThread(std::string threadId) { newThreadName.clear(); newThreadWorkspace.clear(); historyWindows.try_emplace(selectedThreadId); - ensureThreadHydrated(selectedThreadId); - ensureThreadSettingsHydrated(selectedThreadId); + hydrateThreadForSelection(selectedThreadId); render(); } @@ -1005,6 +1017,18 @@ void ShellWidget::Impl::ensureThreadHydrated(const std::string &threadId) { readThread(threadId); } +void ShellWidget::Impl::hydrateThreadForSelection( + const std::string &threadId) { + const auto runtime = runtimeByThread.find(threadId); + if (runtime != runtimeByThread.end() && + runtime->second.hydration == Hydration::Failed) + readThread(threadId, true); + else + ensureThreadHydrated(threadId); + ensureThreadSettingsHydrated(threadId); + hydrateHistoricalChildren(threadId, true); +} + void ShellWidget::Impl::renameThread(const std::string &threadId) { const ThreadPresentation *thread = model.thread(threadId); if (!thread) diff --git a/src/codex/middle/InspectorPane.cpp b/src/codex/middle/InspectorPane.cpp index 647aff6..9fe7c17 100644 --- a/src/codex/middle/InspectorPane.cpp +++ b/src/codex/middle/InspectorPane.cpp @@ -184,33 +184,48 @@ QFrame *InspectorPane::agentFrame(const AgentSnapshot &agent) { auto *layout = new QVBoxLayout(frame); layout->setContentsMargins(12, 10, 12, 10); layout->setSpacing(6); - const QString title = - !agent.childThreadId.empty() - ? QStringLiteral("Subagent") - : agent.tool.empty() ? QStringLiteral("Agent activity") - : QStringLiteral("Agent %1").arg(text(agent.tool)); - layout->addWidget(makeLabel(title, "title")); - QStringList metadata; - for (const std::string *value : - {&agent.agentPath, &agent.tool, &agent.model, &agent.reasoningEffort}) { - if (!value->empty()) - metadata << text(*value); + const QString agentPath = text(agent.agentPath); + const QStringList pathParts = agentPath.split('/', Qt::SkipEmptyParts); + QString agentName; + if (!pathParts.isEmpty()) + agentName = pathParts.back(); + else if (!agent.tool.empty()) + agentName = text(agent.tool); + auto *heading = new QHBoxLayout; + heading->setContentsMargins(0, 0, 0, 0); + heading->setSpacing(6); + auto *titleLabel = makeLabel(QStringLiteral("Agent"), "title"); + titleLabel->setObjectName(QStringLiteral("agentTitle")); + titleLabel->setWordWrap(false); + titleLabel->setContentsMargins(0, 0, 0, 1); + titleLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + heading->addWidget(titleLabel, 0, Qt::AlignBaseline); + if (!agentName.isEmpty()) { + auto *nameLabel = makeLabel(agentName, "code"); + nameLabel->setObjectName(QStringLiteral("agentName")); + nameLabel->setWordWrap(false); + // The fixed-width font's descent sits one pixel below the proportional + // labels. Preserve their visual baseline without changing its font. + nameLabel->setContentsMargins(0, 0, 0, 1); + nameLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); + if (!agentPath.isEmpty()) + nameLabel->setToolTip(agentPath); + heading->addWidget(nameLabel, 0, Qt::AlignBottom); } - auto *metadataRow = new QHBoxLayout; - metadataRow->setContentsMargins(0, 0, 0, 0); - metadataRow->setSpacing(6); + heading->addStretch(); auto *status = statusLabel(agent.status); status->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - metadataRow->addWidget(status); - if (!metadata.isEmpty()) { - auto *details = makeLabel( - QStringLiteral("| ") + metadata.join(QStringLiteral(" | ")), - "meta"); - details->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - metadataRow->addWidget(details); + heading->addWidget(status, 0, Qt::AlignBaseline); + layout->addLayout(heading); + QStringList metadata; + if (!agent.tool.empty() && !agentPath.isEmpty()) + metadata << text(agent.tool); + for (const std::string *value : {&agent.model, &agent.reasoningEffort}) { + if (!value->empty()) + metadata << text(*value); } - metadataRow->addStretch(); - layout->addLayout(metadataRow); + if (!metadata.isEmpty()) + layout->addWidget(makeLabel(metadata.join(QStringLiteral(" ยท ")), "meta")); if (!agent.prompt.empty()) layout->addWidget(makeLabel(text(agent.prompt))); if (!agent.resultText.empty()) { @@ -287,6 +302,8 @@ InspectorPane::InspectorPane(QWidget *parent) : QFrame(parent) { scroll->setFrameShape(QFrame::NoFrame); scroll->setWidgetResizable(true); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scroll->verticalScrollBar()->setProperty("kind", "infoViewer"); scroll->setWidget(content); return scroll; }; diff --git a/src/codex/middle/ThreadPane.cpp b/src/codex/middle/ThreadPane.cpp index 2fb1768..5bc6296 100644 --- a/src/codex/middle/ThreadPane.cpp +++ b/src/codex/middle/ThreadPane.cpp @@ -11,10 +11,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -28,8 +30,19 @@ namespace codexui::codex::middle { namespace { constexpr int ContextMenuRole = Qt::UserRole + 1; +constexpr int DepthRole = Qt::UserRole + 2; +constexpr int HasChildrenRole = Qt::UserRole + 3; +constexpr int ExpandedRole = Qt::UserRole + 4; +constexpr int ParentIdRole = Qt::UserRole + 5; +constexpr int ChildIndent = 16; +constexpr int DisclosureWidth = 16; +constexpr int DisclosureExtent = 24; class ThreadListWidget final : public QListWidget { +public: + std::function toggleExpansion; + std::function navigateHierarchy; + protected: QItemSelectionModel::SelectionFlags selectionCommand(const QModelIndex &index, @@ -42,6 +55,42 @@ class ThreadListWidget final : public QListWidget { } return QListWidget::selectionCommand(index, event); } + + void mousePressEvent(QMouseEvent *event) override { + QListWidgetItem *item = itemAt(event->position().toPoint()); + if (event->button() == Qt::LeftButton && item && + item->data(HasChildrenRole).toBool()) { + QWidget *row = itemWidget(item); + QWidget *indicator = + row ? row->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + const QRect indicatorRect = + indicator + ? QRect(indicator->mapTo(viewport(), QPoint()), indicator->size()) + .adjusted(-(DisclosureExtent - DisclosureWidth) / 2, 0, + (DisclosureExtent - DisclosureWidth) / 2, 0) + : QRect{}; + if (indicatorRect.contains(event->position().toPoint())) { + if (toggleExpansion) + toggleExpansion( + item->data(Qt::UserRole).toString().toStdString()); + event->accept(); + return; + } + } + QListWidget::mousePressEvent(event); + } + + void keyPressEvent(QKeyEvent *event) override { + if ((event->key() == Qt::Key_Left || event->key() == Qt::Key_Right) && + navigateHierarchy) { + navigateHierarchy(event->key()); + event->accept(); + return; + } + QListWidget::keyPressEvent(event); + } }; class ThreadItemDelegate final : public QStyledItemDelegate { @@ -57,6 +106,48 @@ class ThreadItemDelegate final : public QStyledItemDelegate { } }; +class ThreadDisclosureIndicator final : public QWidget { +public: + explicit ThreadDisclosureIndicator(QWidget *parent = nullptr) + : QWidget(parent) { + setObjectName(QStringLiteral("threadExpansionIndicator")); + setFixedSize(DisclosureWidth, DisclosureExtent); + setAttribute(Qt::WA_TransparentForMouseEvents); + } + + void setState(bool hasChildren, bool expanded) { + if (hasChildren_ == hasChildren && expanded_ == expanded) + return; + hasChildren_ = hasChildren; + expanded_ = expanded; + const QString action = !hasChildren + ? QString{} + : expanded ? QStringLiteral("Collapse branch") + : QStringLiteral("Expand branch"); + setAccessibleName(action); + setToolTip(action); + setProperty("chevronDirection", + !hasChildren ? QString{} + : expanded ? QStringLiteral("down") + : QStringLiteral("right")); + update(); + } + +protected: + void paintEvent(QPaintEvent *event) override { + static_cast(event); + if (!hasChildren_) + return; + UiStyle::drawChevron(this, rect().adjusted(3, 3, -3, -3), isEnabled(), + false, expanded_ ? UiStyle::ChevronDirection::Down + : UiStyle::ChevronDirection::Right); + } + +private: + bool hasChildren_ = false; + bool expanded_ = false; +}; + QString text(std::string_view value) { return QString::fromUtf8(value.data(), static_cast(value.size())); } @@ -81,10 +172,17 @@ QFrame *statusDot() { void updateRow(QWidget *row, const std::string &threadId, const std::string &threadTitle, - const std::string &threadStatus, std::size_t requestCount) { + const std::string &threadStatus, std::size_t requestCount, + std::size_t depth, bool hasChildren, bool expanded) { auto *title = row->findChild(QStringLiteral("threadTitle")); auto *status = row->findChild(QStringLiteral("threadStatus")); auto *dot = row->findChild(QStringLiteral("threadStatusDot")); + auto *indent = row->findChild(QStringLiteral("threadIndent")); + auto *indicator = static_cast( + row->findChild( + QStringLiteral("threadExpansionIndicator"))); + indent->setFixedWidth(static_cast(depth) * ChildIndent); + indicator->setState(hasChildren, expanded); QString titleText = text(threadTitle); if (titleText.isEmpty()) titleText = text(threadId.substr(0, 12)); @@ -117,9 +215,18 @@ QWidget *createRow() { row->setAttribute(Qt::WA_TransparentForMouseEvents); row->setStyleSheet(QStringLiteral("background:transparent;")); auto *layout = new QHBoxLayout(row); - layout->setContentsMargins(5, 2, 5, 2); - layout->setSpacing(8); - layout->addWidget(statusDot()); + layout->setContentsMargins(0, 2, 0, 2); + layout->setSpacing(0); + auto *indent = new QWidget; + indent->setObjectName(QStringLiteral("threadIndent")); + indent->setFixedWidth(0); + indent->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); + layout->addWidget(indent); + auto *indicator = new ThreadDisclosureIndicator; + layout->addWidget(indicator, 0, Qt::AlignVCenter); + layout->addSpacing(2); + layout->addWidget(statusDot(), 0, Qt::AlignVCenter); + layout->addSpacing(8); auto *copy = new QVBoxLayout; copy->setContentsMargins(0, 0, 0, 0); copy->setSpacing(1); @@ -234,6 +341,11 @@ ThreadPane::ThreadPane(QWidget *parent) : QFrame(parent) { layout->addLayout(toolbar); list = new ThreadListWidget; + auto *threadList = static_cast(list); + threadList->toggleExpansion = + [this](const std::string &id) { toggleExpanded(id); }; + threadList->navigateHierarchy = + [this](int key) { navigateHierarchy(key); }; list->setObjectName(QStringLiteral("threadList")); list->setItemDelegate(new ThreadItemDelegate(list)); list->setSelectionMode(QAbstractItemView::SingleSelection); @@ -307,8 +419,8 @@ void ThreadPane::updateSortButton() { : QStringLiteral("Recent"))); } -void ThreadPane::sortVisibleThreads(std::vector &ids, - const PresentationModel &model) const { +void ThreadPane::sortRootThreads(std::vector &ids, + const PresentationModel &model) const { QCollator collator(QLocale::system().language() == QLocale::C ? QLocale(QLocale::English) : QLocale::system()); @@ -348,6 +460,75 @@ void ThreadPane::sortVisibleThreads(std::vector &ids, }); } +void ThreadPane::appendVisibleThread( + ThreadPaneSnapshot &snapshot, const PresentationModel &model, + const std::unordered_map &pendingByThread, + const std::string &threadId, const std::string &parentId, + std::size_t depth, std::unordered_set &visited) const { + if (!visited.insert(threadId).second) + return; + const ThreadPresentation *thread = model.thread(threadId); + if (!thread) + return; + const bool hasChildren = std::ranges::any_of( + thread->childThreadOrder, + [&model](const std::string &id) { return model.thread(id) != nullptr; }); + const bool expanded = hasChildren && expandedThreads.contains(threadId); + const auto pending = pendingByThread.find(threadId); + snapshot.rows.push_back( + {threadId, thread->title, thread->cwd, thread->status, parentId, + pending == pendingByThread.end() ? std::size_t{} : pending->second, + depth, hasChildren, expanded}); + if (!expanded) + return; + for (const std::string &childThreadId : thread->childThreadOrder) + appendVisibleThread(snapshot, model, pendingByThread, childThreadId, + threadId, depth + 1, visited); +} + +void ThreadPane::toggleExpanded(const std::string &threadId) { + if (expandedThreads.contains(threadId)) + expandedThreads.erase(threadId); + else + expandedThreads.insert(threadId); + visibleSnapshot.reset(); + if (currentModel) + refresh(*currentModel, projectedSelectedThreadId); +} + +void ThreadPane::navigateHierarchy(int key) { + QListWidgetItem *current = list->currentItem(); + if (!current) + return; + const std::string id = + current->data(Qt::UserRole).toString().toStdString(); + const bool hasChildren = current->data(HasChildrenRole).toBool(); + const bool expanded = current->data(ExpandedRole).toBool(); + if (key == Qt::Key_Right && hasChildren) { + if (!expanded) { + toggleExpanded(id); + return; + } + const int nextRow = list->row(current) + 1; + if (nextRow < list->count() && + list->item(nextRow)->data(ParentIdRole).toString().toStdString() == id) + list->setCurrentRow(nextRow); + return; + } + if (key != Qt::Key_Left) + return; + if (hasChildren && expanded) { + toggleExpanded(id); + return; + } + const QString parentId = current->data(ParentIdRole).toString(); + if (parentId.isEmpty()) + return; + const auto parent = rows.find(parentId.toStdString()); + if (parent != rows.end()) + list->setCurrentItem(parent->second); +} + void ThreadPane::setContextHighlight(const std::string &threadId, bool highlighted) { const auto found = rows.find(threadId); @@ -358,25 +539,27 @@ void ThreadPane::setContextHighlight(const std::string &threadId, void ThreadPane::refresh(const PresentationModel &model, const std::string &selectedThreadId) { + const bool selectionChanged = selectedThreadId != projectedSelectedThreadId; currentModel = &model; projectedSelectedThreadId = selectedThreadId; - const std::vector &authoritativeOrder = model.threadOrder(); - const std::unordered_set authoritativeIds( - authoritativeOrder.begin(), authoritativeOrder.end()); - std::erase_if(retainedVisibleThreads, [&](const std::string &id) { - return !model.thread(id) || authoritativeIds.contains(id); - }); - if (!selectedThreadId.empty() && model.thread(selectedThreadId) && - !authoritativeIds.contains(selectedThreadId) && - std::find(retainedVisibleThreads.begin(), retainedVisibleThreads.end(), - selectedThreadId) == retainedVisibleThreads.end()) { - retainedVisibleThreads.insert(retainedVisibleThreads.begin(), - selectedThreadId); + if (selectionChanged) { + std::unordered_set visited; + std::string descendantId = selectedThreadId; + while (!descendantId.empty() && visited.insert(descendantId).second) { + const ChildThreadOwnership *ownership = + model.childOwnership(descendantId); + if (!ownership) + break; + expandedThreads.insert(ownership->parentThreadId); + descendantId = ownership->parentThreadId; + } } - std::vector visibleOrder = retainedVisibleThreads; - visibleOrder.insert(visibleOrder.end(), authoritativeOrder.begin(), - authoritativeOrder.end()); - sortVisibleThreads(visibleOrder, model); + std::erase_if(expandedThreads, [&model](const std::string &id) { + const ThreadPresentation *thread = model.thread(id); + return !thread || thread->childThreadOrder.empty(); + }); + std::vector rootOrder = model.threadOrder(); + sortRootThreads(rootOrder, model); std::unordered_map pendingByThread; pendingByThread.reserve(model.pendingRequestCount()); @@ -385,20 +568,11 @@ void ThreadPane::refresh(const PresentationModel &model, static_cast(requestId); ++pendingByThread[request.threadId]; } - const auto pendingCount = [&pendingByThread](const std::string &id) { - const auto found = pendingByThread.find(id); - return found == pendingByThread.end() ? std::size_t{} : found->second; - }; - ThreadPaneSnapshot next{selectedThreadId, sortCriterion, {}}; - next.rows.reserve(visibleOrder.size()); - for (const std::string &id : visibleOrder) { - const ThreadPresentation *thread = model.thread(id); - if (!thread) - continue; - next.rows.push_back({id, thread->title, thread->cwd, thread->status, - pendingCount(id)}); - } + std::unordered_set visited; + visited.reserve(rootOrder.size()); + for (const std::string &id : rootOrder) + appendVisibleThread(next, model, pendingByThread, id, {}, 0, visited); if (visibleSnapshot && *visibleSnapshot == next) return; visibleSnapshot = std::move(next); @@ -463,8 +637,12 @@ void ThreadPane::refresh(const PresentationModel &model, } QListWidgetItem *item = found->second; item->setToolTip(text(row.cwd)); + item->setData(DepthRole, static_cast(row.depth)); + item->setData(HasChildrenRole, row.hasChildren); + item->setData(ExpandedRole, row.expanded); + item->setData(ParentIdRole, text(row.parentId)); updateRow(list->itemWidget(item), row.id, row.title, row.status, - row.pending); + row.pending, row.depth, row.hasChildren, row.expanded); if (row.id == contextThreadId) setContextHighlight(row.id, true); if (row.id == snapshot.selectedThreadId) diff --git a/src/codex/middle/ThreadPane.h b/src/codex/middle/ThreadPane.h index f17db42..197d9b0 100644 --- a/src/codex/middle/ThreadPane.h +++ b/src/codex/middle/ThreadPane.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class QListWidget; @@ -53,7 +54,11 @@ class ThreadPane final : public QFrame { std::string title; std::string cwd; std::string status; + std::string parentId; std::size_t pending = 0; + std::size_t depth = 0; + bool hasChildren = false; + bool expanded = false; bool operator==(const ThreadRowSnapshot &) const = default; }; @@ -66,8 +71,15 @@ class ThreadPane final : public QFrame { }; void updateSortButton(); - void sortVisibleThreads(std::vector &ids, - const PresentationModel &model) const; + void sortRootThreads(std::vector &ids, + const PresentationModel &model) const; + void appendVisibleThread( + ThreadPaneSnapshot &snapshot, const PresentationModel &model, + const std::unordered_map &pendingByThread, + const std::string &threadId, const std::string &parentId, + std::size_t depth, std::unordered_set &visited) const; + void toggleExpanded(const std::string &threadId); + void navigateHierarchy(int key); void setContextHighlight(const std::string &threadId, bool highlighted); void showContextMenu(const QPoint &position); @@ -77,7 +89,7 @@ class ThreadPane final : public QFrame { QToolButton *sortButton = nullptr; QListWidget *list = nullptr; std::unordered_map rows; - std::vector retainedVisibleThreads; + std::unordered_set expandedThreads; std::string projectedSelectedThreadId; std::string contextThreadId; QMenu *contextMenu = nullptr; diff --git a/src/codex/ui/UiStyle.cpp b/src/codex/ui/UiStyle.cpp index abe22ce..d9ed12c 100644 --- a/src/codex/ui/UiStyle.cpp +++ b/src/codex/ui/UiStyle.cpp @@ -103,6 +103,7 @@ QString applicationStyleSheet() { QLabel[kind="title"] { font-size: %2pt; font-weight: 600; } QLabel[kind="messagePhase"] { font-size: %2pt; font-weight: 400; } QLabel[kind="body"] { font-size: %2pt; } + QLabel[kind="code"] { font-family: monospace; font-size: %2pt; font-weight: 400; } QLabel[kind="meta"] { color: #667085; font-size: %1pt; } QLabel[kind="small"] { color: #667085; font-size: %1pt; } QLabel[tone="active"] { color: #285fca; } diff --git a/tests/codex/ApplicationLayoutTest.cpp b/tests/codex/ApplicationLayoutTest.cpp index 85452b7..b5986c9 100644 --- a/tests/codex/ApplicationLayoutTest.cpp +++ b/tests/codex/ApplicationLayoutTest.cpp @@ -22,12 +22,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -167,6 +169,17 @@ std::vector threadOrder(const ThreadPane &pane) { return result; } +QListWidgetItem *threadItem(QListWidget *list, std::string_view id) { + if (!list) + return nullptr; + for (int row = 0; row < list->count(); ++row) { + QListWidgetItem *item = list->item(row); + if (item && item->data(Qt::UserRole).toString().toStdString() == id) + return item; + } + return nullptr; +} + bool testOverlayGeometryAndRegionRouting() { MiddleRegionWidget region; bool result = @@ -456,7 +469,8 @@ bool testThreadSelectionProjection() { selected->data(Qt::UserRole).toString() == QStringLiteral("thread-b") && pane.visiblySelectedThreadId() == "thread-b", - "a hydrated selected child thread remains visible outside root ordering"); + "navigating to a nested thread reveals and selects it beneath its " + "parent"); QWidget *row = selected && list ? list->itemWidget(selected) : nullptr; auto *title = row ? row->findChild(QStringLiteral("threadTitle")) : nullptr; @@ -468,11 +482,28 @@ bool testThreadSelectionProjection() { auto *rowLayout = row ? qobject_cast(row->layout()) : nullptr; auto *sortButton = pane.findChild(QStringLiteral("threadSortButton")); + QListWidgetItem *parentItem = threadItem(list, "thread-a"); + QWidget *parentRow = + list && parentItem ? list->itemWidget(parentItem) : nullptr; + QWidget *disclosure = + parentRow ? parentRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + auto *parentDot = + parentRow ? parentRow->findChild( + QStringLiteral("threadStatusDot")) + : nullptr; result &= expect( selected && selected->sizeHint().height() == 54 && rowLayout && - rowLayout->contentsMargins() == QMargins(5, 2, 5, 2) && - rowLayout->spacing() == 8 && title && status && dot && + rowLayout->contentsMargins() == QMargins(0, 2, 0, 2) && + rowLayout->spacing() == 0 && title && status && dot && dot->size() == QSize(10, 10) && rowLayout->indexOf(dot) >= 0 && + disclosure && disclosure->size() == QSize(16, 24) && + disclosure->geometry().left() == 0 && parentDot && + parentDot->geometry().left() - disclosure->geometry().right() - 1 == + 2 && + disclosure->property("chevronDirection").toString() == + QStringLiteral("down") && sortButton && dynamic_cast(sortButton) && sortButton->property("codexChevron").toBool() && @@ -481,19 +512,19 @@ bool testThreadSelectionProjection() { status->property("tone").toString() == QStringLiteral("active") && title->textInteractionFlags().testFlag(Qt::TextSelectableByMouse) && status->textInteractionFlags().testFlag(Qt::TextSelectableByMouse), - "thread cards keep their status dot and shared chevron styling inside " - "the UI contract"); + "thread cards keep their status dot and canonical disclosure styling " + "inside the UI contract"); pane.refresh(model, "thread-a"); - bool retainedSupplement = false; + bool childPresent = false; if (list) { for (int index = 0; index < list->count(); ++index) { - retainedSupplement |= list->item(index)->data(Qt::UserRole).toString() == - QStringLiteral("thread-b"); + childPresent |= list->item(index)->data(Qt::UserRole).toString() == + QStringLiteral("thread-b"); } } result &= - expect(retainedSupplement && pane.visiblySelectedThreadId() == "thread-a", - "a previously selected retained thread survives navigation"); + expect(childPresent && pane.visiblySelectedThreadId() == "thread-a", + "a child thread remains nested while its parent is selected"); model.applyEvent(presentation::event( 4, 1, "thread.removed", nlohmann::json::object(), presentation::Authority::Remove, {{"threadId", "thread-b"}})); @@ -587,6 +618,210 @@ bool testIncrementalThreadSettings() { return result; } +bool testThreadHierarchyExpansionAndNavigation() { + PresentationModel model; + model.applyEvent(presentation::result( + 1, 1, "threads.list", "hierarchy-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "root-z"}, {"name", "Z root"}}, + {{"id", "root-a"}, {"name", "A root"}}})}}, + presentation::Authority::Merge)); + const auto addChild = [&model](std::uint64_t sequence, + const std::string &parent, + const std::string &child, + const std::string &title) { + model.applyEvent(presentation::event( + sequence, 1, "thread.upsert", + {{"thread", {{"id", child}, {"name", title}}}}, + presentation::Authority::Merge, {{"threadId", child}})); + model.applyEvent(presentation::event( + sequence + 1, 1, "agents.activity.upsert", + {{"activity", + {{"id", "spawn-" + child}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", child}}}}, + presentation::Authority::Merge, + {{"threadId", parent}, + {"turnId", "turn-" + parent}, + {"itemId", "spawn-" + child}})); + }; + addChild(2, "root-a", "child-z", "Z child"); + addChild(4, "root-a", "child-a", "A child"); + addChild(6, "child-z", "grandchild", "Nested child"); + model.applyEvent(presentation::event( + 8, 1, "pending-request.upsert", + {{"requestId", "nested-request"}, + {"category", "userInput"}, + {"request", {{"message", "Review nested work"}}}}, + presentation::Authority::Merge, + {{"threadId", "grandchild"}, {"requestId", "nested-request"}})); + + ThreadPane pane; + pane.setSortCriterion(ThreadPane::SortCriterion::Alphanumeric); + std::string selectedThread; + int selections = 0; + ThreadPane::Actions actions; + actions.select = [&](const std::string &id) { + selectedThread = id; + ++selections; + pane.refresh(model, selectedThread); + }; + pane.setActions(std::move(actions)); + pane.resize(340, 620); + pane.show(); + pane.refresh(model, selectedThread); + spin(20); + + auto *list = pane.findChild(QStringLiteral("threadList")); + QListWidgetItem *rootA = threadItem(list, "root-a"); + QWidget *rootRow = list && rootA ? list->itemWidget(rootA) : nullptr; + QWidget *rootDisclosure = + rootRow ? rootRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + bool result = expect( + list && threadOrder(pane) == + std::vector{"root-a", "root-z"} && + rootA && rootA->data(Qt::UserRole + 2).toInt() == 0 && + rootDisclosure && rootDisclosure->size() == QSize(16, 24) && + rootDisclosure->property("chevronDirection").toString() == + QStringLiteral("right") && + pane.visiblySelectedThreadId().empty(), + "thread branches default to a canonical collapsed disclosure without " + "selecting a hidden descendant"); + if (!list || !rootA) + return false; + + const auto clickExpansion = [list](QListWidgetItem *item) { + QWidget *row = item ? list->itemWidget(item) : nullptr; + QWidget *indicator = + row ? row->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + const QPoint position = + indicator + ? indicator->mapTo(list->viewport(), indicator->rect().center()) + : QPoint{}; + QMouseEvent press(QEvent::MouseButtonPress, position, + list->viewport()->mapToGlobal(position), Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + QApplication::sendEvent(list->viewport(), &press); + spin(); + }; + + clickExpansion(rootA); + QListWidgetItem *childZ = threadItem(list, "child-z"); + QListWidgetItem *childA = threadItem(list, "child-a"); + rootA = threadItem(list, "root-a"); + rootRow = rootA ? list->itemWidget(rootA) : nullptr; + rootDisclosure = + rootRow ? rootRow->findChild( + QStringLiteral("threadExpansionIndicator")) + : nullptr; + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + childZ && childZ->data(Qt::UserRole + 2).toInt() == 1 && childA && + rootDisclosure && + rootDisclosure->property("chevronDirection").toString() == + QStringLiteral("down") && + pane.visiblySelectedThreadId().empty() && selections == 0, + "expanding a root reveals its ordered children and updates the " + "canonical disclosure"); + if (!childZ) + return false; + + selectedThread = "grandchild"; + pane.refresh(model, selectedThread); + spin(); + QListWidgetItem *grandchild = threadItem(list, "grandchild"); + QWidget *grandchildRow = + list && grandchild ? list->itemWidget(grandchild) : nullptr; + QLabel *grandchildTitle = grandchildRow + ? grandchildRow->findChild( + QStringLiteral("threadTitle")) + : nullptr; + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + grandchild && grandchild->data(Qt::UserRole + 2).toInt() == 2 && + grandchildTitle && grandchildTitle->text().startsWith("! ") && + pane.visiblySelectedThreadId() == "grandchild" && selections == 0, + "nested navigation expands only its ancestor path and restores nesting, " + "requests, and selection"); + if (!grandchild) + return false; + + childZ = threadItem(list, "child-z"); + clickExpansion(childZ); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + pane.visiblySelectedThreadId().empty() && selections == 0, + "collapsing a nested parent hides descendants without selecting it"); + childZ = threadItem(list, "child-z"); + clickExpansion(childZ); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + pane.visiblySelectedThreadId() == "grandchild" && selections == 0, + "expanding restores arbitrary nesting and projected child selection"); + + rootA = threadItem(list, "root-a"); + clickExpansion(rootA); + result &= expect(threadOrder(pane) == + std::vector{"root-a", "root-z"} && + selections == 0, + "collapsing a root hides its complete descendant subtree"); + rootA = threadItem(list, "root-a"); + clickExpansion(rootA); + + childZ = threadItem(list, "child-z"); + list->setCurrentItem(childZ); + spin(); + const int beforeKeyboard = selections; + QKeyEvent rightToChild(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + QApplication::sendEvent(list, &rightToChild); + spin(); + result &= expect(selectedThread == "grandchild" && + pane.visiblySelectedThreadId() == "grandchild" && + selections == beforeKeyboard + 1, + "Right navigates from an expanded parent to its first child"); + QKeyEvent leftToParent(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + QApplication::sendEvent(list, &leftToParent); + spin(); + result &= expect(selectedThread == "child-z" && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Left navigates from a nested child to its parent"); + QKeyEvent leftCollapse(QEvent::KeyPress, Qt::Key_Left, Qt::NoModifier); + QApplication::sendEvent(list, &leftCollapse); + spin(); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "child-a", + "root-z"} && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Left collapses an expanded parent without changing selection"); + QKeyEvent rightExpand(QEvent::KeyPress, Qt::Key_Right, Qt::NoModifier); + QApplication::sendEvent(list, &rightExpand); + spin(); + result &= expect( + threadOrder(pane) == + std::vector{"root-a", "child-z", "grandchild", + "child-a", "root-z"} && + pane.visiblySelectedThreadId() == "child-z" && + selections == beforeKeyboard + 2, + "Right expands a collapsed parent without changing selection"); + return result; +} + bool testThreadAlphanumericSort() { PresentationModel model; model.applyEvent(presentation::result( @@ -835,6 +1070,17 @@ bool testInfoViewerLayout() { "Info exposes State and Protocol through choice navigation"); if (!infoStack || !protocolChoice || !protocol || !state || !statistics) return false; + const auto inspectorScrolls = inspector.findChildren(); + result &= expect( + inspectorScrolls.size() == 3 && + std::ranges::all_of(inspectorScrolls, [](QScrollArea *scroll) { + return scroll && scroll->property("kind") == "inspectorScroll" && + scroll->verticalScrollBarPolicy() == + Qt::ScrollBarAsNeeded && + scroll->verticalScrollBar()->property("kind") == + "infoViewer"; + }), + "Plan, Agents, and Requests use the canonical Inspector scrollbar"); protocolChoice->click(); inspector.appendProtocolFrame( {{"kind", "event"}, @@ -921,6 +1167,8 @@ bool testInfoViewerLayout() { } bool testInspectorDetailParity() { + const QString previousStyleSheet = qApp->styleSheet(); + qApp->setStyleSheet(codexui::UiStyle::applicationStyleSheet()); PresentationModel model; model.applyEvent(presentation::event( 1, 1, "thread.upsert", @@ -932,6 +1180,7 @@ bool testInspectorDetailParity() { {{"id", "agent-one"}, {"type", "subAgentActivity"}, {"status", "inProgress"}, + {"agentPath", "/root/lifecycle_review"}, {"agentThreadId", "child-thread"}, {"resultText", "No blocking Inspector findings.\n\n" @@ -961,6 +1210,7 @@ bool testInspectorDetailParity() { {{"id", "agent-two"}, {"type", "subAgentActivity"}, {"status", "completed"}, + {"agentPath", "/root/hierarchy_ui_review"}, {"agentThreadId", "child-thread-two"}, {"resultText", "No blocking Git snapshot issues found.\n\n" @@ -1008,10 +1258,28 @@ bool testInspectorDetailParity() { inspector.findChild(QStringLiteral("agentResult")); auto *agentFrame = agentResult ? qobject_cast(agentResult->parentWidget()) : nullptr; + auto *agentTitle = agentFrame + ? agentFrame->findChild( + QStringLiteral("agentTitle")) + : nullptr; + auto *agentName = agentFrame + ? agentFrame->findChild( + QStringLiteral("agentName")) + : nullptr; const int statusBottom = agentStatus && agentFrame ? agentStatus->mapTo(agentFrame, QPoint()).y() + agentStatus->height() : 0; + const int headingBottom = + std::max({agentTitle && agentFrame + ? agentTitle->mapTo(agentFrame, QPoint()).y() + + agentTitle->height() + : 0, + agentName && agentFrame + ? agentName->mapTo(agentFrame, QPoint()).y() + + agentName->height() + : 0, + statusBottom}); const int resultTop = agentResult && agentFrame ? agentResult->mapTo(agentFrame, QPoint()).y() : 0; @@ -1019,11 +1287,24 @@ bool testInspectorDetailParity() { agentResult ? agentResult->heightForWidth(agentResult->width()) : -1; result &= expect(agentStatus && agentStatus->width() > 0 && !agentStatus->visibleRegion().isEmpty(), - "agent status occupies its metadata row instead of " + "agent status occupies the card heading instead of " "leaving an invisible gap"); + result &= expect( + agentTitle && agentTitle->text() == QStringLiteral("Agent") && + agentTitle->property("kind").toString() == QStringLiteral("title") && + agentTitle->contentsMargins().bottom() == 1 && agentName && + agentName->text() == QStringLiteral("lifecycle_review") && + agentName->property("kind").toString() == QStringLiteral("code") && + agentName->toolTip() == QStringLiteral("/root/lifecycle_review") && + agentStatus && + agentStatus->geometry().left() > agentName->geometry().left() && + !hasLabelContaining(inspector, + QStringLiteral("/root/lifecycle_review")), + "agent cards show a regular monospace identity and right-aligned status " + "while retaining the full path as a tooltip"); result &= expect( agentFrame && agentFrame->layout() && agentResult && - resultTop - statusBottom <= agentFrame->layout()->spacing() && + resultTop - headingBottom <= agentFrame->layout()->spacing() && agentResult->alignment().testFlag(Qt::AlignTop) && resultHeightForWidth >= 0 && agentResult->height() >= resultHeightForWidth - 1 && @@ -1065,6 +1346,7 @@ bool testInspectorDetailParity() { denyButton->property("kind") == "destructive" && reviewButton->property("kind") == "request", "pending requests use warning surfaces and semantic actions"); + qApp->setStyleSheet(previousStyleSheet); return result; } @@ -1281,6 +1563,7 @@ int main(int argc, char **argv) { using namespace codexui::codex::middle; bool result = testOverlayGeometryAndRegionRouting(); result &= testThreadSelectionProjection(); + result &= testThreadHierarchyExpansionAndNavigation(); result &= testIncrementalThreadSettings(); result &= testThreadAlphanumericSort(); result &= testThreadCreatedSort(); diff --git a/tests/codex/PresentationPipelineTest.cpp b/tests/codex/PresentationPipelineTest.cpp index d20c6dd..68f159f 100644 --- a/tests/codex/PresentationPipelineTest.cpp +++ b/tests/codex/PresentationPipelineTest.cpp @@ -344,6 +344,561 @@ int main() { "retained-a"}, "thread discovery preserves provider order and one retained tail"); + PresentationModel ownershipModel; + ownershipModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "threads.list", "ownership-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "parent"}}, + {{"id", "child-one"}}, + {{"id", "second-root"}}})}}, + codexui::codex::presentation::Authority::Merge)); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 2, 1, "conversation.item.upsert", + {{"item", + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-one"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-one"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 3, 1, "conversation.item.upsert", + {{"item", + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-one"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-one"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 4, 1, "conversation.item.upsert", + {{"item", + {{"id", "spawn-two"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-two"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "spawn-two"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 5, 1, "conversation.item.upsert", + {{"item", + {{"id", "spawn-grandchild"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "grandchild"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "spawn-grandchild"}})); + const auto *childOneOwnership = + ownershipModel.childOwnership("child-one"); + const auto *grandchildOwnership = + ownershipModel.childOwnership("grandchild"); + const auto *parent = ownershipModel.thread("parent"); + const auto *childOne = ownershipModel.thread("child-one"); + passed &= expect( + childOneOwnership && childOneOwnership->parentThreadId == "parent" && + childOneOwnership->agentId == "spawn-one" && + grandchildOwnership && + grandchildOwnership->parentThreadId == "child-one" && + parent && + parent->childThreadOrder == + std::vector{"child-one", "child-two"} && + childOne && childOne->childThreadOrder == + std::vector{"grandchild"} && + ownershipModel.threadOrder() == + std::vector{"parent", "second-root"}, + "ownership is unique, ordered, nested, and excluded from root order"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 6, 1, "agents.activity.upsert", + {{"activity", + {{"id", "peer-interaction"}, + {"type", "subAgentActivity"}, + {"kind", "interacted"}, + {"agentPath", "/root/child-two"}, + {"agentThreadId", "child-two"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "peer-interaction"}})); + parent = ownershipModel.thread("parent"); + childOne = ownershipModel.thread("child-one"); + passed &= expect( + parent && childOne && + parent->childThreadOrder == + std::vector{"child-one", "child-two"} && + childOne->childThreadOrder == + std::vector{"grandchild"} && + ownershipModel.childOwnership("child-two") && + ownershipModel.childOwnership("child-two")->parentThreadId == + "parent" && + !childOne->agents.contains("peer-interaction") && + parent->agents.at("spawn-two").status == "started" && + stringMember(parent->agents.at("spawn-two").raw, "agentPath") == + "/root/child-two", + "peer interaction cannot reparent a sibling as a nested child"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 7, 1, "turn.upsert", + {{"turn", {{"id", "child-turn"}, {"status", "completed"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, {"turnId", "child-turn"}})); + ownershipModel.applyEvent(codexui::codex::presentation::event( + 8, 1, "conversation.item.upsert", + {{"item", + {{"id", "child-answer"}, + {"type", "agentMessage"}, + {"text", "direct child result"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "child-one"}, + {"turnId", "child-turn"}, + {"itemId", "child-answer"}})); + parent = ownershipModel.thread("parent"); + const auto ownerAgent = parent == nullptr + ? nullptr + : [&]() -> const codexui::codex::AgentPresentation * { + const auto found = + parent->agents.find("spawn-one"); + return found == parent->agents.end() + ? nullptr + : &found->second; + }(); + const auto *ownerSourceItem = + parent ? &parent->turns.at("parent-turn").items.at("spawn-one") + : nullptr; + passed &= expect( + ownerAgent && ownerAgent->status == "completed" && + stringMember(ownerAgent->raw, "resultText") == + "direct child result" && + ownerSourceItem && + stringMember(ownerSourceItem->raw, "resultText") == + "direct child result" && + parent->agents.at("spawn-two").status == "started", + "child completion and results route only to the indexed owning agent"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 9, 1, "agents.activity.upsert", + {{"activity", + {{"id", "wait-state"}, + {"type", "collabAgentToolCall"}, + {"tool", "wait_agent"}, + {"agentsStates", + {{"child-one", + {{"status", "completed"}, + {"message", "state-correlated result"}}}}}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "parent"}, + {"turnId", "parent-turn"}, + {"itemId", "wait-state"}})); + parent = ownershipModel.thread("parent"); + ownerSourceItem = + parent ? &parent->turns.at("parent-turn").items.at("spawn-one") + : nullptr; + passed &= expect( + parent && + stringMember(parent->agents.at("spawn-one").raw, "resultText") == + "state-correlated result" && + ownerSourceItem && + stringMember(ownerSourceItem->raw, "resultText") == + "state-correlated result", + "agent state results route to the indexed owner and its source item"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 10, 1, "thread.read", "replace-child", true, + {{"thread", + {{"id", "child-one"}, + {"status", {{"type", "idle"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "child-turn"}, + {"status", "inProgress"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-grandchild"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "grandchild"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "child-one"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + parent && parent->agents.at("spawn-one").status == "idle" && + !parent->agents.at("spawn-one").raw.contains("resultText") && + ownershipModel.childOwnership("child-one") != nullptr && + ownershipModel.childOwnership("grandchild") != nullptr, + "authoritative child hydration clears stale results without losing ownership"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 11, 1, "threads.list", "relisted-owned-child", true, + {{"threads", + nlohmann::json::array({{{"id", "child-one"}}, + {{"id", "second-root"}}, + {{"id", "parent"}}})}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + ownershipModel.threadOrder() == + std::vector{"second-root", "parent"}, + "thread relisting cannot reintroduce an owned child as a root"); + + ownershipModel.applyEvent(codexui::codex::presentation::result( + 12, 1, "thread.read", "replace-parent", true, + {{"thread", + {{"id", "parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "replacement-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-two"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "child-two"}}, + {{"id", "spawn-one"}, + {"type", "subAgentActivity"}, + {"status", "completed"}, + {"agentThreadId", "child-one"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "parent"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + parent && + parent->childThreadOrder == + std::vector{"child-two", "child-one"} && + ownershipModel.childOwnership("child-one") && + ownershipModel.childOwnership("child-two") && + ownershipModel.threadOrder() == + std::vector{"second-root", "parent"}, + "authoritative parent hydration rebuilds ordered ownership in one pass"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 13, 1, "thread.removed", nlohmann::json::object(), + codexui::codex::presentation::Authority::Remove, + {{"threadId", "child-one"}})); + parent = ownershipModel.thread("parent"); + passed &= expect( + ownershipModel.thread("child-one") == nullptr && parent && + parent->childThreadOrder == + std::vector{"child-two"} && + ownershipModel.childOwnership("child-one") == nullptr && + ownershipModel.childOwnership("grandchild") == nullptr && + ownershipModel.threadOrder() == + std::vector{"second-root", "parent", + "grandchild"}, + "authoritative child removal prunes ownership and promotes surviving descendants"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 14, 1, "thread.removed", nlohmann::json::object(), + codexui::codex::presentation::Authority::Remove, + {{"threadId", "parent"}})); + passed &= expect( + ownershipModel.thread("parent") == nullptr && + ownershipModel.childOwnership("child-two") == nullptr && + ownershipModel.threadOrder() == + std::vector{"second-root", "child-two", + "grandchild"}, + "authoritative parent removal promotes children in retained root order"); + + ownershipModel.applyEvent(codexui::codex::presentation::event( + 15, 1, "connection.provider", + {{"generation", std::uint64_t{1}}, {"state", "disconnected"}}, + codexui::codex::presentation::Authority::Replace)); + passed &= expect(ownershipModel.threadOrder().empty() && + ownershipModel.thread("child-two") == nullptr && + ownershipModel.childOwnership("child-two") == nullptr, + "provider loss clears threads and ownership atomically"); + + PresentationModel reconnectOwnershipModel; + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "thread.read", "hydrate-owner", true, + {{"thread", + {{"id", "hydrated-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "hydrated-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "hydrated-spawn"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "hydrated-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "hydrated-parent"}})); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 2, 1, "thread.read", "hydrate-child", true, + {{"thread", + {{"id", "hydrated-child"}, + {"status", {{"type", "notLoaded"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "stale-outer-turn"}, + {"status", "interrupted"}, + {"items", nlohmann::json::array()}}, + {{"id", "child-turn"}, + {"status", "completed"}, + {"items", + nlohmann::json::array( + {{{"id", "nested-spawn"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "hydrated-grandchild"}}, + {{"id", "hydrated-result"}, + {"type", "agentMessage"}, + {"text", "hydrated answer"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "hydrated-child"}})); + const auto *hydratedParent = + reconnectOwnershipModel.thread("hydrated-parent"); + const auto *hydratedSourceItem = + hydratedParent + ? &hydratedParent->turns.at("hydrated-turn") + .items.at("hydrated-spawn") + : nullptr; + passed &= expect( + hydratedParent && + reconnectOwnershipModel.childOwnership("hydrated-child") && + reconnectOwnershipModel.childOwnership("hydrated-grandchild") && + hydratedParent->agents.at("hydrated-spawn").status == "completed" && + stringMember(hydratedParent->agents.at("hydrated-spawn").raw, + "resultText") == "hydrated answer" && + hydratedSourceItem && + stringMember(hydratedSourceItem->raw, "status") == "completed" && + stringMember(hydratedSourceItem->raw, "resultText") == + "hydrated answer", + "parent-first and nested child hydration retain direct correlation"); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::event( + 1, 2, "connection.lifecycle", {{"state", "connected"}}, + codexui::codex::presentation::Authority::Replace)); + reconnectOwnershipModel.applyEvent(codexui::codex::presentation::result( + 2, 2, "threads.list", "post-reconnect-roots", true, + {{"threads", + nlohmann::json::array({{{"id", "hydrated-child"}}, + {{"id", "hydrated-parent"}}})}}, + codexui::codex::presentation::Authority::Merge)); + passed &= expect( + reconnectOwnershipModel.childOwnership("hydrated-child") && + reconnectOwnershipModel.threadOrder() == + std::vector{"hydrated-parent"}, + "connection-generation reconnect preserves ownership and root filtering"); + + PresentationModel inheritedHistoryModel; + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 1, 1, "thread.read", "hydrate-sibling-parent", true, + {{"thread", + {{"id", "sibling-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "parent-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-a"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-a"}}, + {{"id", "spawn-b"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-b"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "sibling-parent"}})); + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 2, 1, "thread.read", "hydrate-sibling-b", true, + {{"thread", + {{"id", "sibling-b"}, + {"turns", + nlohmann::json::array( + {{{"id", "inherited-parent-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "spawn-a"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "sibling-a"}}})}}})}}}}, + codexui::codex::presentation::Authority::Replace, + {{"threadId", "sibling-b"}})); + inheritedHistoryModel.applyEvent(codexui::codex::presentation::result( + 3, 1, "thread.read", "complete-sibling-a", true, + {{"thread", + {{"id", "sibling-a"}, + {"status", {{"type", "notLoaded"}}}, + {"turns", + nlohmann::json::array( + {{{"id", "sibling-a-turn"}, + {"status", "completed"}, + {"items", + nlohmann::json::array( + {{{"id", "sibling-a-answer"}, + {"type", "agentMessage"}, + {"text", "sibling answer"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "sibling-a"}})); + const auto *siblingParent = inheritedHistoryModel.thread("sibling-parent"); + const auto *siblingB = inheritedHistoryModel.thread("sibling-b"); + const auto *siblingOwnership = + inheritedHistoryModel.childOwnership("sibling-a"); + passed &= expect( + siblingParent && siblingB && siblingOwnership && + siblingOwnership->parentThreadId == "sibling-parent" && + siblingOwnership->agentId == "spawn-a" && + siblingParent->childThreadOrder == + std::vector{"sibling-a", "sibling-b"} && + siblingParent->agents.at("spawn-a").childThreadId == "sibling-a" && + siblingParent->agents.at("spawn-a").status == "completed" && + stringMember(siblingParent->agents.at("spawn-a").raw, + "resultText") == "sibling answer" && + siblingB->agents.at("spawn-a").childThreadId.empty(), + "inherited sibling history cannot steal direct child ownership"); + + PresentationModel reboundOwnershipModel; + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 1, 1, "thread.upsert", {{"thread", {{"id", "rebind-parent"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 2, 1, "conversation.item.upsert", + {{"item", + {{"type", "subAgentActivity"}, + {"id", "stable-agent"}, + {"status", "completed"}, + {"resultText", "old result"}, + {"agentThreadId", "old-child"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}, + {"turnId", "rebind-turn"}, + {"itemId", "stable-agent"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 3, 1, "agents.activity.upsert", + {{"activity", + {{"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "new-child"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}, + {"turnId", "rebind-turn"}, + {"itemId", "stable-agent"}})); + const auto *rebindParent = + reboundOwnershipModel.thread("rebind-parent"); + passed &= expect( + rebindParent && + rebindParent->childThreadOrder == + std::vector{"new-child"} && + reboundOwnershipModel.childOwnership("old-child") == nullptr && + reboundOwnershipModel.childOwnership("new-child") && + reboundOwnershipModel.threadOrder() == + std::vector{"rebind-parent", "old-child"}, + "rebinding one stable agent detaches and promotes the old child"); + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").childThreadId == + "new-child" && + rebindParent->agents.at("stable-agent").status == "started" && + !rebindParent->agents.at("stable-agent").raw.contains("resultText") && + !rebindParent->turns.at("rebind-turn") + .items.at("stable-agent") + .raw.contains("resultText"), + "rebinding one stable agent resets its stale completion and result"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 4, 1, "turn.upsert", + {{"turn", {{"id", "new-child-turn"}, {"status", "completed"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, {"turnId", "new-child-turn"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 5, 1, "conversation.item.upsert", + {{"item", + {{"id", "new-child-answer"}, + {"type", "agentMessage"}, + {"text", "new child result"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, + {"turnId", "new-child-turn"}, + {"itemId", "new-child-answer"}})); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( + 6, 1, "thread.read", "stale-parent-merge", true, + {{"thread", + {{"id", "rebind-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "rebind-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "stable-agent"}, + {"type", "subAgentActivity"}, + {"status", "inProgress"}, + {"kind", "started"}, + {"agentThreadId", "new-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + rebindParent = reboundOwnershipModel.thread("rebind-parent"); + const auto *reboundSourceItem = + rebindParent + ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") + : nullptr; + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").status == "completed" && + reboundSourceItem && + stringMember(reboundSourceItem->raw, "status") == "completed", + "stale merged parent hydration cannot reactivate a completed child agent"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::result( + 7, 1, "thread.read", "stale-former-child-merge", true, + {{"thread", + {{"id", "rebind-parent"}, + {"turns", + nlohmann::json::array( + {{{"id", "rebind-turn"}, + {"items", + nlohmann::json::array( + {{{"id", "stable-agent"}, + {"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "old-child"}}})}}})}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "rebind-parent"}})); + rebindParent = reboundOwnershipModel.thread("rebind-parent"); + const auto *reboundSourceAfterFormerChild = + rebindParent + ? &rebindParent->turns.at("rebind-turn").items.at("stable-agent") + : nullptr; + passed &= expect( + rebindParent && + rebindParent->agents.at("stable-agent").childThreadId == + "new-child" && + rebindParent->agents.at("stable-agent").status == "completed" && + stringMember(rebindParent->agents.at("stable-agent").raw, + "resultText") == "new child result" && + reboundSourceAfterFormerChild && + stringMember(reboundSourceAfterFormerChild->raw, "status") == + "completed" && + stringMember(reboundSourceAfterFormerChild->raw, "resultText") == + "new child result" && + reboundOwnershipModel.childOwnership("old-child") == nullptr && + reboundOwnershipModel.childOwnership("new-child") && + reboundOwnershipModel.childOwnership("new-child")->parentThreadId == + "rebind-parent", + "stale merged identity cannot undo a live child rebind"); + reboundOwnershipModel.applyEvent(codexui::codex::presentation::event( + 8, 1, "agents.activity.upsert", + {{"activity", + {{"type", "subAgentActivity"}, + {"status", "started"}, + {"agentThreadId", "rebind-parent"}}}}, + codexui::codex::presentation::Authority::Merge, + {{"threadId", "new-child"}, + {"turnId", "cycle-turn"}, + {"itemId", "cycle-agent"}})); + passed &= expect( + reboundOwnershipModel.childOwnership("rebind-parent") == nullptr && + reboundOwnershipModel.thread("new-child") && + reboundOwnershipModel.thread("new-child") + ->childThreadOrder.empty(), + "ancestor ownership cycles are rejected without disturbing the tree"); + normalizer.bridgeEvent({{"kind", "bridge.provider"}, {"state", "disconnected"}, {"providerGeneration", std::uint64_t{1}}, diff --git a/tests/codex/ShellIntegrationTest.cpp b/tests/codex/ShellIntegrationTest.cpp index 50dfaa7..2f6bde1 100644 --- a/tests/codex/ShellIntegrationTest.cpp +++ b/tests/codex/ShellIntegrationTest.cpp @@ -581,6 +581,38 @@ bool ShellFlow::verifyBoundedChildHydration() { result &= expect(!peer.waitFor("thread.read", "child-failure", 100).has_value(), "a failed child hydration does not enter an automatic retry loop"); + + result &= expect(selectThread(list, "thread-a") && + selectThread(list, "thread-b"), + "explicit navigation returns to the failed child's parent"); + const auto retriedChildRead = + peer.waitFor("thread.read", "child-failure"); + result &= expect(retriedChildRead.has_value(), + "explicit parent navigation retries one failed child read"); + if (!retriedChildRead) + return false; + result &= peer.send(presentation::result( + sequence++, 2, "thread.read", + retriedChildRead->value("correlationId", std::string{}), true, + {{"thread", threadWithAgentMessage("child-failure", "Child", + "completed child result")}}, + Authority::Replace, {{"threadId", "child-failure"}})); + spin(10); + + auto *inspector = shell.findChild(QStringLiteral("inspector")); + auto *tabs = inspector ? inspector->findChild( + QString{}, Qt::FindDirectChildrenOnly) + : nullptr; + if (tabs) { + tabs->setCurrentIndex(1); + spin(); + } + result &= expect(inspector && tabs && + hasPresentedText(*inspector, + QStringLiteral("Completed")) && + !hasPresentedText(*inspector, + QStringLiteral("Running")), + "retried child completion replaces the stale Running badge"); return result; }