Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@
"members": [],
"source": {
"path": "libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts",
"line": 191
"line": 193
},
"isStatic": false,
"isReadonly": true,
Expand Down Expand Up @@ -484,7 +484,7 @@
"members": [],
"source": {
"path": "libs/chat/src/lib/primitives/chat-tool-calls/chat-tool-calls.component.ts",
"line": 193
"line": 195
},
"overloadIndex": 1,
"isStatic": false,
Expand Down
20 changes: 20 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat-activity-template.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,24 @@ describe('ChatComponent — chatActivityTemplate', () => {
fixture.componentInstance.agent.subagents!.set(new Map());
expect(chat.activityContextFor(message, 1)).not.toBe(first);
});

it('passes fresh message wrappers to the real activity template without mounting tool hosts', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const chat = fixture.debugElement.query(By.directive(ChatComponent)).componentInstance as unknown as {
activityContexts: Map<string, { $implicit: Message; toolCalls: ToolCall[]; subagents: Subagent[] }>;
};
const previous = chat.activityContexts.get('a1')!;
const agent = fixture.componentInstance.agent;
agent.messages.update(messages => messages.map(message => ({ ...message, delivery: { ...message.delivery } })));
fixture.detectChanges();
const current = chat.activityContexts.get('a1')!;
expect(current).not.toBe(previous);
expect(current.$implicit).toBe(agent.messages()[1]);
expect(current.$implicit).not.toBe(previous.$implicit);
expect(current.toolCalls.map(call => call.id)).toEqual(['c_search', 'c_task']);
expect(current.subagents.map(subagent => subagent.toolCallId)).toEqual(['c_task']);
expect(fixture.nativeElement.querySelectorAll('.activity')).toHaveLength(2);
expect(fixture.nativeElement.querySelectorAll('chat-tool-calls')).toHaveLength(0);
});
});
65 changes: 65 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat-empty-tool-work.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ChatComponent } from './chat.component';
import { ChatMessageComponent } from '../../primitives/chat-message/chat-message.component';
import { ChatToolCallsComponent } from '../../primitives/chat-tool-calls/chat-tool-calls.component';
import { ChatStreamingMdComponent } from '../../streaming/streaming-markdown.component';
import { mockAgent } from '../../testing/mock-agent';
import { staticDelivery, streamingDelivery, type Message } from '../../agent';

describe('ChatComponent empty tool grouping work', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [ChatComponent] }));
afterEach(() => vi.restoreAllMocks());

it('keeps every empty tool host and fresh public message without rebuilding historical subagent indexes', () => {
const messages: Message[] = [
...Array.from({ length: 1000 }, (_, i): Message => ({
id: `history-${i}`, role: i % 2 ? 'user' : 'assistant',
content: `Historical message ${i}.`, delivery: staticDelivery(`history-${i}`),
})),
{ id: 'tail', role: 'assistant', content: 'Initial tail', delivery: streamingDelivery('g') },
];
const agent = mockAgent({ messages, isLoading: true, withSubagents: true });
// Observe the actual map iteration used to build each group's subagent
// index; the real resolver, computed values and templates remain intact.
const iterations = vi.spyOn(agent.subagents!(), 'forEach');
const fixture = TestBed.createComponent(ChatComponent);
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const tools = fixture.debugElement.queryAll(By.directive(ChatToolCallsComponent));
expect(tools).toHaveLength(501);
const historical = tools.filter(element => element.componentInstance.message().id !== 'tail');
expect(historical).toHaveLength(500);
for (const element of tools) expect(element.componentInstance.groups()).toEqual([]);
expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card, chat-subagent-card')).toHaveLength(0);
const rows = fixture.debugElement.queryAll(By.directive(ChatMessageComponent));
const historyRow = rows.find(element => element.componentInstance.message()?.id === 'history-0')!;
const document = historyRow.query(By.directive(ChatStreamingMdComponent)).componentInstance.document();
const text = historyRow.nativeElement.textContent;
iterations.mockClear();

const next = messages.map(message => ({ ...message, delivery: { ...message.delivery },
content: message.id === 'tail' ? 'Initial tail with next token' : message.content }));
agent.messages.set(next);
fixture.detectChanges();

const updated = fixture.debugElement.queryAll(By.directive(ChatToolCallsComponent));
expect(updated).toHaveLength(501);
const byId = new Map(next.map(message => [message.id, message]));
for (let i = 0; i < updated.length; i++) {
expect(updated[i].nativeElement).toBe(tools[i].nativeElement);
const current = updated[i].componentInstance.message();
expect(current).toBe(byId.get(current.id));
expect(current).not.toBe(messages.find(message => message.id === current.id));
expect(updated[i].componentInstance.groups()).toEqual([]);
}
expect(historyRow.nativeElement.isConnected).toBe(true);
expect(historyRow.nativeElement.textContent).toBe(text);
expect(historyRow.query(By.directive(ChatStreamingMdComponent)).componentInstance.document()).toBe(document);
expect(fixture.nativeElement.textContent).toContain('Initial tail with next token');
console.info(JSON.stringify({ schema: 'chat-empty-tool-work-v1', historicalAssistants: 500,
mountedToolHosts: updated.length, subagentIndexIterations: iterations.mock.calls.length }));
expect(iterations, 'empty rows must not build a subagent index on a tail update').not.toHaveBeenCalled();
}, 60000);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { By } from '@angular/platform-browser';
import { ChatToolCallCardComponent } from '../../compositions/chat-tool-call-card/chat-tool-call-card.component';
import { ChatToolCallsComponent } from './chat-tool-calls.component';
import { ChatToolCallTemplateDirective } from './chat-tool-call-template.directive';
import { mockAgent } from '../../testing/mock-agent';
import { staticDelivery, type Message, type ToolCall, type Subagent } from '../../agent';

