fix(ai-builder): Scope artifacts panel to resources produced in-thread (#28678)

This commit is contained in:
Albert Alises 2026-04-20 12:11:46 +02:00 committed by GitHub
parent 35f9bed4de
commit 7b3696f3f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 423 additions and 267 deletions

View file

@ -36,7 +36,7 @@ describe('InstanceAiMarkdown', () => {
function getProcessedContent(content: string, registry?: Map<string, ResourceEntry>): string {
if (registry) {
store.resourceRegistry = registry;
store.resourceNameIndex = registry;
}
const { getByTestId } = renderComponent({ props: { content } });
return getByTestId('markdown-output').textContent ?? '';

View file

@ -163,14 +163,16 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
// --- Mock store ---
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
const isStreaming = ref(false);
const resourceRegistry = ref(new Map<string, ResourceEntry>());
const producedArtifacts = ref(new Map<string, ResourceEntry>());
const resourceNameIndex = ref(new Map<string, ResourceEntry>());
const threadMetadata = new Map<string, Record<string, unknown>>();
const store = reactive({
messages,
isStreaming,
resourceRegistry,
producedArtifacts,
resourceNameIndex,
currentThreadId: 'thread-1',
getThreadMetadata: (threadId: string) => threadMetadata.get(threadId),
updateThreadMetadata: async (threadId: string, metadata: Record<string, unknown>) => {
@ -213,21 +215,34 @@ export async function createInstanceAiHarness(): Promise<InstanceAiHarness> {
// --- 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) {

View file

@ -62,13 +62,15 @@ function makeMessage(overrides: Partial<InstanceAiMessage> = {}): InstanceAiMess
function createMockStore() {
const messages = ref<InstanceAiMessage[]>([]) as Ref<InstanceAiMessage[]>;
const isStreaming = ref(false);
const resourceRegistry = ref(new Map<string, ResourceEntry>());
const producedArtifacts = ref(new Map<string, ResourceEntry>());
const resourceNameIndex = ref(new Map<string, ResourceEntry>());
const threadMetadata = new Map<string, Record<string, unknown>>();
return reactive({
messages,
isStreaming,
resourceRegistry,
producedArtifacts,
resourceNameIndex,
currentThreadId: 'thread-1',
getThreadMetadata: (threadId: string) => threadMetadata.get(threadId),
updateThreadMetadata: async (threadId: string, metadata: Record<string, unknown>) => {
@ -80,20 +82,28 @@ function createMockStore() {
type MockStore = ReturnType<typeof createMockStore>;
// ---------------------------------------------------------------------------
// 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<string, ResourceEntry>();
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<string, ResourceEntry>();
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

View file

@ -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<InstanceAiMessage> = {}): InstanceAiMess
function setup(workflowNameLookup?: (id: string) => string | undefined) {
const messages = ref<InstanceAiMessage[]>([]);
const { registry } = useResourceRegistry(() => messages.value, workflowNameLookup);
return { messages, registry };
}
/** Helper to find a registry entry by resource ID. */
function findById(registry: Map<string, unknown>, 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<string, unknown>, 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',

View file

@ -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;
}
</script>

View file

@ -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 {

View file

@ -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);
}

View file

@ -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)

View file

@ -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,

View file

@ -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.

View file

@ -18,18 +18,61 @@ export interface ResourceEntry {
// Internal helpers (defined before use to satisfy no-use-before-define)
// ---------------------------------------------------------------------------
function registerResource(
map: Map<string, ResourceEntry>,
interface Collections {
/** Resources produced/mutated by the agent in this thread, keyed by resource ID. */
produced: Map<string, ResourceEntry>;
/** Every resource seen in any tool call, keyed by lowercased name — for markdown linking. */
byName: Map<string, ResourceEntry>;
}
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<string, unknown>,
): 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<string, ResourceEntry>): 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<string, unknown>;
// --- 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<Record<string, unknown>>) {
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<string, unknown>);
const obj = result.workflow as Record<string, unknown>;
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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<Record<string, unknown>>) {
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<string, unknown>);
const obj = result.table as Record<string, unknown>;
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<string, ResourceEntry>): 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<string, Resour
// ---------------------------------------------------------------------------
/**
* Scans all tool-call results in the conversation to build a registry of
* known resource names (workflows, credentials, data tables) and their IDs.
* Scans tool-call results in the conversation and returns two collections:
*
* The registry key is the **lowercase** name so lookups during markdown
* post-processing are case-insensitive.
* - `producedArtifacts` (keyed by resource id) things the agent built,
* submitted, created, or mutated. Powers the Artifacts panel and the
* canvas preview tabs. Repeated writes to the same resource update the
* existing entry instead of creating a duplicate.
*
* - `resourceNameIndex` (keyed by lowercased name) every named resource
* seen in any tool call, including list results. Used only for markdown
* namelink replacement so references to listed workflows/tables still
* resolve.
*/
export function useResourceRegistry(
messages: () => InstanceAiMessage[],
workflowNameLookup?: (id: string) => string | undefined,
) {
const registry = computed(() => {
const map = new Map<string, ResourceEntry>();
const collections = computed((): Collections => {
const col: Collections = {
produced: new Map<string, ResourceEntry>(),
byName: new Map<string, ResourceEntry>(),
};
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),
};
}