From 5c60f77bfbcf10df8d62a884de454eeb2ab6a63c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 26 Jul 2026 23:09:19 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B7=20fix:=20Keep=20Group=20Identity?= =?UTF-8?q?=20Stable=20and=20Memoize=20Label=20Endpoint=20Resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group remount. Tool-group identity was keyed on the first part in the block. An activity label absorbs the block's leading THINK part the moment its text lands, so the key flipped from tool: to fallback:: mid-run, remounting the group and discarding whatever the user had expanded. The key now scans for the first tool call, which does not move when the block re-forms. Label endpoint resolution is memoized per response. It reads provider config and can hit the database for user keys, yet nothing it depends on changes between batches of one run — and it ran twice per batch, once for generation and once for usage accounting. The promise is cached rather than the value so concurrent batches share a single in-flight resolution, and a rejection is evicted so one transient credential failure cannot disable labels for the rest of the response. --- api/server/controllers/agents/client.js | 34 +++++++++++++------ .../Chat/Messages/Content/ContentParts.tsx | 12 +++++-- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 75833796fa..725f504aec 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -358,16 +358,30 @@ class AgentClient extends BaseClient { * endpoint named by `activityEndpoint` (default: the agent's). */ async resolveActivityLabelLLM() { - return resolveActivityLabelModel({ - req: this.options.req, - agent: this.options.agent, - ids: { - messageId: this.responseMessageId, - conversationId: this.conversationId, - parentMessageId: this.parentMessageId, - }, - db: { getUserKey: db.getUserKey, getUserKeyValues: db.getUserKeyValues }, - }); + /** Memoized per response: resolution reads provider config and can hit the + * database for user keys, and nothing it depends on changes between + * batches of the same run — so re-resolving on every batch (twice, with + * usage accounting) is repeated credential work for an identical result. + * The promise is cached rather than the value so concurrent batches share + * one in-flight resolution. */ + this.activityLabelLLMPromise = + this.activityLabelLLMPromise ?? + resolveActivityLabelModel({ + req: this.options.req, + agent: this.options.agent, + ids: { + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }, + db: { getUserKey: db.getUserKey, getUserKeyValues: db.getUserKeyValues }, + }).catch((error) => { + /** Never cache a rejection: a transient credential read failure would + * otherwise disable labels for the rest of the response. */ + this.activityLabelLLMPromise = null; + throw error; + }); + return this.activityLabelLLMPromise; } /** diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index ecc40256f6..cfdcc14798 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -30,9 +30,15 @@ const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string = if (!firstPart) { return 'empty'; } - const toolCallId = getToolCallId(firstPart.part); - if (toolCallId) { - return `tool:${toolCallId}`; + /** Keyed on the first TOOL CALL, not the first part. An activity label + * absorbs the block's leading THINK part when its text lands, so keying on + * `parts[0]` would flip the key mid-run — remounting the group and losing + * whatever the user had expanded. The tool calls themselves do not move. */ + for (const { part } of parts) { + const toolCallId = getToolCallId(part); + if (toolCallId) { + return `tool:${toolCallId}`; + } } return `fallback:${fallbackScope}:${firstPart.idx}`; };