feat(editor): Let the Agents chat host render results of finished tool calls (no-changelog) (#40611)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jaakko Husso 2026-10-12 14:26:14 +00:00 committed by GitHub
parent 0755255c35
commit 0209caafff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 419 additions and 31 deletions

View File

@ -4,6 +4,12 @@ import { h } from 'vue';
import AgentChatMessageList from '../components/AgentChatMessageList.vue';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { planMessage, planTask, planView } from './fixtures/agent-plan';
import { computed } from 'vue';
import { AGENTS_CHAT_INTERACTION_EXTENSIONS } from '@/features/ai/shared/agentsChat/interactionRegistry';
import {
TEST_RESULT_TOOL_NAME,
testToolResultExtension,
} from '@/features/ai/shared/agentsChat/__tests__/fixtures/testInteractionExtension';
const copySpy = vi.fn();
@ -1384,4 +1390,118 @@ describe('AgentChatMessageList', () => {
expect(wrapper.findAll('[data-testid="agent-chat-timestamp-divider"]')).toHaveLength(0);
});
});
describe('host tool-result renderers', () => {
const savedNote: ToolCall = {
tool: TEST_RESULT_TOOL_NAME,
toolCallId: 'tc-note',
state: 'done',
input: { text: 'Remember this' },
output: { saved: true },
};
const httpCall: ToolCall = {
tool: 'http_request',
toolCallId: 'tc-http',
state: 'done',
output: { status: 200 },
};
function assistantMessage(toolCalls: ToolCall[], content = ''): ChatMessage {
return { id: 'assistant-1', role: 'assistant', content, toolCalls, status: 'success' };
}
function mountList(messages: ChatMessage[], withExtension = true) {
return mount(AgentChatMessageList, {
props: { messages, messagingState: 'idle' },
global: withExtension
? {
provide: {
[AGENTS_CHAT_INTERACTION_EXTENSIONS]: computed(() => [testToolResultExtension]),
},
}
: {},
});
}
function stepToolCallIds(wrapper: ReturnType<typeof mountList>): string[] {
return wrapper
.findAllComponents({ name: 'AgentChatToolSteps' })
.flatMap((steps) => (steps.props('toolCalls') as ToolCall[]).map((tc) => tc.toolCallId));
}
it.each([
['a tool run', ''],
['a message with text', 'Saved your note.'],
])('renders the host component in place of the default step in %s', (_, content) => {
const wrapper = mountList([assistantMessage([httpCall, savedNote], content)]);
const results = wrapper.findAll('[data-testid="agent-chat-host-tool-result"]');
expect(results).toHaveLength(1);
expect(results[0].find('[data-testid="test-tool-result"]').text()).toContain('tc-note');
expect(results[0].text()).toContain('{"saved":true}');
expect(stepToolCallIds(wrapper)).toEqual(['tc-http']);
wrapper.unmount();
});
it('hides the tool steps when the host renders every call', () => {
const wrapper = mountList([assistantMessage([savedNote])]);
expect(wrapper.findAll('[data-testid="test-tool-result"]')).toHaveLength(1);
expect(wrapper.findComponent({ name: 'AgentChatToolSteps' }).exists()).toBe(false);
wrapper.unmount();
});
it('renders the default step without extensions', () => {
const wrapper = mountList([assistantMessage([httpCall, savedNote])], false);
expect(wrapper.find('[data-testid="agent-chat-host-tool-result"]').exists()).toBe(false);
expect(stepToolCallIds(wrapper)).toEqual(['tc-http', 'tc-note']);
wrapper.unmount();
});
it('falls back to the default step until the call finishes', async () => {
const running: ToolCall = { ...savedNote, state: 'running', output: undefined };
const wrapper = mountList([assistantMessage([running])]);
expect(wrapper.find('[data-testid="test-tool-result"]').exists()).toBe(false);
expect(stepToolCallIds(wrapper)).toEqual(['tc-note']);
await wrapper.setProps({ messages: [assistantMessage([savedNote])] });
expect(wrapper.findAll('[data-testid="test-tool-result"]')).toHaveLength(1);
expect(stepToolCallIds(wrapper)).toEqual([]);
wrapper.unmount();
});
it('falls back to the default step until the output arrives', () => {
const ended: ToolCall = { ...savedNote, output: undefined };
const wrapper = mountList([assistantMessage([ended])]);
expect(wrapper.find('[data-testid="test-tool-result"]').exists()).toBe(false);
expect(stepToolCallIds(wrapper)).toEqual(['tc-note']);
wrapper.unmount();
});
it('keeps the default step for a suspended copy of a call that finished later', () => {
const suspended: ToolCall = { ...savedNote, state: 'suspended', output: undefined };
const wrapper = mountList([
{ ...assistantMessage([suspended], 'Saving your note.'), id: 'assistant-1' },
{ ...assistantMessage([savedNote]), id: 'assistant-2' },
]);
const results = wrapper.findAll('[data-testid="test-tool-result"]');
expect(results).toHaveLength(1);
expect(results[0].text()).toContain('{"saved":true}');
expect(stepToolCallIds(wrapper)).toEqual(['tc-note']);
wrapper.unmount();
});
it('falls back to the default step for a failed call', () => {
const failed: ToolCall = { ...savedNote, state: 'error', output: 'boom' };
const wrapper = mountList([assistantMessage([failed])]);
expect(wrapper.find('[data-testid="test-tool-result"]').exists()).toBe(false);
expect(stepToolCallIds(wrapper)).toEqual(['tc-note']);
wrapper.unmount();
});
});
});

