From 7b3696f3f7d95ab3cbaeb8ca58fdc74264a83b52 Mon Sep 17 00:00:00 2001 From: Albert Alises Date: Mon, 20 Apr 2026 12:11:46 +0200 Subject: [PATCH] fix(ai-builder): Scope artifacts panel to resources produced in-thread (#28678) --- .../__tests__/InstanceAiMarkdown.test.ts | 2 +- .../__tests__/createInstanceAiHarness.ts | 39 +- .../__tests__/useCanvasPreview.test.ts | 42 ++- .../__tests__/useResourceRegistry.test.ts | 340 ++++++++++-------- .../components/AgentActivityTree.vue | 6 +- .../instanceAi/components/AgentTimeline.vue | 6 +- .../components/InstanceAiArtifactsPanel.vue | 2 +- .../components/InstanceAiMarkdown.vue | 11 +- .../ai/instanceAi/instanceAi.store.ts | 10 +- .../ai/instanceAi/useCanvasPreview.ts | 2 +- .../ai/instanceAi/useResourceRegistry.ts | 230 ++++++++---- 11 files changed, 423 insertions(+), 267 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts index 7f141c8e841..400ee559172 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiMarkdown.test.ts @@ -36,7 +36,7 @@ describe('InstanceAiMarkdown', () => { function getProcessedContent(content: string, registry?: Map): string { if (registry) { - store.resourceRegistry = registry; + store.resourceNameIndex = registry; } const { getByTestId } = renderComponent({ props: { content } }); return getByTestId('markdown-output').textContent ?? ''; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts index 0f904e0c45c..5c21edb433f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/createInstanceAiHarness.ts @@ -163,14 +163,16 @@ export async function createInstanceAiHarness(): Promise { // --- Mock store --- const messages = ref([]) as Ref; const isStreaming = ref(false); - const resourceRegistry = ref(new Map()); + const producedArtifacts = ref(new Map()); + const resourceNameIndex = ref(new Map()); const threadMetadata = new Map>(); const store = reactive({ messages, isStreaming, - resourceRegistry, + producedArtifacts, + resourceNameIndex, currentThreadId: 'thread-1', getThreadMetadata: (threadId: string) => threadMetadata.get(threadId), updateThreadMetadata: async (threadId: string, metadata: Record) => { @@ -213,21 +215,34 @@ export async function createInstanceAiHarness(): Promise { // --- Convenience actions --- function registerWorkflow(id: string, name = `Workflow ${id}`) { - const next = new Map(store.resourceRegistry); - next.set(name.toLowerCase(), { type: 'workflow', id, name }); - store.resourceRegistry = next; + const entry: ResourceEntry = { type: 'workflow', id, name }; + const nextProduced = new Map(store.producedArtifacts); + nextProduced.set(id, entry); + store.producedArtifacts = nextProduced; + const nextByName = new Map(store.resourceNameIndex); + nextByName.set(name.toLowerCase(), entry); + store.resourceNameIndex = nextByName; } function registerDataTable(id: string, name = `Table ${id}`, projectId?: string) { - const next = new Map(store.resourceRegistry); - next.set(name.toLowerCase(), { type: 'data-table', id, name, projectId }); - store.resourceRegistry = next; + const entry: ResourceEntry = { type: 'data-table', id, name, projectId }; + const nextProduced = new Map(store.producedArtifacts); + nextProduced.set(id, entry); + store.producedArtifacts = nextProduced; + const nextByName = new Map(store.resourceNameIndex); + nextByName.set(name.toLowerCase(), entry); + store.resourceNameIndex = nextByName; } - function removeResource(key: string) { - const next = new Map(store.resourceRegistry); - next.delete(key); - store.resourceRegistry = next; + function removeResource(idOrName: string) { + const nextProduced = new Map(store.producedArtifacts); + const removed = nextProduced.get(idOrName); + nextProduced.delete(idOrName); + store.producedArtifacts = nextProduced; + const nextByName = new Map(store.resourceNameIndex); + if (removed) nextByName.delete(removed.name.toLowerCase()); + nextByName.delete(idOrName.toLowerCase()); + store.resourceNameIndex = nextByName; } function simulatePushEvent(event: PushMessage) { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts index afdaa7840ec..22ed05db77a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useCanvasPreview.test.ts @@ -62,13 +62,15 @@ function makeMessage(overrides: Partial = {}): InstanceAiMess function createMockStore() { const messages = ref([]) as Ref; const isStreaming = ref(false); - const resourceRegistry = ref(new Map()); + const producedArtifacts = ref(new Map()); + const resourceNameIndex = ref(new Map()); const threadMetadata = new Map>(); return reactive({ messages, isStreaming, - resourceRegistry, + producedArtifacts, + resourceNameIndex, currentThreadId: 'thread-1', getThreadMetadata: (threadId: string) => threadMetadata.get(threadId), updateThreadMetadata: async (threadId: string, metadata: Record) => { @@ -80,20 +82,28 @@ function createMockStore() { type MockStore = ReturnType; // --------------------------------------------------------------------------- -// Registry helpers — populate the store's resourceRegistry so computed +// Registry helpers — populate the store's producedArtifacts so computed // activeWorkflowId / activeDataTableId can derive values from tabs. // --------------------------------------------------------------------------- function registerWorkflow(store: MockStore, id: string, name = `Workflow ${id}`) { - const next = new Map(store.resourceRegistry); - next.set(name.toLowerCase(), { type: 'workflow', id, name }); - store.resourceRegistry = next; + const entry: ResourceEntry = { type: 'workflow', id, name }; + const nextProduced = new Map(store.producedArtifacts); + nextProduced.set(id, entry); + store.producedArtifacts = nextProduced; + const nextByName = new Map(store.resourceNameIndex); + nextByName.set(name.toLowerCase(), entry); + store.resourceNameIndex = nextByName; } function registerDataTable(store: MockStore, id: string, name = `Table ${id}`, projectId?: string) { - const next = new Map(store.resourceRegistry); - next.set(name.toLowerCase(), { type: 'data-table', id, name, projectId }); - store.resourceRegistry = next; + const entry: ResourceEntry = { type: 'data-table', id, name, projectId }; + const nextProduced = new Map(store.producedArtifacts); + nextProduced.set(id, entry); + store.producedArtifacts = nextProduced; + const nextByName = new Map(store.resourceNameIndex); + nextByName.set(name.toLowerCase(), entry); + store.resourceNameIndex = nextByName; } // --------------------------------------------------------------------------- @@ -270,9 +280,9 @@ describe('useCanvasPreview', () => { test('excludes credential entries', () => { const ctx = setup(); const registry = new Map(); - registry.set('wf', { type: 'workflow', id: 'wf-1', name: 'WF' }); - registry.set('cred', { type: 'credential', id: 'cred-1', name: 'Cred' }); - ctx.store.resourceRegistry = registry; + registry.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'WF' }); + registry.set('cred-1', { type: 'credential', id: 'cred-1', name: 'Cred' }); + ctx.store.producedArtifacts = registry; expect(ctx.allArtifactTabs.value).toHaveLength(1); expect(ctx.allArtifactTabs.value[0].type).toBe('workflow'); @@ -686,7 +696,7 @@ describe('useCanvasPreview', () => { expect(ctx.activeDataTableId.value).toBeNull(); }); - test('looks up projectId from resourceRegistry', async () => { + test('looks up projectId from producedArtifacts', async () => { const ctx = setup(); ctx.store.isStreaming = true; registerDataTable(ctx.store, 'dt-1', 'Test Table', 'proj-42'); @@ -914,8 +924,8 @@ describe('useCanvasPreview', () => { // Remove wf-2 from registry, keeping wf-1 const next = new Map(); - next.set('workflow wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' }); - ctx.store.resourceRegistry = next; + next.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Workflow wf-1' }); + ctx.store.producedArtifacts = next; await nextTick(); expect(ctx.activeTabId.value).toBe('wf-1'); @@ -928,7 +938,7 @@ describe('useCanvasPreview', () => { await nextTick(); // Temporarily empty registry (simulates race where registry hasn't been populated yet) - ctx.store.resourceRegistry = new Map(); + ctx.store.producedArtifacts = new Map(); await nextTick(); // Tab should remain set — guard skips when tabs are empty diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts index 2d1cd513301..1b7782b5529 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useResourceRegistry.test.ts @@ -6,6 +6,7 @@ import type { InstanceAiToolCallState, } from '@n8n/api-types'; import { useResourceRegistry } from '../useResourceRegistry'; +import type { ResourceEntry } from '../useResourceRegistry'; // --------------------------------------------------------------------------- // Factories @@ -47,16 +48,11 @@ function makeMessage(overrides: Partial = {}): InstanceAiMess function setup(workflowNameLookup?: (id: string) => string | undefined) { const messages = ref([]); - const { registry } = useResourceRegistry(() => messages.value, workflowNameLookup); - return { messages, registry }; -} - -/** Helper to find a registry entry by resource ID. */ -function findById(registry: Map, id: string) { - for (const entry of registry.values()) { - if ((entry as { id: string }).id === id) return entry; - } - return undefined; + const { producedArtifacts, resourceNameIndex } = useResourceRegistry( + () => messages.value, + workflowNameLookup, + ); + return { messages, producedArtifacts, resourceNameIndex }; } // --------------------------------------------------------------------------- @@ -64,9 +60,9 @@ function findById(registry: Map, id: string) { // --------------------------------------------------------------------------- describe('useResourceRegistry', () => { - describe('workflow registration', () => { + describe('producedArtifacts — workflow registration', () => { test('registers workflow with workflowName from result', async () => { - const { messages, registry } = setup(); + const { messages, producedArtifacts, resourceNameIndex } = setup(); messages.value = [ makeMessage({ @@ -82,13 +78,14 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - expect(registry.value.get('my workflow')).toEqual( + expect(producedArtifacts.value.get('wf-1')).toEqual( expect.objectContaining({ type: 'workflow', id: 'wf-1', name: 'My Workflow' }), ); + expect(resourceNameIndex.value.get('my workflow')?.id).toBe('wf-1'); }); test('falls back to args.name when result has no workflowName', async () => { - const { messages, registry } = setup(); + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ @@ -105,13 +102,13 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - expect(registry.value.get('from args')).toEqual( + expect(producedArtifacts.value.get('wf-2')).toEqual( expect.objectContaining({ type: 'workflow', id: 'wf-2', name: 'From Args' }), ); }); test('falls back to Untitled when neither workflowName nor args.name is present', async () => { - const { messages, registry } = setup(); + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ @@ -128,15 +125,13 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - // Keyed by workflowId when unnamed (avoids collisions between multiple unnamed workflows) - const entry = registry.value.get('wf-3'); - expect(entry).toEqual( + expect(producedArtifacts.value.get('wf-3')).toEqual( expect.objectContaining({ type: 'workflow', id: 'wf-3', name: 'Untitled' }), ); }); test('does not collide when multiple workflows have no name', async () => { - const { messages, registry } = setup(); + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ @@ -158,66 +153,196 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - // Both should be registered, keyed by their IDs - expect(findById(registry.value, 'wf-a')).toEqual( - expect.objectContaining({ type: 'workflow', id: 'wf-a', name: 'Untitled' }), - ); - expect(findById(registry.value, 'wf-b')).toEqual( - expect.objectContaining({ type: 'workflow', id: 'wf-b', name: 'Untitled' }), - ); + expect(producedArtifacts.value.get('wf-a')?.id).toBe('wf-a'); + expect(producedArtifacts.value.get('wf-b')?.id).toBe('wf-b'); }); + }); - test('registers workflow from build-workflow-with-agent child calls', async () => { - const { messages, registry } = setup(); - - // Simulates the real scenario: parent is build-workflow-with-agent, - // child create call has name but no workflowId, - // child patch call has workflowId but no name. - const childCreate = makeAgentNode({ - agentId: 'builder', - toolCalls: [ - makeToolCall({ - toolCallId: 'tc-create', - toolName: 'build-workflow', - args: { name: 'Insert Random City Data' }, - result: { success: true, errors: [] }, - }), - makeToolCall({ - toolCallId: 'tc-patch', - toolName: 'build-workflow', - args: { patches: [{ op: 'replace' }] }, - result: { success: true, workflowId: 'wf-built' }, - }), - ], - }); + describe('producedArtifacts — updates and merges', () => { + test('second write to the same workflow id updates the existing entry', async () => { + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ agentTree: makeAgentNode({ toolCalls: [ makeToolCall({ - toolName: 'build-workflow-with-agent', - result: { result: 'done', taskId: 'task-1' }, + toolCallId: 'tc-1', + toolName: 'build-workflow', + args: { name: 'Initial' }, + result: { workflowId: 'wf-1' }, + }), + makeToolCall({ + toolCallId: 'tc-2', + toolName: 'submit-workflow', + result: { workflowId: 'wf-1', workflowName: 'Renamed' }, }), ], - children: [childCreate], }), }), ]; await nextTick(); - // The patch call registers the workflow with 'Untitled' fallback - // (since its args only have patches, not name) - const entry = findById(registry.value, 'wf-built'); - expect(entry).toBeDefined(); - expect((entry as { id: string }).id).toBe('wf-built'); + expect(producedArtifacts.value.size).toBe(1); + expect(producedArtifacts.value.get('wf-1')?.name).toBe('Renamed'); + }); + + test('patch call without a name preserves the existing name (no regression to Untitled)', async () => { + const { messages, producedArtifacts } = setup(); + + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolCallId: 'tc-create', + toolName: 'build-workflow', + args: { name: 'Keep Me' }, + result: { workflowId: 'wf-1' }, + }), + makeToolCall({ + toolCallId: 'tc-patch', + toolName: 'build-workflow', + args: { patches: [{ op: 'replace' }] }, + result: { success: true, workflowId: 'wf-1' }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.get('wf-1')?.name).toBe('Keep Me'); + }); + + test('mutation result enriches an existing data-table entry with projectId', async () => { + const { messages, producedArtifacts } = setup(); + + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'data-tables', + args: { action: 'create' }, + result: { table: { id: 'dt-1', name: 'Signups' } }, + }), + makeToolCall({ + toolName: 'insert-data-table-rows', + result: { + insertedCount: 5, + dataTableId: 'dt-1', + tableName: 'Signups', + projectId: 'proj-2', + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.size).toBe(1); + expect(producedArtifacts.value.get('dt-1')).toEqual( + expect.objectContaining({ + type: 'data-table', + id: 'dt-1', + name: 'Signups', + projectId: 'proj-2', + }), + ); + }); + }); + + describe('list results do not populate producedArtifacts', () => { + test('workflows action=list result is indexed by name only, never in producedArtifacts', async () => { + const { messages, producedArtifacts, resourceNameIndex } = setup(); + + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'workflows', + args: { action: 'list' }, + result: { + workflows: [ + { id: 'wf-list-1', name: 'Workspace Workflow 1' }, + { id: 'wf-list-2', name: 'Workspace Workflow 2' }, + ], + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.size).toBe(0); + expect(resourceNameIndex.value.get('workspace workflow 1')?.id).toBe('wf-list-1'); + expect(resourceNameIndex.value.get('workspace workflow 2')?.id).toBe('wf-list-2'); + }); + + test('data-tables action=list result is indexed by name only', async () => { + const { messages, producedArtifacts, resourceNameIndex } = setup(); + + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'data-tables', + args: { action: 'list' }, + result: { + tables: [{ id: 'dt-a', name: 'Existing Table' }], + }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.size).toBe(0); + expect(resourceNameIndex.value.get('existing table')?.id).toBe('dt-a'); + }); + + test('a later write promotes a previously-listed resource into producedArtifacts', async () => { + const { messages, producedArtifacts, resourceNameIndex } = setup(); + + messages.value = [ + makeMessage({ + agentTree: makeAgentNode({ + toolCalls: [ + makeToolCall({ + toolName: 'workflows', + args: { action: 'list' }, + result: { workflows: [{ id: 'wf-1', name: 'Existing' }] }, + }), + makeToolCall({ + toolName: 'build-workflow', + args: { patches: [{ op: 'replace' }] }, + result: { workflowId: 'wf-1' }, + }), + ], + }), + }), + ]; + await nextTick(); + + expect(producedArtifacts.value.size).toBe(1); + // Produced entry keeps the 'Existing' name via fallback-to-untitled + // avoidance — the patch call has no name of its own. + expect(producedArtifacts.value.get('wf-1')?.name).toBe('Untitled'); + // Name index still resolves 'existing' + expect(resourceNameIndex.value.get('existing')?.id).toBe('wf-1'); }); }); describe('workflowNameLookup enrichment', () => { test('enriches fallback name from store lookup', async () => { const lookup = (id: string) => (id === 'wf-3' ? 'Insert Random City Data' : undefined); - const { messages, registry } = setup(lookup); + const { messages, producedArtifacts, resourceNameIndex } = setup(lookup); messages.value = [ makeMessage({ @@ -234,21 +359,14 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - // Should be keyed by the enriched name, not the workflowId - expect(registry.value.has('wf-3')).toBe(false); - const entry = registry.value.get('insert random city data'); - expect(entry).toEqual( - expect.objectContaining({ - type: 'workflow', - id: 'wf-3', - name: 'Insert Random City Data', - }), - ); + expect(producedArtifacts.value.get('wf-3')?.name).toBe('Insert Random City Data'); + expect(resourceNameIndex.value.get('insert random city data')?.id).toBe('wf-3'); + expect(resourceNameIndex.value.get('untitled')).toBeUndefined(); }); test('keeps original name when lookup returns undefined', async () => { const lookup = () => undefined; - const { messages, registry } = setup(lookup); + const { messages, producedArtifacts } = setup(lookup); messages.value = [ makeMessage({ @@ -264,38 +382,12 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - expect(registry.value.get('original name')).toEqual( - expect.objectContaining({ name: 'Original Name' }), - ); - }); - - test('does not enrich when store name matches existing name', async () => { - const lookup = (id: string) => (id === 'wf-5' ? 'My Workflow' : undefined); - const { messages, registry } = setup(lookup); - - messages.value = [ - makeMessage({ - agentTree: makeAgentNode({ - toolCalls: [ - makeToolCall({ - toolName: 'build-workflow', - result: { workflowId: 'wf-5', workflowName: 'My Workflow' }, - }), - ], - }), - }), - ]; - await nextTick(); - - // Key should remain the same - expect(registry.value.get('my workflow')).toEqual( - expect.objectContaining({ id: 'wf-5', name: 'My Workflow' }), - ); + expect(producedArtifacts.value.get('wf-4')?.name).toBe('Original Name'); }); test('does not enrich data-table entries', async () => { const lookup = () => 'Should Not Apply'; - const { messages, registry } = setup(lookup); + const { messages, producedArtifacts } = setup(lookup); messages.value = [ makeMessage({ @@ -312,9 +404,7 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - expect(registry.value.get('cities1')).toEqual( - expect.objectContaining({ type: 'data-table', name: 'cities1' }), - ); + expect(producedArtifacts.value.get('dt-1')?.name).toBe('cities1'); }); }); @@ -322,7 +412,7 @@ describe('useResourceRegistry', () => { test.each(['insert-data-table-rows', 'update-data-table-rows', 'delete-data-table-rows'])( 'registers data table from %s result with name and projectId', async (toolName) => { - const { messages, registry } = setup(); + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ @@ -342,7 +432,7 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - const entry = registry.value.get('orders'); + const entry = producedArtifacts.value.get('dt-mut-1') as ResourceEntry; expect(entry).toEqual({ type: 'data-table', id: 'dt-mut-1', @@ -352,47 +442,8 @@ describe('useResourceRegistry', () => { }, ); - test('enriches existing registry entry with projectId from mutation result', async () => { - const { messages, registry } = setup(); - - messages.value = [ - makeMessage({ - agentTree: makeAgentNode({ - toolCalls: [ - // First: create-data-table registers the table (without projectId) - makeToolCall({ - toolName: 'create-data-table', - result: { table: { id: 'dt-enrich', name: 'Signups' } }, - }), - // Then: insert-data-table-rows adds projectId - makeToolCall({ - toolName: 'insert-data-table-rows', - result: { - insertedCount: 5, - dataTableId: 'dt-enrich', - tableName: 'Signups', - projectId: 'proj-2', - }, - }), - ], - }), - }), - ]; - await nextTick(); - - const entry = registry.value.get('signups'); - expect(entry).toEqual( - expect.objectContaining({ - type: 'data-table', - id: 'dt-enrich', - name: 'Signups', - projectId: 'proj-2', - }), - ); - }); - test('uses dataTableId as fallback name when tableName is missing', async () => { - const { messages, registry } = setup(); + const { messages, producedArtifacts } = setup(); messages.value = [ makeMessage({ @@ -412,8 +463,7 @@ describe('useResourceRegistry', () => { ]; await nextTick(); - const entry = registry.value.get('dt-no-name'); - expect(entry).toEqual({ + expect(producedArtifacts.value.get('dt-no-name')).toEqual({ type: 'data-table', id: 'dt-no-name', name: 'dt-no-name', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue index 8cea1aa2398..ea9101eb0d1 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentActivityTree.vue @@ -46,10 +46,8 @@ const lastGroupIdx = computed(() => { }); function resolveArtifactName(artifact: ArtifactInfo): string { - for (const entry of store.resourceRegistry.values()) { - if (entry.id === artifact.resourceId) return entry.name; - } - return artifact.name; + const entry = store.producedArtifacts.get(artifact.resourceId); + return entry?.name ?? artifact.name; } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue index bfc7920407f..ac374560aa6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AgentTimeline.vue @@ -28,10 +28,8 @@ const rootStore = useRootStore(); /** Resolve artifact name from the enriched registry (falls back to extracted name). */ function resolveArtifactName(artifact: ArtifactInfo): string { - for (const entry of store.resourceRegistry.values()) { - if (entry.id === artifact.resourceId) return entry.name; - } - return artifact.name; + const entry = store.producedArtifacts.get(artifact.resourceId); + return entry?.name ?? artifact.name; } function formatRelativeTime(isoTime: string): string { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue index 89f75ca6f4c..104a439f937 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiArtifactsPanel.vue @@ -55,7 +55,7 @@ const statusIconMap: Record< // --- Artifacts --- const artifacts = computed((): ResourceEntry[] => { const result: ResourceEntry[] = []; - for (const entry of store.resourceRegistry.values()) { + for (const entry of store.producedArtifacts.values()) { if (entry.type === 'workflow' || entry.type === 'data-table') { result.push(entry); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue index 81ddd03d77f..81f482ad47f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiMarkdown.vue @@ -51,7 +51,7 @@ const INTERNAL_BLOCK_PATTERN = /<(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)[\s\S]*?<\/(?:planning-blueprint|planned-task-follow-up|background-task-completed|running-tasks)>/g; const processedContent = computed(() => { - const registry = store.resourceRegistry; + const registry = store.resourceNameIndex; // Strip internal protocol blocks the LLM may have echoed let result = props.content.replace(INTERNAL_BLOCK_PATTERN, '').trim(); @@ -149,10 +149,15 @@ function enhanceResourceLinks(): void { if (resourceMatch) { const [, type, id] = resourceMatch; - // Look up registry entry (needed for projectId on data-table links) + // Look up registry entry (needed for projectId on data-table links). + // Search the name index because it contains both produced and listed + // resources — a user may click through to a data table the agent + // only referenced via a list call. const registryEntry = type === 'data-table' - ? [...store.resourceRegistry.values()].find((r) => r.type === 'data-table' && r.id === id) + ? [...store.resourceNameIndex.values()].find( + (r) => r.type === 'data-table' && r.id === id, + ) : undefined; // Swap href to the real app URL (used for Cmd+click / new tab) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts index 4487d0465e7..1958b3becdf 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.store.ts @@ -160,9 +160,12 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { const gatewayDirectory = computed(() => instanceAiSettingsStore.gatewayDirectory); const activeDirectory = computed(() => gatewayDirectory.value); - // Resource registry — maps known resource names to their types & IDs + // Resource registry — two collections derived from tool-call results: + // * producedArtifacts: resources the agent built/created/mutated (panel). + // * resourceNameIndex: every named resource seen, keyed by lowercased name + // (markdown linking). const workflowsListStore = useWorkflowsListStore(); - const { registry: resourceRegistry } = useResourceRegistry( + const { producedArtifacts, resourceNameIndex } = useResourceRegistry( () => messages.value, (id) => workflowsListStore.getWorkflowById(id)?.name, ); @@ -1027,7 +1030,8 @@ export const useInstanceAiStore = defineStore('instanceAi', () => { activeDirectory, contextualSuggestion, currentTasks, - resourceRegistry, + producedArtifacts, + resourceNameIndex, rateableResponseId, creditsRemaining, creditsPercentageRemaining, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts index 91c6dbb0d68..53ffcd3c5ec 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useCanvasPreview.ts @@ -87,7 +87,7 @@ export function useCanvasPreview({ store, route, workflowExecutions }: UseCanvas const result: ArtifactTab[] = []; const liveExecMap = workflowExecutions?.value; const historicalExecMap = historicalExecutions.value; - for (const entry of store.resourceRegistry.values()) { + for (const entry of store.producedArtifacts.values()) { if (entry.type === 'workflow' || entry.type === 'data-table') { // Live push event state takes priority over historical message data. // Historical data already has stale executions filtered out. diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts index f4863d59d36..e85631d3da7 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/useResourceRegistry.ts @@ -18,18 +18,61 @@ export interface ResourceEntry { // Internal helpers (defined before use to satisfy no-use-before-define) // --------------------------------------------------------------------------- -function registerResource( - map: Map, +interface Collections { + /** Resources produced/mutated by the agent in this thread, keyed by resource ID. */ + produced: Map; + /** Every resource seen in any tool call, keyed by lowercased name — for markdown linking. */ + byName: Map; +} + +function optionalString(val: unknown): string | undefined { + return typeof val === 'string' ? val : undefined; +} + +/** + * Upsert a produced artifact. When an entry for the same `id` already exists, + * optional fields provided by the new call win; fields it omits are preserved + * from the existing entry. Callers are responsible for resolving `name` using + * the existing entry as a fallback so partial updates (e.g. a patch + * `build-workflow` call that carries only a `workflowId`) don't regress a + * known name to 'Untitled'. + */ +function recordProduced(col: Collections, entry: ResourceEntry): void { + const existing = col.produced.get(entry.id); + const merged: ResourceEntry = existing + ? { + type: entry.type, + id: entry.id, + name: entry.name, + createdAt: entry.createdAt ?? existing.createdAt, + updatedAt: entry.updatedAt ?? existing.updatedAt, + projectId: entry.projectId ?? existing.projectId, + } + : entry; + col.produced.set(entry.id, merged); + if (existing && existing.name.toLowerCase() !== merged.name.toLowerCase()) { + col.byName.delete(existing.name.toLowerCase()); + } + col.byName.set(merged.name.toLowerCase(), merged); +} + +function indexByName(col: Collections, entry: ResourceEntry): void { + col.byName.set(entry.name.toLowerCase(), entry); +} + +function entryFromListItem( type: ResourceEntry['type'], obj: Record, -): void { - if (typeof obj.name === 'string' && typeof obj.id === 'string') { - const entry: ResourceEntry = { type, id: obj.id, name: obj.name }; - if (typeof obj.createdAt === 'string') entry.createdAt = obj.createdAt; - if (typeof obj.updatedAt === 'string') entry.updatedAt = obj.updatedAt; - if (typeof obj.projectId === 'string') entry.projectId = obj.projectId; - map.set(obj.name.toLowerCase(), entry); - } +): ResourceEntry | undefined { + if (typeof obj.name !== 'string' || typeof obj.id !== 'string') return undefined; + const entry: ResourceEntry = { type, id: obj.id, name: obj.name }; + const createdAt = optionalString(obj.createdAt); + const updatedAt = optionalString(obj.updatedAt); + const projectId = optionalString(obj.projectId); + if (createdAt !== undefined) entry.createdAt = createdAt; + if (updatedAt !== undefined) entry.updatedAt = updatedAt; + if (projectId !== undefined) entry.projectId = projectId; + return entry; } /** Tools whose results may contain resource info (workflows, credentials, data tables). */ @@ -47,97 +90,127 @@ const ARTIFACT_TOOLS = new Set([ 'delete-data-table-rows', ]); -function extractFromToolCall(tc: InstanceAiToolCallState, map: Map): void { +function extractFromToolCall(tc: InstanceAiToolCallState, col: Collections): void { if (!ARTIFACT_TOOLS.has(tc.toolName)) return; if (!tc.result || typeof tc.result !== 'object') return; const result = tc.result as Record; // --- Workflows -------------------------------------------------------- - // Tool results that list workflows: { workflows: [{ id, name }, ...] } + // List result: { workflows: [{ id, name }, ...] } — index by name only. if (Array.isArray(result.workflows)) { for (const wf of result.workflows as Array>) { - registerResource(map, 'workflow', wf); + const entry = entryFromListItem('workflow', wf); + if (entry) indexByName(col, entry); } } - // build-workflow / build-workflow-with-agent result: - // { workflowId: "...", workflowName?: "..." } + // build-workflow / build-workflow-with-agent / submit-workflow: + // { workflowId, workflowName? } — produced. Patch calls may omit the name, + // so fall back to the existing entry before regressing to 'Untitled'. if (typeof result.workflowId === 'string') { + const existing = col.produced.get(result.workflowId); const name = - typeof result.workflowName === 'string' - ? result.workflowName - : typeof tc.args?.name === 'string' - ? tc.args.name - : undefined; - - const resolvedName = name ?? 'Untitled'; - // Key by workflowId when unnamed to avoid collisions between multiple - // unnamed workflows that would all map to the "untitled" key. - const key = name ? name.toLowerCase() : result.workflowId; - map.set(key, { - type: 'workflow', - id: result.workflowId, - name: resolvedName, - }); + optionalString(result.workflowName) ?? + optionalString(tc.args?.name) ?? + existing?.name ?? + 'Untitled'; + recordProduced(col, { type: 'workflow', id: result.workflowId, name }); } - // Single workflow object: { workflow: { id, name } } + // Single workflow object: { workflow: { id, name, ... } } — produced. if (result.workflow && typeof result.workflow === 'object') { - registerResource(map, 'workflow', result.workflow as Record); + const obj = result.workflow as Record; + if (typeof obj.id === 'string') { + const existing = col.produced.get(obj.id); + const name = optionalString(obj.name) ?? existing?.name ?? 'Untitled'; + const entry: ResourceEntry = { type: 'workflow', id: obj.id, name }; + const createdAt = optionalString(obj.createdAt); + const updatedAt = optionalString(obj.updatedAt); + const projectId = optionalString(obj.projectId); + if (createdAt !== undefined) entry.createdAt = createdAt; + if (updatedAt !== undefined) entry.updatedAt = updatedAt; + if (projectId !== undefined) entry.projectId = projectId; + recordProduced(col, entry); + } } // --- Credentials ----------------------------------------------------- + // Credentials never show in the panel; only needed for name linking. if (Array.isArray(result.credentials)) { for (const cred of result.credentials as Array>) { - registerResource(map, 'credential', cred); + const entry = entryFromListItem('credential', cred); + if (entry) indexByName(col, entry); } } // --- Data tables ----------------------------------------------------- + // List results — index by name only. if (Array.isArray(result.tables)) { for (const table of result.tables as Array>) { - registerResource(map, 'data-table', table); + const entry = entryFromListItem('data-table', table); + if (entry) indexByName(col, entry); } } if (Array.isArray(result.dataTables)) { for (const table of result.dataTables as Array>) { - registerResource(map, 'data-table', table); + const entry = entryFromListItem('data-table', table); + if (entry) indexByName(col, entry); } } - // Singular data table (e.g. create-data-table result) + // Singular data table (e.g. data-tables action=create) — produced. if (result.table && typeof result.table === 'object') { - registerResource(map, 'data-table', result.table as Record); + const obj = result.table as Record; + if (typeof obj.id === 'string') { + const existing = col.produced.get(obj.id); + const name = optionalString(obj.name) ?? existing?.name ?? obj.id; + const entry: ResourceEntry = { type: 'data-table', id: obj.id, name }; + const createdAt = optionalString(obj.createdAt); + const updatedAt = optionalString(obj.updatedAt); + const projectId = optionalString(obj.projectId); + if (createdAt !== undefined) entry.createdAt = createdAt; + if (updatedAt !== undefined) entry.updatedAt = updatedAt; + if (projectId !== undefined) entry.projectId = projectId; + recordProduced(col, entry); + } } - // Data table mutation results (insert/update/delete-data-table-rows) - // These return { dataTableId, projectId } without a nested table object. - // Merge projectId into existing registry entry or create a minimal one. + // Data table mutation results (insert/update/delete-data-table-rows): + // { dataTableId, projectId, tableName? } — produced. Preserves an + // existing name if the mutation result doesn't carry `tableName`. if (typeof result.dataTableId === 'string' && typeof result.projectId === 'string') { - const existingEntry = [...map.values()].find( - (e) => e.type === 'data-table' && e.id === result.dataTableId, - ); - if (existingEntry) { - existingEntry.projectId = result.projectId as string; - } else { - const tableName = - typeof result.tableName === 'string' ? result.tableName : (result.dataTableId as string); - map.set(tableName.toLowerCase(), { - type: 'data-table', - id: result.dataTableId as string, - name: tableName, - projectId: result.projectId as string, - }); - } + const existing = col.produced.get(result.dataTableId); + const name = optionalString(result.tableName) ?? existing?.name ?? result.dataTableId; + recordProduced(col, { + type: 'data-table', + id: result.dataTableId, + name, + projectId: result.projectId, + }); } } -function collectFromAgentNode(node: InstanceAiAgentNode, map: Map): void { +function collectFromAgentNode(node: InstanceAiAgentNode, col: Collections): void { for (const tc of node.toolCalls) { - extractFromToolCall(tc, map); + extractFromToolCall(tc, col); } for (const child of node.children) { - collectFromAgentNode(child, map); + collectFromAgentNode(child, col); + } +} + +function enrichWorkflowNames( + col: Collections, + workflowNameLookup: (id: string) => string | undefined, +): void { + for (const entry of col.produced.values()) { + if (entry.type !== 'workflow') continue; + const storeName = workflowNameLookup(entry.id); + if (storeName && storeName !== entry.name) { + col.byName.delete(entry.name.toLowerCase()); + entry.name = storeName; + col.byName.set(storeName.toLowerCase(), entry); + } } } @@ -146,39 +219,42 @@ function collectFromAgentNode(node: InstanceAiAgentNode, map: Map InstanceAiMessage[], workflowNameLookup?: (id: string) => string | undefined, ) { - const registry = computed(() => { - const map = new Map(); + const collections = computed((): Collections => { + const col: Collections = { + produced: new Map(), + byName: new Map(), + }; for (const msg of messages()) { if (!msg.agentTree) continue; - collectFromAgentNode(msg.agentTree, map); + collectFromAgentNode(msg.agentTree, col); } - // Enrich workflow names from the store when the registry only has a - // fallback (e.g. 'Untitled' from a patch-only build-workflow call). if (workflowNameLookup) { - for (const [key, entry] of map) { - if (entry.type !== 'workflow') continue; - const storeName = workflowNameLookup(entry.id); - if (storeName && storeName !== entry.name) { - map.delete(key); - map.set(storeName.toLowerCase(), { ...entry, name: storeName }); - } - } + enrichWorkflowNames(col, workflowNameLookup); } - return map; + return col; }); - return { registry }; + return { + producedArtifacts: computed(() => collections.value.produced), + resourceNameIndex: computed(() => collections.value.byName), + }; }