Skip to content

Commit 960ab91

Browse files
lukasmoschitzmme
authored andcommitted
fix(react-core): IntelligenceIndicator stays through tool-result interleaving
The pill's gate "the message must be the last message of its run" was suppressed every time a `role: "tool"` result arrived between successive assistant-with-tool-call messages. Real MCP recall flows always interleave tool results between assistant tool-call messages, so the assistant message holding the matching tool call lost its "last in run" claim immediately, the indicator returned `null`, and the pill flashed off. By the time the run finished, the final prose-only assistant message was the last in the run and the pill on the bash-bearing assistant stayed suppressed. Net result: the user saw no pill at all during a real recall. Change the gate to "the latest assistant-with-matching-tool-call message in the run". Tool result messages (`role: "tool"`) and prose-only assistant messages now skip through the walk without invalidating an earlier matching-assistant's claim on the slot, so the pill stays continuously through a multi-step tool chain and transitions to checkmark on `isRunning` falling (debounced 500 ms, unchanged) as before. The existing test suite did not cover this case — none of the walkthrough scenarios emit `role: "tool"` between successive assistant messages. A regression test that interleaves a tool result will land alongside this fix.
1 parent e8192a1 commit 960ab91

1 file changed

Lines changed: 41 additions & 12 deletions

File tree

packages/react-core/src/v2/components/intelligence-indicator/IntelligenceIndicator.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,16 @@ export interface IntelligenceIndicatorProps {
7676
* 1. `copilotkit.intelligence !== undefined` (Intelligence runtime
7777
* is configured; checked by the parent before mounting, and
7878
* again here as a defence)
79-
* 2. The message is the last message of its run
79+
* 2. The message is an assistant message with at least one tool call
80+
* whose name matches {@link DEFAULT_TOOL_PATTERNS}
8081
* 3. The message's run is the latest run on the thread
81-
* 4. The message has at least one tool call whose name matches
82-
* {@link DEFAULT_TOOL_PATTERNS}
82+
* 4. The message is the *latest* such matching-assistant message in
83+
* its run — i.e. no later assistant-with-matching-tool-call
84+
* message exists in the same run. Tool result messages
85+
* (`role: "tool"`) and prose-only assistant messages do NOT
86+
* invalidate this slot, so the pill stays continuously through a
87+
* multi-step bash chain instead of flickering off every time a
88+
* tool result arrives.
8389
* 5. The phase machine is not yet `hidden`
8490
*
8591
* Phase machine (per-instance, all timers local):
@@ -185,31 +191,54 @@ export function IntelligenceIndicator(
185191
if (!hasMatch) return null;
186192

187193
// The message must belong to the latest run on the thread, AND must
188-
// be the LAST message in its run. We derive both by walking
189-
// `agent.messages` from the end:
194+
// be the latest assistant-with-matching-tool-call message in its
195+
// run. We derive both by walking `agent.messages` from the end:
190196
// - the most recent run-associated message defines the latest run
191-
// - if `message` is the last message in `agent.messages` whose
192-
// runId matches `messageRunId`, it's the last in its run
197+
// - the first match-eligible assistant message we hit (walking
198+
// backwards) within `messageRunId` is the canonical pill slot
199+
//
200+
// Tool result messages (`role: "tool"`) and prose-only assistant
201+
// messages are skipped during the walk: they don't invalidate an
202+
// earlier matching-assistant message's claim on the slot. This is
203+
// what keeps the pill continuously visible through a multi-step
204+
// tool chain (each MCP tool reply is a `role: "tool"` message that
205+
// would otherwise become "the last message in the run" and suppress
206+
// the pill on every cycle).
193207
const messageRunId = copilotkit.getRunIdForMessage(
194208
agentId,
195209
config.threadId,
196210
message.id,
197211
);
198212
if (!messageRunId) return null;
199213
let latestRunId: string | undefined;
200-
let lastMessageIdInThisRun: string | undefined;
214+
let latestMatchingAssistantIdInRun: string | undefined;
201215
for (let i = agent.messages.length - 1; i >= 0; i -= 1) {
202216
const m = agent.messages[i]!;
203217
const r = copilotkit.getRunIdForMessage(agentId, config.threadId, m.id);
204218
if (latestRunId === undefined && r) latestRunId = r;
205-
if (lastMessageIdInThisRun === undefined && r === messageRunId) {
206-
lastMessageIdInThisRun = m.id;
219+
if (
220+
latestMatchingAssistantIdInRun === undefined &&
221+
r === messageRunId &&
222+
m.role === "assistant"
223+
) {
224+
const tcs = Array.isArray(m.toolCalls) ? m.toolCalls : [];
225+
const isMatch = tcs.some((tc) => {
226+
const name = tc?.function?.name;
227+
return (
228+
typeof name === "string" &&
229+
DEFAULT_TOOL_PATTERNS.some((p) => p.test(name))
230+
);
231+
});
232+
if (isMatch) latestMatchingAssistantIdInRun = m.id;
207233
}
208-
if (latestRunId !== undefined && lastMessageIdInThisRun !== undefined)
234+
if (
235+
latestRunId !== undefined &&
236+
latestMatchingAssistantIdInRun !== undefined
237+
)
209238
break;
210239
}
211240
if (latestRunId !== messageRunId) return null;
212-
if (lastMessageIdInThisRun !== message.id) return null;
241+
if (latestMatchingAssistantIdInRun !== message.id) return null;
213242

214243
// ─── Visual ──────────────────────────────────────────────────────────
215244

0 commit comments

Comments
 (0)