View File

@ -1,13 +1,22 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { computed, inject, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { N8nButton, N8nCallout, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { APPROVAL_TOOL_NAME, N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types';
import { isAwaitingCard } from '@/features/ai/shared/agentsChat/n8nChatInteraction';
import { useI18n } from '@n8n/i18n';
import { useSessionStorage } from '@vueuse/core';
import { TIME } from '@/app/constants/durations';
import { isAssistantGroup, type DisplayGroup } from '@/features/ai/shared/agentsChat/displayGroups';
import {
DISPLAY_GROUP_KIND,
isAssistantGroup,
type DisplayGroup,
} from '@/features/ai/shared/agentsChat/displayGroups';
import { getMessageInteractives, isRecord } from '@/features/ai/shared/agentsChat/messageMappers';
import {
AGENTS_CHAT_INTERACTION_EXTENSIONS,
findToolResultRenderer,
type AgentsChatToolResultRenderer,
} from '@/features/ai/shared/agentsChat/interactionRegistry';
import {
getMessageThinkingSegments,
getThinkingDurationSec,
@ -68,8 +77,9 @@ const canSendToAssistant = computed(() =>
);
function onFixWithAssistant(group: DisplayGroup, failures: AgentFixWithAssistantFailure[]) {
if (group.kind === 'backgroundJobSignal') return;
const executionId = group.kind === 'toolRun' ? group.executionId : group.message.executionId;
if (group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL) return;
const executionId =
group.kind === DISPLAY_GROUP_KIND.TOOL_RUN ? group.executionId : group.message.executionId;
if (!executionId || failures.length === 0) return;
emit('sendToAssistant', { executionId, failures });
}
@ -157,9 +167,50 @@ function getMessageRenderItems(message: ChatMessage): MessageRenderItem[] {
return items;
}
const interactionExtensions = inject(AGENTS_CHAT_INTERACTION_EXTENSIONS, undefined);
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
const displayGroups = computed(() => buildAgentPlanDisplayGroups(props.messages));
interface GroupToolCalls {
/** Calls that render as default tool steps. */
steps: ToolCall[];
/** Finished calls that a host extension renders in place of a step. */
hostResults: Array<{ toolCall: ToolCall; renderer: AgentsChatToolResultRenderer }>;
}
const NO_GROUP_TOOL_CALLS: GroupToolCalls = { steps: [], hostResults: [] };
/**
* The tool calls of each group, split into default steps and host results.
* History can hold the same `toolCallId` twice (suspended, then resolved), so
* each group checks its own copy of the call.
*/
const toolCallsByGroupId = computed(() => {
const extensions = interactionExtensions?.value ?? [];
const byGroupId = new Map<string, GroupToolCalls>();
for (const group of displayGroups.value) {
if (group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL) continue;
const toolCalls =
group.kind === DISPLAY_GROUP_KIND.TOOL_RUN
? group.toolCalls
: (group.message.toolCalls ?? []);
const split: GroupToolCalls = { steps: [], hostResults: [] };
for (const toolCall of toolCalls) {
const renderer =
extensions.length > 0 ? findToolResultRenderer(toolCall, extensions) : undefined;
if (renderer) split.hostResults.push({ toolCall, renderer });
else split.steps.push(toolCall);
}
byGroupId.set(group.id, split);
}
return byGroupId;
});
function getGroupToolCalls(groupId: string): GroupToolCalls {
return toolCallsByGroupId.value.get(groupId) ?? NO_GROUP_TOOL_CALLS;
}
const retryErrorMessageId = computed(() => {
const message = props.messages.at(-1);
return props.retryMessageId && isRetryableChatError(message) ? message?.id : undefined;
@ -182,7 +233,7 @@ const dividerLabels = computed(() => {
const labels = new Map<string, string>();
let previousUserCreatedAt: number | undefined;
for (const group of displayGroups.value) {
if (group.kind !== 'message' || group.message.role !== 'user') continue;
if (group.kind !== DISPLAY_GROUP_KIND.MESSAGE || group.message.role !== 'user') continue;
const createdAt = group.message.createdAt;
if (createdAt === undefined) continue;
// A new local day always earns a divider, even inside the window: without
@ -219,7 +270,7 @@ const changeRequestGroupId = computed(() =>
canSendToAssistant.value && !changeNoteDismissed.value
? displayGroups.value.findLast(
(group) =>
group.kind === 'message' &&
group.kind === DISPLAY_GROUP_KIND.MESSAGE &&
group.message.role === 'user' &&
looksLikeAgentChangeRequest(group.message.content),
)?.id
@ -238,8 +289,8 @@ function isThinkingActive(message: ChatMessage): boolean {
}
function getAssistantGroupContent(group: DisplayGroup): string {
if (group.kind === 'backgroundJobSignal') return '';
if (group.kind === 'toolRun') {
if (group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL) return '';
if (group.kind === DISPLAY_GROUP_KIND.TOOL_RUN) {
return group.finalMessage?.content ?? '';
}
@ -306,8 +357,8 @@ function parseMemoryOutput(output: unknown): MemoryUsed[] {
}
function isCompletedAssistantGroup(group: DisplayGroup): boolean {
if (group.kind === 'backgroundJobSignal') return false;
if (group.kind === 'toolRun') {
if (group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL) return false;
if (group.kind === DISPLAY_GROUP_KIND.TOOL_RUN) {
return (
group.finalMessage !== undefined &&
group.finalMessage.status !== CHAT_MESSAGE_STATUS.STREAMING &&
@ -344,7 +395,10 @@ function getMemoriesUsedInAssistantRun(groupId: string): MemoryUsed[] {
const group = displayGroups.value[i];
if (!isAssistantGroup(group)) break;
const toolCalls = group.kind === 'toolRun' ? group.toolCalls : (group.message.toolCalls ?? []);
const toolCalls =
group.kind === DISPLAY_GROUP_KIND.TOOL_RUN
? group.toolCalls
: (group.message.toolCalls ?? []);
for (let j = toolCalls.length - 1; j >= 0; j--) {
const toolCall = toolCalls[j];
if (toolCall.tool !== 'recall_memory') continue;
@ -471,20 +525,34 @@ watch(
>
<N8nText size="small" color="text-light">{{ dividerLabels.get(group.id) }}</N8nText>
</div>
<div v-if="group.kind === 'backgroundJobSignal'" :class="[$style.message, $style.assistant]">
<div
v-if="group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL"
:class="[$style.message, $style.assistant]"
>
<AgentChatBackgroundJobSignal :class="$style.content" :signal="group.signal" />
</div>
<div v-else-if="group.kind === 'toolRun'" :class="[$style.message, $style.assistant]">
<div
v-else-if="group.kind === DISPLAY_GROUP_KIND.TOOL_RUN"
:class="[$style.message, $style.assistant]"
>
<div :class="$style.content">
<AgentChatToolSteps
v-if="group.toolCalls.length"
:tool-calls="group.toolCalls"
v-if="getGroupToolCalls(group.id).steps.length"
:tool-calls="getGroupToolCalls(group.id).steps"
:project-id="projectId"
:can-fix-with-assistant="canSendToAssistant"
:dismissed-tool-call-ids="dismissedFixToolCallIds"
:execution-id="group.executionId"
@fix-with-assistant="onFixWithAssistant(group, $event)"
/>
<div
v-for="result in getGroupToolCalls(group.id).hostResults"
:key="`result-${result.toolCall.toolCallId}`"
:class="$style.toolResult"
data-testid="agent-chat-host-tool-result"
>
<component :is="result.renderer.component" :tool-call="result.toolCall" />
</div>
<template v-for="tc in group.toolCalls" :key="`wait-${tc.toolCallId}`">
<N8nText
v-if="externalWaitPlatform(tc)"
@ -593,14 +661,22 @@ watch(
>
<div :class="$style.content">
<AgentChatToolSteps
v-if="group.message.toolCalls?.length"
:tool-calls="group.message.toolCalls"
v-if="getGroupToolCalls(group.id).steps.length"
:tool-calls="getGroupToolCalls(group.id).steps"
:project-id="projectId"
:can-fix-with-assistant="canSendToAssistant"
:dismissed-tool-call-ids="dismissedFixToolCallIds"
:execution-id="group.message.executionId"
@fix-with-assistant="onFixWithAssistant(group, $event)"
/>
<div
v-for="result in getGroupToolCalls(group.id).hostResults"
:key="`result-${result.toolCall.toolCallId}`"
:class="$style.toolResult"
data-testid="agent-chat-host-tool-result"
>
<component :is="result.renderer.component" :tool-call="result.toolCall" />
</div>
<template v-for="tc in group.message.toolCalls ?? []" :key="`wait-${tc.toolCallId}`">
<N8nText
v-if="externalWaitPlatform(tc)"
@ -847,6 +923,12 @@ watch(
margin-bottom: var(--spacing--2xs);
}
/* A host component that replaces the default step of a finished tool call. */
.toolResult {
margin-top: var(--spacing--2xs);
margin-bottom: var(--spacing--2xs);
}
/* Stretches past the right-aligned user bubble it follows, and stacks the
hand-off button under the note instead of squeezing it in beside the text.
`stretch` gives the text row the full width the dismiss button needs to sit

View File

@ -3,6 +3,7 @@ import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import {
buildDisplayGroups,
DISPLAY_GROUP_KIND,
type DisplayGroup,
} from '@/features/ai/shared/agentsChat/displayGroups';
import type { ChatMessage, ToolCall } from '@/features/ai/shared/agentsChat/types';
@ -147,8 +148,8 @@ export function buildAgentPlanDisplayGroups(messages: ChatMessage[]): DisplayGro
}
return buildDisplayGroups(messages).flatMap((group): DisplayGroup[] => {
if (group.kind === 'backgroundJobSignal') return [group];
if (group.kind === 'message') {
if (group.kind === DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL) return [group];
if (group.kind === DISPLAY_GROUP_KIND.MESSAGE) {
return [
{
...group,

View File

@ -56,3 +56,29 @@ export const testInteractionExtension: AgentsChatInteractionExtension<TestCardIn
export const testInteractionExtensions: readonly AgentsChatInteractionExtension[] = [
testInteractionExtension,
];
/** Test-only tool whose finished result the host renders. */
export const TEST_RESULT_TOOL_NAME = 'save_note';
export const TEST_RESULT_EXTENSION_KEY = 'test_result';
export const TestToolResult = defineComponent({
name: 'TestToolResult',
props: {
toolCall: { type: Object as PropType<ToolCall>, required: true },
},
setup(props) {
return () =>
h('div', { 'data-testid': 'test-tool-result' }, [
h('span', props.toolCall.toolCallId),
h('pre', JSON.stringify(props.toolCall.output)),
]);
},
});
/** A host extension that only renders tool results. It has no card fields. */
export const testToolResultExtension: AgentsChatInteractionExtension = {
key: TEST_RESULT_EXTENSION_KEY,
matchToolResult: (toolCall) => toolCall.tool === TEST_RESULT_TOOL_NAME,
resultComponent: TestToolResult,
};

View File

@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { TOOL_CALL_STATE } from '../constants';
import {
findToolResultRenderer,
type AgentsChatInteractionExtension,
} from '../interactionRegistry';
import type { ToolCall } from '../types';
import {
TEST_RESULT_EXTENSION_KEY,
TEST_RESULT_TOOL_NAME,
TestToolResult,
testInteractionExtension,
testToolResultExtension,
} from './fixtures/testInteractionExtension';
const finishedCall: ToolCall = {
tool: TEST_RESULT_TOOL_NAME,
toolCallId: 'tc-1',
state: TOOL_CALL_STATE.DONE,
input: { text: 'Remember this' },
output: { saved: true },
};
describe('findToolResultRenderer', () => {
it('returns the host renderer of a result-only extension for a finished call', () => {
expect(findToolResultRenderer(finishedCall, [testToolResultExtension])).toEqual({
key: TEST_RESULT_EXTENSION_KEY,
component: TestToolResult,
});
});
it('returns undefined without extensions', () => {
expect(findToolResultRenderer(finishedCall, [])).toBeUndefined();
});
it('returns undefined when no extension matches the call', () => {
expect(
findToolResultRenderer({ ...finishedCall, tool: 'other_tool' }, [testToolResultExtension]),
).toBeUndefined();
});
it('ignores extensions that only provide cards', () => {
expect(findToolResultRenderer(finishedCall, [testInteractionExtension])).toBeUndefined();
});
it('ignores an extension that matches but has no result component', () => {
const extension: AgentsChatInteractionExtension = {
...testToolResultExtension,
resultComponent: undefined,
};
expect(findToolResultRenderer(finishedCall, [extension])).toBeUndefined();
});
it.each([
TOOL_CALL_STATE.PENDING,
TOOL_CALL_STATE.RUNNING,
TOOL_CALL_STATE.SUSPENDED,
TOOL_CALL_STATE.ERROR,
])('keeps the default step for a call in state %s', (state) => {
expect(
findToolResultRenderer({ ...finishedCall, state }, [testToolResultExtension]),
).toBeUndefined();
});
it.each([
['the cancelled state', { state: TOOL_CALL_STATE.CANCELLED }],
['the canceled flag', { canceled: true }],
])('keeps the default step for a call canceled with %s', (_, canceled) => {
expect(
findToolResultRenderer({ ...finishedCall, ...canceled }, [testToolResultExtension]),
).toBeUndefined();
});
it('keeps the default step for a finished call without output', () => {
expect(
findToolResultRenderer({ ...finishedCall, output: undefined }, [testToolResultExtension]),
).toBeUndefined();
});
it('uses the first extension that matches', () => {
const second: AgentsChatInteractionExtension = {
...testToolResultExtension,
key: 'second',
};
expect(findToolResultRenderer(finishedCall, [testToolResultExtension, second])?.key).toBe(
TEST_RESULT_EXTENSION_KEY,
);
});
});

View File

@ -5,6 +5,16 @@ import { getMessageInteractives } from './messageMappers';
import { getMessageThinkingSegments } from './thinking';
import type { AgentsChatMessage, InteractivePayload, ThinkingSegment, ToolCall } from './types';
/** Values of the `kind` tag that tells the `DisplayGroup` variants apart. */
export const DISPLAY_GROUP_KIND = {
/** A notice about background jobs of the agent that came to an end. */
BACKGROUND_JOB_SIGNAL: 'backgroundJobSignal',
/** One user or assistant message that the list does not fold. */
MESSAGE: 'message',
/** Consecutive tool-only assistant messages folded into one block. */
TOOL_RUN: 'toolRun',
} as const;
/**
* Presentation group for the message list. The builder persists one assistant
* message per tool-use turn, so a single conversation fragments into many robot
@ -19,18 +29,18 @@ import type { AgentsChatMessage, InteractivePayload, ThinkingSegment, ToolCall }
*/
export type DisplayGroup =
| {
kind: 'backgroundJobSignal';
kind: typeof DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL;
id: string;
signal: AgentBackgroundJobSignal;
}
| {
kind: 'message';
kind: typeof DISPLAY_GROUP_KIND.MESSAGE;
id: string;
message: AgentsChatMessage;
thinkingSegments: ThinkingSegment[];
}
| {
kind: 'toolRun';
kind: typeof DISPLAY_GROUP_KIND.TOOL_RUN;
id: string;
thinkingSegments: ThinkingSegment[];
active: boolean;
@ -58,22 +68,26 @@ export type DisplayGroup =
executionId?: string;
};
export type TurnDisplayGroup = Exclude<DisplayGroup, { kind: 'backgroundJobSignal' }>;
export type TurnDisplayGroup = Exclude<
DisplayGroup,
{ kind: typeof DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL }
>;
export function isGroupable(message: AgentsChatMessage): boolean {
return message.role === 'assistant' && !!message.toolCalls?.length && !message.content.trim();
}
type ToolRunGroup = Extract<DisplayGroup, { kind: 'toolRun' }>;
type ToolRunGroup = Extract<DisplayGroup, { kind: typeof DISPLAY_GROUP_KIND.TOOL_RUN }>;
export function isAssistantGroup(group: DisplayGroup): group is TurnDisplayGroup {
return (
group.kind === 'toolRun' || (group.kind === 'message' && group.message.role === 'assistant')
group.kind === DISPLAY_GROUP_KIND.TOOL_RUN ||
(group.kind === DISPLAY_GROUP_KIND.MESSAGE && group.message.role === 'assistant')
);
}
function executionIdForGroup(group: TurnDisplayGroup): string | undefined {
return group.kind === 'toolRun' ? group.executionId : group.message.executionId;
return group.kind === DISPLAY_GROUP_KIND.TOOL_RUN ? group.executionId : group.message.executionId;
}
/** Keep one reasoning block at the tail of each assistant run, below its final output. */
@ -208,7 +222,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
if (message.role === 'assistant' && message.backgroundJobSignal) {
// Keep the signal key stable when the same turn gains text or tool calls.
groups.push({
kind: 'backgroundJobSignal',
kind: DISPLAY_GROUP_KIND.BACKGROUND_JOB_SIGNAL,
id: `${message.executionId ?? message.id}:background-job-signal`,
signal: message.backgroundJobSignal,
});
@ -223,7 +237,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
}
if (isGroupable(message)) {
const last = groups[groups.length - 1];
if (last?.kind === 'toolRun' && canAppendToToolRun(last, message)) {
if (last?.kind === DISPLAY_GROUP_KIND.TOOL_RUN && canAppendToToolRun(last, message)) {
last.toolCalls = appendToolCalls(last.toolCalls, message.toolCalls ?? []);
last.thinkingSegments.push(...getMessageThinkingSegments(message));
last.active ||= message.status === 'streaming';
@ -237,7 +251,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
continue;
}
groups.push({
kind: 'toolRun',
kind: DISPLAY_GROUP_KIND.TOOL_RUN,
id: message.id,
thinkingSegments: getMessageThinkingSegments(message),
active: message.status === 'streaming',
@ -252,7 +266,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
if (message.role === 'assistant') {
const last = groups[groups.length - 1];
if (last?.kind === 'toolRun' && canAppendToToolRun(last, message)) {
if (last?.kind === DISPLAY_GROUP_KIND.TOOL_RUN && canAppendToToolRun(last, message)) {
last.finalMessage = message;
last.executionId ??= message.executionId;
last.thinkingSegments.push(...getMessageThinkingSegments(message));
@ -271,7 +285,7 @@ export function buildDisplayGroups(messages: AgentsChatMessage[]): DisplayGroup[
}
groups.push({
kind: 'message',
kind: DISPLAY_GROUP_KIND.MESSAGE,
id: message.id,
message,
thinkingSegments: message.role === 'assistant' ? getMessageThinkingSegments(message) : [],

View File

@ -1,5 +1,6 @@
import type { Component, ComputedRef, InjectionKey } from 'vue';
import { TOOL_CALL_STATE } from './constants';
import type { AgentsChatInteraction, ToolCall } from './types';
export interface AgentsChatInteractionRenderer {
@ -17,6 +18,11 @@ export interface AgentsChatInteractionRenderer {
* The card fields (`parse` and `component`) are optional, so a host can add
* other parts without a card. The chat maps a card only with `parse`, and
* renders it only with `component`. Set both for a card.
*
* An extension can also render the result of a finished tool call
* (`matchToolResult` and `resultComponent`). The chat then shows the host
* component in place of the default tool step. An extension that only renders
* results needs no card fields.
*/
export interface AgentsChatInteractionExtension<TInput = unknown> {
/** Unique key. The payload stores it as `extensionKey`. */
@ -37,6 +43,23 @@ export interface AgentsChatInteractionExtension<TInput = unknown> {
* default: the chat cancels the card and steers the run with the text.
*/
composerResumeData?(input: TInput, text: string): unknown;
/**
* True when the host renders the result of this tool call. The chat asks
* only for calls that finished with an output, without an error or a cancel.
*/
matchToolResult?(toolCall: ToolCall): boolean;
/**
* Renders a matched tool result. It gets the finished call as the `toolCall`
* prop. When this extension also renders a card for the tool, `output` is
* the resume data until the tool result arrives.
*/
resultComponent?: Component;
}
/** A host component that renders the result of one finished tool call. */
export interface AgentsChatToolResultRenderer {
key: string;
component: Component;
}
/** The extensions of the chat panel, for the interactive cards that it renders. */
@ -50,3 +73,33 @@ export function findInteractionRenderer(
): AgentsChatInteractionRenderer | undefined {
return renderers.find((renderer) => renderer.matches(payload));
}
/**
* `tool-execution-end` and the stream finalizer set `DONE` before the output
* arrives, so a call without output is not finished yet.
*/
function isFinishedToolCall(toolCall: ToolCall): boolean {
return (
toolCall.state === TOOL_CALL_STATE.DONE &&
toolCall.canceled !== true &&
toolCall.output !== undefined
);
}
/**
* The first host extension that renders the result of `toolCall`, or
* `undefined` when the chat shows the default tool step. Calls that run, wait
* for the user, fail, were canceled or have no output yet always use the
* default tool step.
*/
export function findToolResultRenderer(
toolCall: ToolCall,
extensions: readonly AgentsChatInteractionExtension[],
): AgentsChatToolResultRenderer | undefined {
if (!isFinishedToolCall(toolCall)) return undefined;
for (const extension of extensions) {
if (!extension.resultComponent || !extension.matchToolResult?.(toolCall)) continue;
return { key: extension.key, component: extension.resultComponent };
}
return undefined;
}