const prose = (): Message => ({ id: 'answer', role: 'assistant', content: 'prose', delivery: staticDelivery('answer') });
const call = (id = 'search'): ToolCall => ({ id, name: 'search_web', args: { query: 'before' }, status: 'running' });

@Component({
standalone: true,
imports: [ChatToolCallsComponent, ChatToolCallTemplateDirective],
template: `<chat-tool-calls [agent]="agent" [message]="message">
<ng-template chatToolCallTemplate="search_web" let-call let-status="status">
<span data-named>{{ call.id }}:{{ status }}:{{ call.result }}</span>
</ng-template>
<ng-template chatToolCallTemplate="*" let-call let-status="status">
<span data-wildcard>{{ call.id }}:{{ status }}</span>
</ng-template>
</chat-tool-calls>`,
})
class EmptyTemplateHost {
readonly agent = mockAgent();
readonly message = { ...prose(), toolCallIds: ['search', 'other'] };
}

describe('ChatToolCallsComponent empty groups', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [ChatToolCallsComponent] }));
afterEach(() => vi.restoreAllMocks());

it('skips subagent indexing for empty calls while retaining its host and unit-computed styles', () => {
const agent = mockAgent({ withSubagents: true });
const iterations = vi.spyOn(agent.subagents!(), 'forEach');
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('message', prose());
fixture.detectChanges();
const host: HTMLElement = fixture.nativeElement;
document.body.appendChild(host);
try {
expect(getComputedStyle(host).display).toBe('block');
expect(getComputedStyle(host).marginBottom).toBe('20px');
iterations.mockClear();
fixture.componentRef.setInput('message', prose());
fixture.detectChanges();
expect(fixture.componentInstance.toolCalls()).toEqual([]);
expect(fixture.componentInstance.groups()).toEqual([]);
expect(fixture.nativeElement).toBe(host);
expect(getComputedStyle(host).display).toBe('block');
expect(getComputedStyle(host).marginBottom).toBe('20px');
console.info(JSON.stringify({ schema: 'primitive-empty-tool-work-v1', subagentIndexIterations: iterations.mock.calls.length }));
expect(iterations).not.toHaveBeenCalled();
} finally { host.remove(); }
});

it('exposes supported same-object linkage and tool mutations through existing tool-call signal invalidations', () => {
const tool = call();
const agent = mockAgent({ toolCalls: [tool] });
const message = { ...prose(), toolCallIds: [] as string[] };
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('message', message);
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
const host = fixture.nativeElement;
message.toolCallIds.push(tool.id);
agent.toolCalls.set([...agent.toolCalls()]);
fixture.detectChanges();
expect(fixture.componentInstance.message()).toBe(message);
expect(fixture.componentInstance.toolCalls()).toEqual([tool]);
expect(fixture.componentInstance.groups()[0].calls[0]).toBe(tool);
expect(host.querySelectorAll('chat-tool-call-card')).toHaveLength(1);
tool.status = 'complete'; tool.result = 'answer'; tool.args['query'] = 'after';
agent.toolCalls.set([...agent.toolCalls()]);
fixture.detectChanges();
expect(fixture.componentInstance.groups()[0].calls[0]).toBe(tool);
const card = fixture.debugElement.query(By.directive(ChatToolCallCardComponent)).componentInstance;
expect(card.toolCall()).toMatchObject({ status: 'complete', result: 'answer', args: { query: 'after' } });
message.toolCallIds.length = 0;
agent.toolCalls.set([...agent.toolCalls()]);
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
expect(host.querySelectorAll('chat-tool-call-card')).toHaveLength(0);
expect(fixture.nativeElement).toBe(host);
});

