Files
windmill/frontend/src/lib/components/flows/conversations/agentAttachmentInput.test.ts
T
GuilhemandClaude Opus 5 c4c9677982 feat: attach files to a flow chat message (#11185)
* feat: attach files to a flow chat message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: store chat uploads under windmill_uploads and withdraw refused sends cleanly

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse extra files for a single-file input and keep attachments on retry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: clean up partial upload batches, withdraw a stopped send once, free retry payloads

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: list a chat conversation only once its run starts and require files where the flow does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: carry uploaded attachments on the pending chat user message

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: route rich-composer file paste through the attachment lanes and tighten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep a stopped turn's files for retry and let an explicit media type win

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: refuse chat attachments sent without text and shorten comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve the attachments input from real flow input reads, ignoring loop iteration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: read flow input references through one parser for the model and attachments controls

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: withdraw an attachment send stopped after its uploads answered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: discard uploads when a send is stopped as its attachments are announced

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep uploadAttachments' doc comment on uploadAttachments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: never delete chat uploads from workspace storage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test: pin that a failed upload aborts the rest of its batch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 10:16:10 +02:00

180 lines
6.2 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
agentSteps,
attachmentsTargetFor,
flowInputRef,
resolveAgentChatInputs
} from './agentAttachmentInput'
import type { FlowModule } from '$lib/gen'
/** An agent step with the given input transforms, as the editor stores them. */
function agentWith(input_transforms: Record<string, any>, id = 'a'): FlowModule {
return {
id,
value: { type: 'aiagent', tools: [], input_transforms }
} as unknown as FlowModule
}
describe('agentSteps', () => {
it('finds agents inside loops and branches', () => {
const modules = [
{ id: 'loop', value: { type: 'forloopflow', modules: [agentWith({}, 'in-loop')] } },
{
id: 'branch',
value: {
type: 'branchone',
default: [agentWith({}, 'in-default')],
branches: [{ modules: [agentWith({}, 'in-branch')] }]
}
},
{
id: 'all',
value: { type: 'branchall', branches: [{ modules: [agentWith({}, 'in-all')] }] }
}
] as unknown as FlowModule[]
expect(agentSteps(modules).map((m) => m.id)).toEqual([
'in-loop',
'in-default',
'in-branch',
'in-all'
])
})
// The graph walks an agent's tools as child steps; a tool agent's inputs belong to the
// agent that calls it, not to the chat.
it("ignores an agent carried as another agent's tool", () => {
const parent = agentWith({})
;(parent.value as any).tools = [
{ id: 'summarize', value: { tool_type: 'flowmodule', type: 'aiagent', tools: [] } }
]
expect(agentSteps([parent]).map((m) => m.id)).toEqual(['a'])
})
})
describe('flowInputRef', () => {
it('names the one input an expression reads, however it reshapes it', () => {
expect(
flowInputRef({
type: 'javascript',
expr: '(flow_input.files || []).map(f => ({ bucket: f.storage, key: f.s3 }))'
})
).toBe('files')
expect(flowInputRef({ type: 'javascript', expr: 'flow_input?.docs' })).toBe('docs')
})
// A loop step reads its iteration from `flow_input.iter`, which is not a flow input.
it('ignores names the flow does not declare, such as a loop iteration', () => {
const transform = {
type: 'javascript' as const,
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
expect(flowInputRef(transform)).toBeUndefined()
expect(flowInputRef(transform, { files: {} })).toBe('files')
})
it('counts real reads only, in any access form and in a statement body', () => {
const ref = (expr: string) =>
flowInputRef({ type: 'javascript', expr }, { files: {}, docs: {} })
expect(ref("flow_input['files']")).toBe('files')
expect(ref('/* flow_input.docs */ flow_input?.files')).toBe('files')
expect(ref("'flow_input.docs' + flow_input.files")).toBe('files')
expect(ref('results.a.flow_input.docs ?? flow_input.files')).toBe('files')
expect(ref('const f = flow_input.files\nreturn f')).toBe('files')
expect(ref('flow_input.files.concat(')).toBeUndefined()
})
it('names nothing for two inputs or a static value', () => {
expect(
flowInputRef({ type: 'javascript', expr: '[...flow_input.a, ...flow_input.b]' })
).toBeUndefined()
expect(flowInputRef({ type: 'static', value: [] })).toBeUndefined()
})
})
describe('resolveAgentChatInputs', () => {
const schema = { properties: { files: { type: 'array' } }, required: [] }
const reader = (name: string) =>
agentWith({ user_attachments: { type: 'javascript', expr: `flow_input.${name}` } })
// Every agent step carries a placeholder transform for each key of AI_AGENT_SCHEMA
// (loadSchemaFromModule writes them back onto the module), so an agent that reads
// nothing must not be mistaken for one reading a different input.
const seeded = () => agentWith({ user_attachments: { type: 'static', value: undefined } })
it('promotes the input one agent reads', () => {
expect(resolveAgentChatInputs([reader('files')], schema).map((i) => i.name)).toEqual(['files'])
})
it('still promotes it when another agent leaves the field unwired', () => {
expect(resolveAgentChatInputs([reader('files'), seeded()], schema).map((i) => i.name)).toEqual([
'files'
])
})
it('promotes nothing when two agents read different inputs', () => {
const twoInputs = {
properties: { files: { type: 'array' }, docs: { type: 'array' } },
required: []
}
expect(resolveAgentChatInputs([reader('files'), reader('docs')], twoInputs)).toEqual([])
})
it('promotes the input an agent inside a loop reads next to its iteration', () => {
const loop = {
id: 'loop',
value: {
type: 'forloopflow',
modules: [
agentWith({
user_attachments: {
type: 'javascript',
expr: 'flow_input.files.filter((_, i) => i === flow_input.iter.index)'
}
})
]
}
} as unknown as FlowModule
expect(resolveAgentChatInputs([loop], schema).map((i) => i.name)).toEqual(['files'])
})
it('gives no say to an agent that only mentions flow_input in a comment', () => {
const commented = agentWith({
user_attachments: { type: 'javascript', expr: '// flow_input.docs\nresults.a.files' }
})
expect(resolveAgentChatInputs([reader('files'), commented], schema).map((i) => i.name)).toEqual(
['files']
)
})
it('promotes nothing for an input the schema does not declare', () => {
expect(resolveAgentChatInputs([reader('missing')], schema)).toEqual([])
})
})
describe('attachmentsTargetFor', () => {
const input = (property: Record<string, any>) =>
({ name: 'files', key: 'user_attachments', property }) as any
it('takes a list of s3 files, and says it holds several', () => {
expect(
attachmentsTargetFor(input({ type: 'array', items: { resourceType: 's3object' } }))
).toEqual({ name: 'files', multiple: true })
})
it('takes a single s3 file', () => {
expect(attachmentsTargetFor(input({ format: 'resource-s3_object' }))).toEqual({
name: 'files',
multiple: false
})
})
// The transform can build the s3 object itself, promoting an input that holds a key
// rather than a file. Uploading into it would write an object where a string is declared.
it('offers no paperclip where the input cannot hold a file', () => {
expect(attachmentsTargetFor(input({ type: 'string' }))).toBeUndefined()
expect(
attachmentsTargetFor(input({ type: 'array', items: { type: 'string' } }))
).toBeUndefined()
expect(attachmentsTargetFor(undefined)).toBeUndefined()
})
})