@@ -242,13 +242,30 @@ void AcpSessionModel::loadFromDisk()
242242 tc.groupId = to.value (QStringLiteral (" groupId" )).toInt ();
243243 tc.backgrounded = to.value (QStringLiteral (" backgrounded" )).toBool (false );
244244 tc.durationMs = static_cast <qint64>(to.value (QStringLiteral (" durationMs" )).toDouble (-1 ));
245+ tc.parentToolCallId = to.value (QStringLiteral (" parentToolCallId" )).toString ();
245246 if (!tc.id .isEmpty ()) {
246247 m_toolCalls.insert (tc.id , tc);
247248 if (tc.groupId > m_currentGroupId) {
248249 m_currentGroupId = tc.groupId ;
249250 }
251+ // Arrival order lives on the parent (m_toolCalls is unordered).
252+ const QJsonArray children = to.value (QStringLiteral (" subagentChildren" )).toArray ();
253+ if (!children.isEmpty ()) {
254+ QStringList &ids = m_subagentChildren[tc.id ];
255+ ids.reserve (children.size ());
256+ for (const auto cv : children) {
257+ if (QString id = cv.toString (); !id.isEmpty ())
258+ ids.append (std::move (id));
259+ }
260+ }
250261 }
251262 }
263+ // Children whose id the parent's list lacks (hand-edited or older file).
264+ for (auto it = m_toolCalls.cbegin (); it != m_toolCalls.cend (); ++it) {
265+ const QString &parent = it.value ().parentToolCallId ;
266+ if (!parent.isEmpty () && !m_subagentChildren.value (parent).contains (it.key ()))
267+ m_subagentChildren[parent].append (it.key ());
268+ }
252269
253270 // timeline
254271 const QJsonArray tl = obj.value (QStringLiteral (" timeline" )).toArray ();
@@ -335,6 +352,13 @@ QJsonObject AcpSessionModel::toHistoryJson() const
335352 if (tc.durationMs >= 0 ) {
336353 to.insert (QStringLiteral (" durationMs" ), static_cast <double >(tc.durationMs ));
337354 }
355+ if (!tc.parentToolCallId .isEmpty ()) {
356+ to.insert (QStringLiteral (" parentToolCallId" ), tc.parentToolCallId );
357+ }
358+ if (const auto children = m_subagentChildren.constFind (tc.id );
359+ children != m_subagentChildren.constEnd () && !children->isEmpty ()) {
360+ to.insert (QStringLiteral (" subagentChildren" ), QJsonArray::fromStringList (*children));
361+ }
338362 if (AskUserForm::isAskUserToolCall (tc.name , tc.title , tc.rawInput )) {
339363 // Small (validated: <= 30 questions) and needed to restore the form.
340364 to.insert (QStringLiteral (" rawInput" ), tc.rawInput );
@@ -642,29 +666,53 @@ void AcpSessionModel::onThoughtChunk(const QString &text)
642666 schedulePersistIfNeeded ();
643667}
644668
669+ void AcpSessionModel::attachSubagentChild (const QString &parentToolCallId, const QString &childId)
670+ {
671+ QStringList &ids = m_subagentChildren[parentToolCallId];
672+ if (!ids.contains (childId))
673+ ids.append (childId);
674+ }
675+
645676void AcpSessionModel::onToolCallReceived (const AcpToolCall &tc)
646677{
678+ // A re-sent tool_call without the meta keeps the attribution it had.
679+ QString parentToolCallId = tc.parentToolCallId ;
680+ if (parentToolCallId.isEmpty ()) {
681+ const auto prior = m_toolCalls.constFind (tc.id );
682+ if (prior != m_toolCalls.constEnd ())
683+ parentToolCallId = prior->parentToolCallId ;
684+ }
685+ const bool subagentChild = !parentToolCallId.isEmpty ();
647686 // A tool-call card is a new timeline peer — close any active streaming
648687 // assistant text or thought so a subsequent text/thought chunk opens a
649688 // fresh bubble below the card instead of merging into the previous one.
650- m_streamingAssistantMessageIndex = -1 ;
651- m_streamingThoughtMessageIndex = -1 ;
689+ // A subagent's call nests under its Agent card instead: not a peer.
690+ if (!subagentChild) {
691+ m_streamingAssistantMessageIndex = -1 ;
692+ m_streamingThoughtMessageIndex = -1 ;
693+ }
652694
653695 AcpToolCall copy = tc;
696+ copy.parentToolCallId = parentToolCallId;
654697 copy.groupId = m_currentGroupId;
655698 if (!AcpProtocol::toolCallStatusIsTerminal (copy.status )) {
656699 copy.startedAtMs = QDateTime::currentMSecsSinceEpoch ();
657700 }
658701 AcpProtocol::injectToolCallPath (copy.rawInput , QJsonArray{});
659702 AcpProtocol::stripTerminalContentBlocks (copy.content );
660703 copy.content = AcpProtocol::ensureDiffContent (copy.content , copy.rawInput , copy.rawOutput );
704+ const bool known = m_toolCalls.contains (copy.id );
661705 m_toolCalls.insert (copy.id , copy);
662706
663- AcpTimelineEntry entry;
664- entry.kind = AcpTimelineEntry::Kind::ToolCall;
665- entry.toolCallId = copy.id ;
666- entry.groupId = m_currentGroupId;
667- m_timeline.append (entry);
707+ if (subagentChild) {
708+ attachSubagentChild (copy.parentToolCallId , copy.id );
709+ } else if (!known) {
710+ AcpTimelineEntry entry;
711+ entry.kind = AcpTimelineEntry::Kind::ToolCall;
712+ entry.toolCallId = copy.id ;
713+ entry.groupId = m_currentGroupId;
714+ m_timeline.append (entry);
715+ }
668716
669717 emit toolCallAddedOrUpdated (copy.id );
670718 schedulePersistIfNeeded ();
@@ -678,11 +726,17 @@ void AcpSessionModel::onToolCallUpdated(const AcpToolCallUpdate &update)
678726 // initial tool_call. We still want it in the timeline.
679727 // Also close any active streaming text/thought, since this branch
680728 // adds a fresh timeline peer (same rule as onToolCallReceived).
681- m_streamingAssistantMessageIndex = -1 ;
682- m_streamingThoughtMessageIndex = -1 ;
729+ const bool subagentChild = update.parentToolCallId .has_value ();
730+ if (!subagentChild) {
731+ m_streamingAssistantMessageIndex = -1 ;
732+ m_streamingThoughtMessageIndex = -1 ;
733+ }
683734
684735 AcpToolCall tc;
685736 tc.id = update.id ;
737+ if (subagentChild) {
738+ tc.parentToolCallId = *update.parentToolCallId ;
739+ }
686740 if (update.title .has_value ()) {
687741 tc.title = *update.title ;
688742 }
@@ -716,12 +770,23 @@ void AcpSessionModel::onToolCallUpdated(const AcpToolCallUpdate &update)
716770 }
717771 m_toolCalls.insert (update.id , tc);
718772
719- AcpTimelineEntry entry;
720- entry.kind = AcpTimelineEntry::Kind::ToolCall;
721- entry.toolCallId = update.id ;
722- entry.groupId = m_currentGroupId;
723- m_timeline.append (entry);
773+ if (subagentChild) {
774+ attachSubagentChild (tc.parentToolCallId , tc.id );
775+ } else {
776+ AcpTimelineEntry entry;
777+ entry.kind = AcpTimelineEntry::Kind::ToolCall;
778+ entry.toolCallId = update.id ;
779+ entry.groupId = m_currentGroupId;
780+ m_timeline.append (entry);
781+ }
724782 } else {
783+ // Late attribution: an eager (permission) tool_call can arrive before
784+ // the adapter knows its subagent. The call keeps its timeline entry;
785+ // every reader skips entries whose call has a parent.
786+ if (update.parentToolCallId .has_value () && it.value ().parentToolCallId .isEmpty ()) {
787+ it.value ().parentToolCallId = *update.parentToolCallId ;
788+ attachSubagentChild (*update.parentToolCallId , update.id );
789+ }
725790 const QJsonArray previousContent = it.value ().content ;
726791 if (update.title .has_value ()) {
727792 it.value ().title = *update.title ;
@@ -1100,6 +1165,8 @@ void AcpSessionModel::upsertGoalJudgeToolCall(const AcpToolCall &tc)
11001165{
11011166 AcpToolCall copy = tc;
11021167 copy.id = namespacedGoalJudgeToolId (tc.id );
1168+ if (!copy.parentToolCallId .isEmpty ())
1169+ copy.parentToolCallId = namespacedGoalJudgeToolId (copy.parentToolCallId );
11031170 closeGoalJudgeStreaming ();
11041171 const int assistantIdx = m_streamingAssistantMessageIndex;
11051172 const int thoughtIdx = m_streamingThoughtMessageIndex;
@@ -1114,6 +1181,8 @@ void AcpSessionModel::applyGoalJudgeToolCallUpdate(const AcpToolCallUpdate &upda
11141181{
11151182 AcpToolCallUpdate u = update;
11161183 u.id = namespacedGoalJudgeToolId (update.id );
1184+ if (u.parentToolCallId .has_value ())
1185+ u.parentToolCallId = namespacedGoalJudgeToolId (*u.parentToolCallId );
11171186 const bool existed = m_toolCalls.contains (u.id );
11181187 if (!existed)
11191188 closeGoalJudgeStreaming ();
@@ -1173,6 +1242,12 @@ void AcpSessionModel::removeGoalJudgeTurn()
11731242 if (e.kind == AcpTimelineEntry::Kind::ToolCall) {
11741243 removedTools.append (e.toolCallId );
11751244 m_toolCalls.remove (e.toolCallId );
1245+ // Its subagent's calls have no timeline entries of their own.
1246+ const QStringList children = m_subagentChildren.take (e.toolCallId );
1247+ for (const QString &child : children) {
1248+ if (m_toolCalls.remove (child))
1249+ removedTools.append (child);
1250+ }
11761251 } else if (e.messageIndex >= 0 ) {
11771252 removedMsg.append (e.messageIndex );
11781253 }
0 commit comments