mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Tell the AI Assistant which preview tabs are open (#39768)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fa93a7f01f
commit
10e484f552
|
|
@ -67,7 +67,7 @@ describe('Instance AI prompt version requests', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('accepts a thread artifact index and rejects an empty or oversized list', () => {
|
||||
it('accepts a thread artifact index, including an empty one, and rejects an oversized list', () => {
|
||||
const base = { message: 'Change this', timeZone: 'UTC' };
|
||||
expect(
|
||||
InstanceAiSendMessageRequest.safeParse({
|
||||
|
|
@ -83,7 +83,7 @@ describe('Instance AI prompt version requests', () => {
|
|||
...base,
|
||||
threadArtifacts: { artifacts: [] },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(
|
||||
InstanceAiSendMessageRequest.safeParse({
|
||||
...base,
|
||||
|
|
|
|||
|
|
@ -1649,9 +1649,12 @@ export const instanceAiThreadArtifactSchema = z.object({
|
|||
});
|
||||
export type InstanceAiThreadArtifact = z.infer<typeof instanceAiThreadArtifactSchema>;
|
||||
|
||||
/** The thread view's artifact tabs, plus which tab is focused when the preview is open. */
|
||||
/**
|
||||
* The tabs open in the thread view, plus which tab is focused when the preview is open.
|
||||
* An empty list means no tabs are open.
|
||||
*/
|
||||
export const instanceAiThreadArtifactsContextSchema = z.object({
|
||||
artifacts: z.array(instanceAiThreadArtifactSchema).min(1).max(20),
|
||||
artifacts: z.array(instanceAiThreadArtifactSchema).max(20),
|
||||
activeId: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
export type InstanceAiThreadArtifactsContext = z.infer<
|
||||
|
|
|
|||
|
|
@ -141,6 +141,17 @@ A request to build something genuinely new goes straight to the build path — n
|
|||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn sends the tabs block only when the open tabs change, so the model
|
||||
* needs to know what a user message without one means.
|
||||
*/
|
||||
function getPreviewTabsSection(): string {
|
||||
return `
|
||||
## Preview Tabs
|
||||
|
||||
The latest \`<thread-artifacts>\` block lists the tabs the user has open now. A user message without one means the tabs did not change. When a tab from an earlier block is missing from the latest one, the user closed it: you can still work on it if the user asks, but do not assume the user is looking at it.`;
|
||||
}
|
||||
|
||||
function getConversationRecallSection(): string {
|
||||
return `
|
||||
## Past Conversations
|
||||
|
|
@ -233,6 +244,7 @@ export function createSystemPromptRenderer(communicationStyleSection: string) {
|
|||
${workspaceRoot ? `${getSandboxWorkspaceSection(workspaceRoot)}` : ''}
|
||||
${getProjectScopeSection(projectId)}
|
||||
${getExistingResourcesSection()}
|
||||
${getPreviewTabsSection()}
|
||||
${conversationHistoryEnabled ? getConversationRecallSection() : ''}
|
||||
${preferenceSavingEnabled ? getPreferenceSavingSection() : ''}
|
||||
${SECRET_ASK_GUARDRAIL}
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ import {
|
|||
import { INSTANCE_AI_RUN_TIMEOUT_REASON } from '../liveness/instance-ai-liveness.service';
|
||||
import { InstanceAiRunLimitError } from '../instance-ai-run-limit.error';
|
||||
import { InstanceAiService } from '../instance-ai.service';
|
||||
import { buildThreadContextBlock } from '../internal-messages';
|
||||
import { buildThreadArtifactsBlock, buildThreadContextBlock } from '../internal-messages';
|
||||
import { InstanceAiSandboxService } from '../sandbox';
|
||||
import type {
|
||||
RebuildSuspendedRunOutcome,
|
||||
|
|
@ -5497,8 +5497,9 @@ describe('InstanceAiService — editor handoff context resources', () => {
|
|||
// Imported helpers compile to `(0,__vite_ssr_import_N__.fn)(args)`. Both
|
||||
// arguments must reach the block, or the editor hand-off drops out of it.
|
||||
expect(source).toMatch(
|
||||
/buildThreadArtifactsBlock\)?\s*\(\s*threadArtifacts\s*,\s*contextAttachments\s*\)/,
|
||||
/resolveThreadArtifactsTurn\(\s*threadId\s*,\s*threadArtifacts\s*,\s*contextAttachments\s*,/,
|
||||
);
|
||||
expect(source).toMatch(/buildThreadArtifactsBlock\)?\s*\(\s*context\s*,\s*attachments\s*\)/);
|
||||
expect(source).toMatch(/buildThreadContextBlock\)?/);
|
||||
expect(source).not.toContain('buildContextResourcesBlock');
|
||||
expect(source).not.toContain('EDITOR_CONTEXT_OPEN_TAG');
|
||||
|
|
@ -6582,6 +6583,165 @@ describe('InstanceAiService — internal follow-up failure streak', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiService — resolveThreadArtifactsTurn', () => {
|
||||
type Context = {
|
||||
artifacts: Array<{ type: 'workflow'; id: string; name?: string }>;
|
||||
activeId?: string;
|
||||
};
|
||||
type Internals = {
|
||||
resolveThreadArtifactsTurn: (
|
||||
threadId: string,
|
||||
context: Context | undefined,
|
||||
attachments: Array<{ type: 'workflow'; id: string; name: string }>,
|
||||
loadHistory: () => Promise<unknown[]>,
|
||||
) => Promise<string>;
|
||||
getReplayedMessages: (threadId: string) => Promise<unknown[]>;
|
||||
agentMemory: {
|
||||
getMessages: Mock;
|
||||
getCursor: Mock;
|
||||
getActiveObservationLog: Mock;
|
||||
getMessagesForObservationScope: Mock;
|
||||
};
|
||||
logger: { warn: Mock };
|
||||
};
|
||||
|
||||
function createService(): Internals {
|
||||
const service = Object.create(InstanceAiService.prototype) as unknown as Internals;
|
||||
service.agentMemory = {
|
||||
getMessages: vi.fn().mockResolvedValue([]),
|
||||
getCursor: vi.fn().mockResolvedValue(null),
|
||||
getActiveObservationLog: vi.fn().mockResolvedValue([]),
|
||||
getMessagesForObservationScope: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
service.logger = { warn: vi.fn() };
|
||||
return service;
|
||||
}
|
||||
|
||||
const digest = { type: 'workflow' as const, id: 'wf-1', name: 'Digest' };
|
||||
const report = { type: 'workflow' as const, id: 'wf-2', name: 'Report' };
|
||||
const storedUserTurn = (block: string) => ({
|
||||
role: 'user',
|
||||
content: [buildThreadContextBlock(['Ambient context.', block]), 'Change it'].join('\n\n'),
|
||||
});
|
||||
|
||||
async function resolve(
|
||||
service: Internals,
|
||||
context: Context | undefined,
|
||||
attachments: Array<{ type: 'workflow'; id: string; name: string }> = [],
|
||||
) {
|
||||
return await service.resolveThreadArtifactsTurn(
|
||||
'thread-1',
|
||||
context,
|
||||
attachments,
|
||||
async () => await service.getReplayedMessages('thread-1'),
|
||||
);
|
||||
}
|
||||
|
||||
it('sends nothing when the client sent no tabs', async () => {
|
||||
expect(await resolve(createService(), undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('sends the tabs when the history has no tabs block', async () => {
|
||||
const block = await resolve(createService(), { artifacts: [digest], activeId: 'wf-1' });
|
||||
|
||||
expect(block).toContain('<thread-artifacts>');
|
||||
expect(block).toContain('(id: `wf-1`) [current]');
|
||||
});
|
||||
|
||||
it('does not send the tabs again when they have not changed, also after a reorder', async () => {
|
||||
const service = createService();
|
||||
const earlier = buildThreadArtifactsBlock({ artifacts: [digest, report], activeId: 'wf-1' });
|
||||
service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]);
|
||||
|
||||
expect(await resolve(service, { artifacts: [digest, report], activeId: 'wf-1' })).toBe('');
|
||||
expect(await resolve(service, { artifacts: [report, digest], activeId: 'wf-1' })).toBe('');
|
||||
});
|
||||
|
||||
it('sends the tabs when a tab closed or the active tab changed', async () => {
|
||||
const service = createService();
|
||||
const earlier = buildThreadArtifactsBlock({ artifacts: [digest, report], activeId: 'wf-1' });
|
||||
service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]);
|
||||
|
||||
expect(await resolve(service, { artifacts: [digest], activeId: 'wf-1' })).toContain(
|
||||
'Workflow "Digest"',
|
||||
);
|
||||
expect(await resolve(service, { artifacts: [digest, report], activeId: 'wf-2' })).toContain(
|
||||
'(id: `wf-2`) [current]',
|
||||
);
|
||||
});
|
||||
|
||||
it('says no tabs are open once, then not again while nothing changes', async () => {
|
||||
const service = createService();
|
||||
const noTabs = await resolve(service, { artifacts: [] });
|
||||
expect(noTabs).toContain('The user has no tabs open in this conversation’s preview.');
|
||||
|
||||
service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(noTabs)]);
|
||||
expect(await resolve(service, { artifacts: [] })).toBe('');
|
||||
});
|
||||
|
||||
it('says no tabs are open after a block that listed tabs or only an editor hand-off', async () => {
|
||||
const service = createService();
|
||||
service.agentMemory.getMessages.mockResolvedValue([
|
||||
storedUserTurn(buildThreadArtifactsBlock({ artifacts: [digest] })),
|
||||
]);
|
||||
expect(await resolve(service, { artifacts: [] })).toContain('no tabs open');
|
||||
|
||||
const handoffOnly = buildThreadArtifactsBlock({ artifacts: [] }, [
|
||||
{ type: 'workflow' as const, id: 'wf-9', name: 'Handed off' },
|
||||
]);
|
||||
service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(handoffOnly)]);
|
||||
expect(await resolve(service, { artifacts: [] })).toContain('no tabs open');
|
||||
});
|
||||
|
||||
it('says no tabs are open again when the same block was compacted out of the replay window', async () => {
|
||||
const service = createService();
|
||||
// The full history has the same block, so only a replay-window read sends it again.
|
||||
service.agentMemory.getMessages.mockResolvedValue([
|
||||
storedUserTurn(buildThreadArtifactsBlock({ artifacts: [] })),
|
||||
]);
|
||||
service.agentMemory.getCursor.mockResolvedValue({
|
||||
lastObservedAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||
lastObservedMessageId: 'message-1',
|
||||
});
|
||||
service.agentMemory.getActiveObservationLog.mockResolvedValue([{ id: 'observation-1' }]);
|
||||
service.agentMemory.getMessagesForObservationScope.mockResolvedValue([]);
|
||||
|
||||
expect(await resolve(service, { artifacts: [] })).toContain('no tabs open');
|
||||
});
|
||||
|
||||
it('always sends a block that carries an editor hand-off', async () => {
|
||||
const service = createService();
|
||||
const attachments = [{ type: 'workflow' as const, id: 'wf-1', name: 'Digest' }];
|
||||
const earlier = buildThreadArtifactsBlock({ artifacts: [digest] }, attachments);
|
||||
service.agentMemory.getMessages.mockResolvedValue([storedUserTurn(earlier)]);
|
||||
|
||||
expect(await resolve(service, { artifacts: [digest] }, attachments)).toBe(earlier);
|
||||
});
|
||||
|
||||
it('sends the tabs again when the earlier block was compacted out of the replay window', async () => {
|
||||
const service = createService();
|
||||
service.agentMemory.getMessages.mockResolvedValue([
|
||||
storedUserTurn(buildThreadArtifactsBlock({ artifacts: [digest] })),
|
||||
]);
|
||||
service.agentMemory.getCursor.mockResolvedValue({
|
||||
lastObservedAt: new Date('2026-09-01T00:00:00.000Z'),
|
||||
lastObservedMessageId: 'message-1',
|
||||
});
|
||||
service.agentMemory.getActiveObservationLog.mockResolvedValue([{ id: 'observation-1' }]);
|
||||
service.agentMemory.getMessagesForObservationScope.mockResolvedValue([]);
|
||||
|
||||
expect(await resolve(service, { artifacts: [digest] })).toContain('Workflow "Digest"');
|
||||
});
|
||||
|
||||
it('sends the tabs when the history cannot be read', async () => {
|
||||
const service = createService();
|
||||
service.agentMemory.getMessages.mockRejectedValue(new Error('database is locked'));
|
||||
|
||||
expect(await resolve(service, { artifacts: [] })).toContain('no tabs open');
|
||||
expect(service.logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InstanceAiService — resolveAiPreferencesTurn', () => {
|
||||
type StoredMessage = { role: string; content: string };
|
||||
type Internals = {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
cleanStoredUserMessage,
|
||||
extractAgentPreviewHandoffContext,
|
||||
extractAiPreferencesBlock,
|
||||
extractThreadArtifactsBlock,
|
||||
extractEditorContextResourceAttachments,
|
||||
withCurrentDateTime,
|
||||
withPastConversations,
|
||||
|
|
@ -747,10 +748,52 @@ describe('extractAiPreferencesBlock', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('extractThreadArtifactsBlock', () => {
|
||||
const block = buildThreadArtifactsBlock({
|
||||
artifacts: [{ type: 'workflow', id: 'wf-1', name: 'Digest' }],
|
||||
activeId: 'wf-1',
|
||||
});
|
||||
|
||||
it('returns the block exactly as the thread-context wrapper stored it', () => {
|
||||
const stored = [
|
||||
buildThreadContextBlock([
|
||||
instanceContextMarker(),
|
||||
block,
|
||||
buildCurrentDateTimeBlock('Monday 1 January 2026'),
|
||||
]),
|
||||
'Change it',
|
||||
].join('\n\n');
|
||||
|
||||
expect(extractThreadArtifactsBlock(stored)).toBe(asStoredThreadContextSection(block));
|
||||
});
|
||||
|
||||
it('ignores a tag lookalike in the user text', () => {
|
||||
expect(extractThreadArtifactsBlock(`Please explain\n${block}`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildThreadArtifactsBlock', () => {
|
||||
it('returns empty when there are no artifacts', () => {
|
||||
it('returns empty when the client sent no tabs', () => {
|
||||
expect(buildThreadArtifactsBlock(undefined)).toBe('');
|
||||
expect(buildThreadArtifactsBlock({ artifacts: [] })).toBe('');
|
||||
});
|
||||
|
||||
it('says the user has no tabs open when the client sent an empty list', () => {
|
||||
expect(buildThreadArtifactsBlock({ artifacts: [] })).toBe(
|
||||
'<thread-artifacts>\nThe user has no tabs open in this conversation’s preview.\n</thread-artifacts>',
|
||||
);
|
||||
});
|
||||
|
||||
it('lists the open tabs as of this message, in the same order however the tabs are sorted', () => {
|
||||
const workflow = { type: 'workflow' as const, id: 'wf-1', name: 'Digest' };
|
||||
const table = { type: 'data-table' as const, id: 'dt-1', name: 'FAQ' };
|
||||
|
||||
const block = buildThreadArtifactsBlock({ artifacts: [workflow, table] });
|
||||
|
||||
expect(block).toContain(
|
||||
'Tabs the user has open in this conversation’s preview, as of this message:',
|
||||
);
|
||||
expect(block.indexOf('Data table "FAQ"')).toBeLessThan(block.indexOf('Workflow "Digest"'));
|
||||
expect(buildThreadArtifactsBlock({ artifacts: [table, workflow] })).toBe(block);
|
||||
});
|
||||
|
||||
it('marks the focused tab as current and lists the rest', () => {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ import {
|
|||
buildThreadContextBlock,
|
||||
buildWorkflowTestRequestBlock,
|
||||
extractAiPreferencesBlock,
|
||||
extractThreadArtifactsBlock,
|
||||
getProjectContextSection,
|
||||
WORKFLOW_SETUP_STATE_CLOSE_TAG,
|
||||
WORKFLOW_SETUP_STATE_OPEN_TAG,
|
||||
|
|
@ -4207,9 +4208,18 @@ export class InstanceAiService {
|
|||
|
||||
// Keep setup handoffs first and the user's message last.
|
||||
// Group ambient context in the thread-context wrapper.
|
||||
// One read of the replayed history serves every section sent only when it changed.
|
||||
let replayedHistory: Promise<AgentDbMessage[]> | undefined;
|
||||
const loadReplayedHistory = async () =>
|
||||
await (replayedHistory ??= this.getReplayedMessages(threadId));
|
||||
const threadArtifactsBlock =
|
||||
resumeReason === undefined
|
||||
? buildThreadArtifactsBlock(threadArtifacts, contextAttachments)
|
||||
? await this.resolveThreadArtifactsTurn(
|
||||
threadId,
|
||||
threadArtifacts,
|
||||
contextAttachments,
|
||||
loadReplayedHistory,
|
||||
)
|
||||
: '';
|
||||
const [boundProject, pastConversationsSection] = await Promise.all([
|
||||
this.resolveBoundProject(context),
|
||||
|
|
@ -4222,7 +4232,12 @@ export class InstanceAiService {
|
|||
// already in the history the follow-up replays.
|
||||
const aiPreferencesTurn =
|
||||
aiPreferencesEnabled && resumeReason === undefined && !isMachineFollowUp
|
||||
? await this.resolveAiPreferencesTurn(user.id, boundProject, threadId)
|
||||
? await this.resolveAiPreferencesTurn(
|
||||
user.id,
|
||||
boundProject,
|
||||
threadId,
|
||||
loadReplayedHistory,
|
||||
)
|
||||
: undefined;
|
||||
const threadContextBlock = buildThreadContextBlock([
|
||||
instanceContext.state === 'injected' ? instanceContext.block : '',
|
||||
|
|
@ -5378,6 +5393,8 @@ export class InstanceAiService {
|
|||
userId: string,
|
||||
project: ProjectSummary | undefined,
|
||||
threadId: string,
|
||||
loadHistory: () => Promise<AgentDbMessage[]> = async () =>
|
||||
await this.getReplayedMessages(threadId),
|
||||
): Promise<{ block: string | undefined; payload: AiPreferencesAppliedPayload }> {
|
||||
const resolved = await this.bestEffort(
|
||||
'Instance AI failed to read the AI preferences for this turn',
|
||||
|
|
@ -5400,7 +5417,7 @@ export class InstanceAiService {
|
|||
const history = await this.bestEffort(
|
||||
'Instance AI failed to read the last AI preferences block of this thread',
|
||||
{ threadId },
|
||||
async () => await this.findAiPreferencesHistory(threadId),
|
||||
async () => await this.findAiPreferencesHistory(loadHistory),
|
||||
);
|
||||
const lastBlock = history?.block;
|
||||
const savedSinceLastBlock = history?.savedSinceLastBlock === true;
|
||||
|
|
@ -5459,9 +5476,9 @@ export class InstanceAiService {
|
|||
* current preferences must be injected again when the window has no block.
|
||||
*/
|
||||
private async findAiPreferencesHistory(
|
||||
threadId: string,
|
||||
loadHistory: () => Promise<AgentDbMessage[]>,
|
||||
): Promise<{ block?: string; savedSinceLastBlock: boolean }> {
|
||||
const history = await this.getReplayedMessages(threadId);
|
||||
const history = await loadHistory();
|
||||
let savedSinceLastBlock = false;
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const m = history[i];
|
||||
|
|
@ -5483,6 +5500,48 @@ export class InstanceAiService {
|
|||
return { savedSinceLastBlock };
|
||||
}
|
||||
|
||||
/**
|
||||
* The open tabs block for this turn, or `''` when the agent already has the same
|
||||
* block in its replayed history. The agent reads the latest block as the current
|
||||
* tabs, so an unchanged block is not sent again.
|
||||
*/
|
||||
private async resolveThreadArtifactsTurn(
|
||||
threadId: string,
|
||||
context: InstanceAiThreadArtifactsContext | undefined,
|
||||
attachments: InstanceAiResourceAttachment[],
|
||||
loadHistory: () => Promise<AgentDbMessage[]>,
|
||||
): Promise<string> {
|
||||
const freshBlock = buildThreadArtifactsBlock(context, attachments);
|
||||
if (!freshBlock) return '';
|
||||
// A hand-off always rides its own turn: the parser rebuilds the attachments from it.
|
||||
if (attachments.length > 0) return freshBlock;
|
||||
|
||||
const history = await this.bestEffort(
|
||||
'Instance AI failed to read the last thread artifacts block of this thread',
|
||||
{ threadId },
|
||||
async () => ({ block: await this.findLastThreadArtifactsBlock(loadHistory) }),
|
||||
);
|
||||
// Send the block when the history cannot be read, so the agent never has stale tabs.
|
||||
if (!history) return freshBlock;
|
||||
// A "no tabs" block is sent like any other: after compaction, an observation can
|
||||
// still say that tabs are open.
|
||||
return asStoredThreadContextSection(freshBlock) === history.block ? '' : freshBlock;
|
||||
}
|
||||
|
||||
/** The latest thread artifacts block in the replay window, or `undefined`. */
|
||||
private async findLastThreadArtifactsBlock(
|
||||
loadHistory: () => Promise<AgentDbMessage[]>,
|
||||
): Promise<string | undefined> {
|
||||
const history = await loadHistory();
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const m = history[i];
|
||||
if (!('role' in m) || m.role !== 'user') continue;
|
||||
const block = extractThreadArtifactsBlock(this.extractStoredMessageText(m.content));
|
||||
if (block !== undefined) return block;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted messages the runtime replays to the model on the next turn. Mirrors
|
||||
* `MemoryOrchestrator.loadHistoryMessages`, including its desync guard: the post-cursor
|
||||
|
|
|
|||
|
|
@ -257,6 +257,24 @@ export function extractAiPreferencesBlock(stored: string): string | undefined {
|
|||
return threadContext ? AI_PREFERENCES_BLOCK.exec(threadContext)?.[0] : undefined;
|
||||
}
|
||||
|
||||
const PREVIEW_TABS_HEADER =
|
||||
'Tabs the user has open in this conversation’s preview, as of this message:';
|
||||
|
||||
/** Matches the service-written thread artifacts block inside one `<thread-context>` block. */
|
||||
const THREAD_ARTIFACTS_BLOCK = /<thread-artifacts>\n[\s\S]*?\n<\/thread-artifacts>/;
|
||||
|
||||
/**
|
||||
* The thread artifacts block a stored user message carries, exactly as stored, or
|
||||
* `undefined`. Read from the leading internal blocks only, like the preferences block.
|
||||
* Compare against `asStoredThreadContextSection(freshBlock)`, never the raw render.
|
||||
*/
|
||||
export function extractThreadArtifactsBlock(stored: string): string | undefined {
|
||||
const threadContext = leadingInternalBlocks(stored).find((block) =>
|
||||
block.startsWith(THREAD_CONTEXT_OPEN_TAG),
|
||||
);
|
||||
return threadContext ? THREAD_ARTIFACTS_BLOCK.exec(threadContext)?.[0] : undefined;
|
||||
}
|
||||
|
||||
/** Longest a user-supplied value may be inside a block. Matches the instance-context bound. */
|
||||
const PROMPT_TEXT_MAX_LENGTH = 128;
|
||||
|
||||
|
|
@ -523,8 +541,17 @@ export function buildThreadArtifactsBlock(
|
|||
context: InstanceAiThreadArtifactsContext | undefined,
|
||||
resourceAttachments: InstanceAiResourceAttachment[] = [],
|
||||
): string {
|
||||
const previewArtifacts = context?.artifacts ?? [];
|
||||
if (previewArtifacts.length === 0 && resourceAttachments.length === 0) return '';
|
||||
// A fixed order, so reordering tabs does not change the block and re-send it.
|
||||
const previewArtifacts = [...(context?.artifacts ?? [])].sort((a, b) =>
|
||||
`${a.type}:${a.id}`.localeCompare(`${b.type}:${b.id}`),
|
||||
);
|
||||
if (previewArtifacts.length === 0 && resourceAttachments.length === 0) {
|
||||
// An empty list that the client sent means no tabs are open. The wording holds
|
||||
// whether the user closed tabs or never had any.
|
||||
return context
|
||||
? `${THREAD_ARTIFACTS_OPEN_TAG}\nThe user has no tabs open in this conversation’s preview.\n${THREAD_ARTIFACTS_CLOSE_TAG}`
|
||||
: '';
|
||||
}
|
||||
|
||||
const executionByWorkflowId = new Map<string, string>();
|
||||
for (const attachment of resourceAttachments) {
|
||||
|
|
@ -582,9 +609,7 @@ export function buildThreadArtifactsBlock(
|
|||
'Use these ids when you act on the user’s request. Do not inspect, run, or describe their contents beyond what that request needs.';
|
||||
|
||||
const prose = [
|
||||
...(previewLines.length > 0
|
||||
? ['Artifacts the user can see in this conversation’s preview:', ...previewLines]
|
||||
: []),
|
||||
...(previewLines.length > 0 ? [PREVIEW_TABS_HEADER, ...previewLines] : []),
|
||||
...(handoffLines.length > 0
|
||||
? [
|
||||
'The user opened this conversation from the editor, where they are looking at:',
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@ watch(
|
|||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
// The agent sees the open tabs, not every artifact the thread produced. Until the
|
||||
// stored tabs load, the default tabs can still hold closed ones, so send none.
|
||||
watch(
|
||||
[() => preview.openTabs.value, () => preview.tabsLoaded.value],
|
||||
([tabs, loaded]) => thread.setOpenTabs(loaded ? tabs : null),
|
||||
{ immediate: true },
|
||||
);
|
||||
// --- Setup panel (checklist docked above the composer) ---
|
||||
// Early setup announcements can arrive before the first workflow artifact.
|
||||
const setupPanelWorkflowId = computed(() => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function makeThread(): ThreadRuntime {
|
|||
linkableResourceNameIndex: new Map(),
|
||||
activeArtifactId: undefined,
|
||||
setActiveArtifactId: vi.fn(),
|
||||
setOpenTabs: vi.fn(),
|
||||
feedbackByResponseId: {},
|
||||
rateableResponseId: null,
|
||||
pendingConfirmations: [],
|
||||
|
|
|
|||
|
|
@ -1746,6 +1746,33 @@ describe('createThreadRuntime - SSE and hydration', () => {
|
|||
);
|
||||
});
|
||||
|
||||
test('sendMessage sends no tabs while the stored tabs of the view load', async () => {
|
||||
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
|
||||
const runtime = activeRuntime(registry);
|
||||
runtime.producedArtifacts.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Maybe closed' });
|
||||
runtime.setOpenTabs(null);
|
||||
|
||||
await runtime.sendMessage('Change it', { authorship: USER_TYPED_MESSAGE });
|
||||
|
||||
expect(mockPostMessage.mock.calls[0]?.[8]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('sendMessage forwards the open tabs instead of every produced artifact', async () => {
|
||||
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
|
||||
const runtime = activeRuntime(registry);
|
||||
runtime.producedArtifacts.set('wf-1', { type: 'workflow', id: 'wf-1', name: 'Closed' });
|
||||
runtime.producedArtifacts.set('wf-2', { type: 'workflow', id: 'wf-2', name: 'Open' });
|
||||
runtime.setOpenTabs([{ type: 'workflow', id: 'wf-2', name: 'Open' }]);
|
||||
runtime.setActiveArtifactId('wf-2');
|
||||
|
||||
await runtime.sendMessage('Change it', { authorship: USER_TYPED_MESSAGE });
|
||||
|
||||
expect(mockPostMessage.mock.calls[0]?.[8]).toEqual({
|
||||
artifacts: [{ type: 'workflow', id: 'wf-2', name: 'Open' }],
|
||||
activeId: 'wf-2',
|
||||
});
|
||||
});
|
||||
|
||||
test('sendMessage forwards the thread artifact index to postMessage', async () => {
|
||||
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
|
||||
const runtime = activeRuntime(registry);
|
||||
|
|
|
|||
|
|
@ -68,4 +68,59 @@ describe('buildThreadArtifactsContext', () => {
|
|||
artifacts: [{ type: 'workflow', id: 'wf-1', name: 'A' }],
|
||||
});
|
||||
});
|
||||
|
||||
describe('with the open tabs of the thread view', () => {
|
||||
const produced = [
|
||||
entry({ type: 'workflow', id: 'wf-1', name: 'First', archived: true }),
|
||||
entry({ type: 'workflow', id: 'wf-2', name: 'Second' }),
|
||||
entry({ type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1', pending: true }),
|
||||
];
|
||||
|
||||
it('sends the open tabs in tab order, not every produced artifact', () => {
|
||||
expect(
|
||||
buildThreadArtifactsContext(produced, 'wf-2', [
|
||||
{ type: 'workflow', id: 'wf-2', name: 'Second' },
|
||||
{ type: 'data-table', id: 'dt-9', name: 'Picked table', projectId: 'proj-1' },
|
||||
]),
|
||||
).toEqual({
|
||||
artifacts: [
|
||||
{ type: 'workflow', id: 'wf-2', name: 'Second' },
|
||||
{ type: 'data-table', id: 'dt-9', name: 'Picked table', projectId: 'proj-1' },
|
||||
],
|
||||
activeId: 'wf-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an empty list when every tab is closed', () => {
|
||||
expect(buildThreadArtifactsContext(produced, undefined, [])).toEqual({ artifacts: [] });
|
||||
});
|
||||
|
||||
it('keeps the archived and pending flags of the produced artifacts', () => {
|
||||
expect(
|
||||
buildThreadArtifactsContext(produced, undefined, [
|
||||
{ type: 'workflow', id: 'wf-1', name: 'First' },
|
||||
{ type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1' },
|
||||
])?.artifacts,
|
||||
).toEqual([
|
||||
{ type: 'workflow', id: 'wf-1', name: 'First', archived: true },
|
||||
{ type: 'agent', id: 'agent-1', name: 'Agent', projectId: 'proj-1', pending: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first tabs and the focused one when over the cap', () => {
|
||||
const tabs = Array.from({ length: 25 }, (_, index) => ({
|
||||
type: 'workflow' as const,
|
||||
id: `wf-${index}`,
|
||||
name: `Workflow ${index}`,
|
||||
}));
|
||||
|
||||
const result = buildThreadArtifactsContext([], 'wf-24', tabs);
|
||||
|
||||
expect(result?.artifacts).toHaveLength(20);
|
||||
expect(result?.artifacts[0]?.id).toBe('wf-0');
|
||||
expect(result?.artifacts.at(-1)?.id).toBe('wf-24');
|
||||
expect(result?.artifacts.some((artifact) => artifact.id === 'wf-19')).toBe(false);
|
||||
expect(result?.activeId).toBe('wf-24');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { computed, nextTick, reactive, ref, triggerRef, watch } from 'vue';
|
||||
import { computed, nextTick, reactive, ref, shallowRef, triggerRef, watch } from 'vue';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import {
|
||||
|
|
@ -68,7 +68,7 @@ import {
|
|||
useResourceRegistry,
|
||||
type TransientWorkflowArtifactReference,
|
||||
} from './useResourceRegistry';
|
||||
import { buildThreadArtifactsContext } from './threadArtifacts';
|
||||
import { buildThreadArtifactsContext, type OpenThreadTab } from './threadArtifacts';
|
||||
import { useResponseFeedback } from './useResponseFeedback';
|
||||
import {
|
||||
INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY,
|
||||
|
|
@ -530,6 +530,13 @@ export function createThreadRuntime(
|
|||
const lastEventId = ref<number | undefined>(undefined);
|
||||
/** Focused preview tab id while the artifacts preview is open. */
|
||||
const activeArtifactId = ref<string>();
|
||||
/**
|
||||
* The tabs the thread view has open, sent to the agent with each message.
|
||||
* `undefined` when no view reports tabs; the agent then gets every artifact.
|
||||
* `null` while the view's stored tabs load; the message then carries no tabs,
|
||||
* so the agent keeps the last tabs it has instead of closed ones.
|
||||
*/
|
||||
const openTabs = shallowRef<OpenThreadTab[] | null>();
|
||||
// Event ids already applied on this thread — guards against replay overlap,
|
||||
// e.g. an auto-reconnect replaying an id that already arrived just before
|
||||
// the disconnect. Not reactive: only consulted inside onSSEMessage.
|
||||
|
|
@ -1408,6 +1415,10 @@ export function createThreadRuntime(
|
|||
activeArtifactId.value = id;
|
||||
}
|
||||
|
||||
function setOpenTabs(tabs?: OpenThreadTab[] | null): void {
|
||||
openTabs.value = tabs;
|
||||
}
|
||||
|
||||
/** Reset all state owned by this runtime. */
|
||||
function resetState(): void {
|
||||
hydrationGeneration += 1;
|
||||
|
|
@ -1432,6 +1443,7 @@ export function createThreadRuntime(
|
|||
lastEventId.value = undefined;
|
||||
seenEventIds.clear();
|
||||
activeArtifactId.value = undefined;
|
||||
openTabs.value = undefined;
|
||||
pendingWorkflowAttachment.value = null;
|
||||
transientWorkflowReferences.clear();
|
||||
pendingHandoff.value = null;
|
||||
|
|
@ -1637,7 +1649,13 @@ export function createThreadRuntime(
|
|||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
pushRef,
|
||||
instanceAiSettingsStore.computerUseChannels,
|
||||
buildThreadArtifactsContext(producedArtifacts.values(), activeArtifactId.value),
|
||||
openTabs.value === null
|
||||
? undefined
|
||||
: buildThreadArtifactsContext(
|
||||
producedArtifacts.values(),
|
||||
activeArtifactId.value,
|
||||
openTabs.value,
|
||||
),
|
||||
);
|
||||
|
||||
return runId;
|
||||
|
|
@ -1940,6 +1958,7 @@ export function createThreadRuntime(
|
|||
producedArtifactOrigins,
|
||||
activeArtifactId,
|
||||
setActiveArtifactId,
|
||||
setOpenTabs,
|
||||
feedbackByResponseId,
|
||||
rateableResponseId,
|
||||
currentTasks,
|
||||
|
|
|
|||
|
|
@ -4,32 +4,67 @@ import type { ResourceEntry } from './useResourceRegistry';
|
|||
|
||||
type ThreadArtifactType = InstanceAiThreadArtifact['type'];
|
||||
|
||||
/** A tab the user has open in the thread view. */
|
||||
export type OpenThreadTab = {
|
||||
type: ThreadArtifactType;
|
||||
id: string;
|
||||
name?: string;
|
||||
projectId?: string;
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
const MAX_THREAD_ARTIFACTS = 20;
|
||||
|
||||
function isThreadArtifactType(type: ResourceEntry['type']): type is ThreadArtifactType {
|
||||
return type === 'workflow' || type === 'agent' || type === 'data-table';
|
||||
}
|
||||
|
||||
function toThreadArtifact(
|
||||
tab: OpenThreadTab,
|
||||
produced: ResourceEntry | undefined,
|
||||
): InstanceAiThreadArtifact {
|
||||
const name = tab.name ?? produced?.name;
|
||||
const projectId = tab.projectId ?? produced?.projectId;
|
||||
return {
|
||||
type: tab.type,
|
||||
id: tab.id,
|
||||
...(name ? { name } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(tab.pending || produced?.pending ? { pending: true as const } : {}),
|
||||
...(produced?.archived ? { archived: true as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-turn index the backend injects inside `<thread-context>`.
|
||||
* Matches the thread preview tabs: ids and names only.
|
||||
* Build the per-turn index the backend injects inside `<thread-context>`: ids
|
||||
* and names only.
|
||||
*
|
||||
* With `openTabs`, the index is the tabs the user has open, in tab order. An
|
||||
* empty list tells the agent no tabs are open. Without it, for a
|
||||
* client that has no tab bar, the index is every artifact the thread produced.
|
||||
*/
|
||||
export function buildThreadArtifactsContext(
|
||||
produced: Iterable<ResourceEntry>,
|
||||
activeId?: string,
|
||||
openTabs?: OpenThreadTab[],
|
||||
): InstanceAiThreadArtifactsContext | undefined {
|
||||
const producedById = new Map<string, ResourceEntry>();
|
||||
const all: InstanceAiThreadArtifact[] = [];
|
||||
for (const entry of produced) {
|
||||
if (!isThreadArtifactType(entry.type)) continue;
|
||||
all.push({
|
||||
type: entry.type,
|
||||
id: entry.id,
|
||||
...(entry.name ? { name: entry.name } : {}),
|
||||
...(entry.projectId ? { projectId: entry.projectId } : {}),
|
||||
...(entry.pending ? { pending: true as const } : {}),
|
||||
...(entry.archived ? { archived: true as const } : {}),
|
||||
});
|
||||
producedById.set(entry.id, entry);
|
||||
if (!openTabs) all.push(toThreadArtifact({ type: entry.type, id: entry.id }, entry));
|
||||
}
|
||||
|
||||
if (openTabs) {
|
||||
const artifacts = openTabs.map((tab) => toThreadArtifact(tab, producedById.get(tab.id)));
|
||||
// Over the cap, keep the first tabs in tab order and never drop the focused one.
|
||||
const active = artifacts.find((artifact) => artifact.id === activeId);
|
||||
let capped = artifacts.slice(0, MAX_THREAD_ARTIFACTS);
|
||||
if (active && !capped.includes(active)) capped = [...capped.slice(0, -1), active];
|
||||
return { artifacts: capped, ...(active ? { activeId: active.id } : {}) };
|
||||
}
|
||||
|
||||
if (all.length === 0) return undefined;
|
||||
|
||||
// Insertion order is oldest first. Over the cap, keep the newest tabs — the
|
||||
|
|
|
|||
|
|
@ -582,6 +582,8 @@ export function useCanvasPreview({ thread, initialAgentId, tabsStorage }: UseCan
|
|||
return {
|
||||
activeTabId,
|
||||
openTabs,
|
||||
/** False until the stored tabs of the thread load. */
|
||||
tabsLoaded: tabs.isLoaded,
|
||||
activeWorkflowId,
|
||||
activeDataTableId,
|
||||
activeDataTableProjectId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user