mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
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:
parent
0755255c35
commit
0209caafff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) : [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user