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}`; };