it('resolves structured content mutated in place and calls supplied after the empty result', () => {
const message: Message = { ...prose(), content: [] };
const tool = call();
const agent = mockAgent();
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('message', message);
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
(message.content as Exclude<Message['content'], string>).push({ type: 'tool_use', id: tool.id, name: tool.name, args: {} });
agent.toolCalls.set([]);
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
agent.toolCalls.set([tool]);
fixture.detectChanges();
expect(fixture.componentInstance.toolCalls()[0]).toBe(tool);
expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card')).toHaveLength(1);
});

it('resumes subagent anchoring after an empty result and reacts to subagent replacement', () => {
const agent = mockAgent({ withSubagents: true });
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('message', { ...prose(), toolCallIds: ['task'] });
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
const sub: Subagent = { toolCallId: 'task', name: 'Research', status: signal('running'), messages: signal([]), state: signal({}) };
agent.subagents!.set(new Map([['different-run-key', sub]]));
agent.toolCalls.set([{ ...call('task'), name: 'task' }]);
fixture.detectChanges();
expect(fixture.componentInstance.groups()[0].subagent).toBe(sub);
expect(fixture.nativeElement.querySelectorAll('chat-subagent-card')).toHaveLength(1);
const replacement = { ...sub, name: 'Replacement' };
agent.subagents!.set(new Map([['another-key', replacement]]));
fixture.detectChanges();
expect(fixture.componentInstance.groups()[0].subagent).toBe(replacement);
});

it('keeps nonassistant and absent-message global calls across agent replacement', () => {
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', mockAgent());
fixture.detectChanges();
expect(fixture.componentInstance.groups()).toEqual([]);
const tool = call();
fixture.componentRef.setInput('agent', mockAgent({ toolCalls: [tool] }));
fixture.detectChanges();
expect(fixture.componentInstance.toolCalls()[0]).toBe(tool);
fixture.componentRef.setInput('message', { ...prose(), role: 'user' });
fixture.detectChanges();
expect(fixture.componentInstance.toolCalls()[0]).toBe(tool);
expect(fixture.nativeElement.querySelectorAll('chat-tool-call-card')).toHaveLength(1);
});

it('returns independent empty group arrays after actual recomputation', () => {
const fixture = TestBed.createComponent(ChatToolCallsComponent);
fixture.componentRef.setInput('agent', mockAgent());
fixture.componentRef.setInput('message', prose());
fixture.detectChanges();
const first = fixture.componentInstance.groups();
first.push({ name: 'consumer edit', calls: [] });
fixture.componentRef.setInput('message', prose());
fixture.detectChanges();
expect(fixture.componentInstance.groups()).not.toBe(first);
expect(fixture.componentInstance.groups()).toEqual([]);
});
});

describe('empty tool groups with projected templates', () => {
it('instantiates named/wildcard templates after empty linkage and updates their status/result context', () => {
TestBed.configureTestingModule({ imports: [EmptyTemplateHost] });
const fixture = TestBed.createComponent(EmptyTemplateHost);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('[data-named], [data-wildcard]')).toHaveLength(0);
const tool = call();
fixture.componentInstance.agent.toolCalls.set([tool, { ...call('other'), name: 'read_file' }]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('[data-named]')).toHaveLength(1);
expect(fixture.nativeElement.querySelectorAll('[data-wildcard]')).toHaveLength(1);
expect(fixture.nativeElement.querySelector('[data-named]').textContent).toContain('search:running:');
tool.status = 'complete'; tool.result = 'finished';
fixture.componentInstance.agent.toolCalls.set([...fixture.componentInstance.agent.toolCalls()]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-named]').textContent).toContain('search:complete:finished');
fixture.componentInstance.agent.toolCalls.set([]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('[data-named], [data-wildcard]')).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ export class ChatToolCallsComponent {
);

readonly groups = computed((): Group[] => {
const resolvedCalls = this.toolCalls();
if (resolvedCalls.length === 0) return [];
const excludeSet = new Set(this.excludeToolNames());
const calls = this.toolCalls().filter(tc => !excludeSet.has(tc.name));
const calls = resolvedCalls.filter(tc => !excludeSet.has(tc.name));
// Anchor subagents by the contract field `Subagent.toolCallId` (the tool
// call that spawned them), NOT by the adapter's map key. Adapters key the
// map differently — LangGraph by toolCallId, AG-UI activities by
Expand Down
Loading
Loading