Skip to content

Commit f0cbdc2

Browse files
feat(acp): nest subagent work under parent cards
Subagent narration and tool calls are not peer transcript events: they belong to the Agent/Task call that spawned them. Emitting their chunks into the main stream split the primary bubble, and folding them as top-level tools hid the parent relationship. - Detect parent via _meta keys (parentToolCallId variants) - Drop subagent message/thought chunks from the main stream - Persist parent id and ordered children; skip parents in folds
1 parent beb3664 commit f0cbdc2

15 files changed

Lines changed: 729 additions & 100 deletions

‎src/AcpConnection.cpp‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,10 +961,17 @@ void AcpConnection::handleInboundNotification(const QString &method, const QJson
961961
return;
962962
m_promptProducedOutput = true;
963963
noteStreamingActivity();
964+
// A subagent's narration is not the agent's answer: its result comes
965+
// back through the Agent tool call, and its chunks would interleave
966+
// with (and split) the main agent's bubble.
967+
if (!AcpProtocol::subagentParentToolCallId(update).isEmpty())
968+
return;
964969
emit messageChunk(text, update.value(QStringLiteral("messageId")).toString());
965970
} else if (kind == QLatin1String("agent_thought_chunk")) {
966971
m_promptProducedOutput = true;
967972
noteStreamingActivity();
973+
if (!AcpProtocol::subagentParentToolCallId(update).isEmpty())
974+
return; // same as a subagent's message chunk
968975
const QString text = AcpProtocol::contentBlockToChunkText(
969976
update.value(QStringLiteral("content")).toObject());
970977
emit thoughtChunk(text);
@@ -981,6 +988,7 @@ void AcpConnection::handleInboundNotification(const QString &method, const QJson
981988
tc.rawInput = update.value(QStringLiteral("rawInput")).toObject();
982989
tc.rawOutput = AcpProtocol::rawOutputToObject(update.value(QStringLiteral("rawOutput")));
983990
tc.groupId = update.value(QStringLiteral("groupId")).toInt(0);
991+
tc.parentToolCallId = AcpProtocol::subagentParentToolCallId(update);
984992
if (AcpProtocol::isAdvisorToolName(tc.name)) {
985993
m_advisorToolCallIds.insert(tc.id);
986994
}
@@ -1021,6 +1029,9 @@ void AcpConnection::handleInboundNotification(const QString &method, const QJson
10211029
if (AcpProtocol::toolCallUpdateIsBackgrounded(update)) {
10221030
u.backgrounded = true;
10231031
}
1032+
if (QString parent = AcpProtocol::subagentParentToolCallId(update); !parent.isEmpty()) {
1033+
u.parentToolCallId = std::move(parent);
1034+
}
10241035
noteScheduledJobUpdate(update);
10251036
const QJsonValue c = update.value(QStringLiteral("content"));
10261037
if (advisorFailed) {

‎src/AcpProtocol.cpp‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,24 @@ bool toolCallUpdateIsBackgrounded(const QJsonObject &update)
562562
.value(QStringLiteral("backgrounded")).toBool(false);
563563
}
564564

565+
QString subagentParentToolCallId(const QJsonObject &update)
566+
{
567+
const QJsonValue metaValue = update.value(QStringLiteral("_meta"));
568+
if (!metaValue.isObject())
569+
return {}; // the main agent's updates: no lookups past this
570+
const QJsonObject meta = metaValue.toObject();
571+
QString parent = meta.value(QStringLiteral("parentToolCallId")).toString();
572+
if (parent.isEmpty()) {
573+
parent = meta.value(QStringLiteral("claudeCode")).toObject()
574+
.value(QStringLiteral("parentToolUseId")).toString();
575+
}
576+
if (parent.isEmpty())
577+
parent = meta.value(QStringLiteral("parentToolUseId")).toString();
578+
if (!parent.isEmpty() && parent == update.value(QStringLiteral("toolCallId")).toString())
579+
return {};
580+
return parent;
581+
}
582+
565583
QString formatWaitDuration(qint64 ms)
566584
{
567585
if (ms < 0) {

‎src/AcpProtocol.h‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ struct AcpToolCall
110110
// persisted. -1 = unknown (restored/never observed running).
111111
qint64 startedAtMs{0};
112112
qint64 durationMs{-1};
113+
// Made by a subagent: the Agent/Task tool call that spawned it. Such calls
114+
// render nested under that card, never as transcript peers. Persisted.
115+
QString parentToolCallId;
113116
};
114117

115118
// Needle match for ACP tool name or title. Prefer extra over dropping a write.
@@ -156,6 +159,9 @@ struct AcpToolCallUpdate
156159
std::optional<bool> backgroundRunning;
157160
// Host-side (not wire): mirrors AcpToolCall::durationMs once known.
158161
std::optional<qint64> durationMs;
162+
// Late subagent attribution (subagentParentToolCallId of the update): an
163+
// eager tool_call can miss it, the refining update carries it.
164+
std::optional<QString> parentToolCallId;
159165
};
160166

161167
struct AcpPlanEntry
@@ -346,6 +352,12 @@ bool asyncTaskStateIsLive(const QString &state);
346352
// `_meta.jetbrains.air.asyncTasks.backgrounded == true` on a tool_call_update.
347353
bool toolCallUpdateIsBackgrounded(const QJsonObject &update);
348354

355+
// The Agent/Task tool call whose subagent sent this session/update, from its
356+
// `_meta`: `parentToolCallId`, then `claudeCode.parentToolUseId`
357+
// (claude-agent-acp), then `parentToolUseId`. Empty for the main agent, and
358+
// when the id names the update's own tool call.
359+
QString subagentParentToolCallId(const QJsonObject &update);
360+
349361
// Compact wait clock: "4.2s" under a minute, "2m 05s" under an hour, then "1h 02m".
350362
QString formatWaitDuration(qint64 ms);
351363
// Compact tool run time: "<1s", "40s", "1m5s", "1h1m5s".

‎src/AcpSessionModel.cpp‎

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
645676
void 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
}

‎src/AcpSessionModel.h‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ class AcpSessionModel : public QObject
122122
const QVector<AcpMessage> &messages() const { return m_messages; }
123123
const QHash<QString, AcpProtocol::AcpToolCall> &toolCalls() const { return m_toolCalls; }
124124
const QVector<AcpTimelineEntry> &timeline() const { return m_timeline; }
125+
// Tool calls a subagent made, in arrival order, keyed by the Agent/Task
126+
// tool call that spawned it. They live in toolCalls() but get no timeline
127+
// entry (a call attributed late keeps the one it already had; readers skip
128+
// tool calls with a parentToolCallId).
129+
QStringList subagentChildren(const QString &parentToolCallId) const
130+
{
131+
return m_subagentChildren.value(parentToolCallId);
132+
}
125133
const std::optional<AcpProtocol::AcpUsage> &usage() const { return m_usage; }
126134
const QList<AcpProtocol::AcpCommandInfo> &availableCommands() const { return m_availableCommands; }
127135
// True only when the agent advertised `goal`/`/goal`. Host inject does not set this.
@@ -216,6 +224,7 @@ public slots:
216224
private:
217225
void schedulePersistIfNeeded();
218226
void flushPendingPersist();
227+
void attachSubagentChild(const QString &parentToolCallId, const QString &childId);
219228
QString resolveHistoryDir() const;
220229
QString resolveHistoryFilePath() const;
221230

@@ -231,6 +240,7 @@ public slots:
231240
QVector<AcpMessage> m_messages;
232241
QHash<QString, AcpProtocol::AcpToolCall> m_toolCalls;
233242
QVector<AcpTimelineEntry> m_timeline;
243+
QHash<QString, QStringList> m_subagentChildren; // parent tool call -> children, arrival order
234244

235245
int m_currentGroupId = 0;
236246
std::optional<AcpProtocol::AcpUsage> m_usage;

‎src/AcpTurnFolding.cpp‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ QVector<Turn> compute(const QVector<AcpTimelineEntry> &timeline,
9898
continue;
9999
const AcpTimelineEntry &e = timeline.at(i);
100100
if (e.kind == AcpTimelineEntry::Kind::ToolCall) {
101-
if (toolCalls.contains(e.toolCallId)) {
101+
// A subagent's call attributed after it got its entry renders
102+
// inside its Agent card, not here.
103+
const auto tc = toolCalls.constFind(e.toolCallId);
104+
if (tc != toolCalls.constEnd() && tc->parentToolCallId.isEmpty()) {
102105
turn.folded.append(i);
103106
++turn.toolCalls;
104107
}

0 commit comments

Comments
 (0)