Files
windmill/frontend/src/lib/components/flows/agentResourceUtils.test.ts
T
hugocasaandClaude Opus 5 c297ed0052 feat: managed memory with an inherited or custom memory id per step (#11118)
* feat: split ai agent memory into agent policy, run memory id and step history

* fix: scope string memory ids to workspace and flow, keep nested tool history inputs

* chore: update sqlx cache for the flow context query

* docs: describe memory id scoping as collision-free rather than isolated

* chore: regenerate openflow json after merging main

* fix: offer no memory id for legacy manual memory, document linked history inputs

* fix: seed provided messages from legacy manual memory and hide its note once set

* fix: bypass memory when a provided messages expression evaluates to null

* fix: require a user message when provided messages are empty

* chore: keep the empty messages comment within the line width

* docs: name the history inputs wherever linked steps list their flow-local inputs

* docs: keep the memory storage path on one line

* feat: managed memory with an inherited or custom memory id per step

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: list a custom memory id in the test run form and name where an inherited one comes from

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep memory id out of the add-field menu and drop the memory id telemetry

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep legacy auto memory without an id working after an untouched redeploy

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: rename step messages to previous_messages and address review

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: rewrap comments and docs lines lengthened by the previous_messages rename

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: read agent memory as either a legacy shape or the current one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: name the memory setting in ignored-input notes and keep conversions honest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: keep a legacy memory count unset on open and read a cleared count as off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: address review on cleared test history and zero-count memory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: drop flow-local keys from a linked agent resource before interpolating it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep a linked resource's own inputs as fallbacks and note ignored history on image runs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: restore the linked agent draft tests and log ignored history on every image run

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: resolve the one-of variant from the value when the selected one leaves the list

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: treat zero-count managed memory as off when enabling chat mode and shorten comments

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: stop requiring user_message in the openflow agent contract when previous messages are the prompt

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:55:43 +02:00

196 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
agentConfigToInputTransforms,
agentEditorRefusal,
flowLocalInputs,
inputTransformsToAgentConfig,
nonStaticBrainKeys,
summarizeAgentBrain,
toolInputOverrides,
transformValuedBrainKeys
} from './agentResourceUtils'
describe('transformValuedBrainKeys', () => {
// The tag is the whole test: `{type:'ai'}` carries no payload key to look for.
it('flags every transform variant, whatever it carries', () => {
expect(
transformValuedBrainKeys({
system_prompt: { type: 'ai' },
temperature: { type: 'javascript', expr: 'flow_input.t' },
max_iterations: { type: 'static', value: 3 }
})
).toEqual(['system_prompt', 'temperature', 'max_iterations'])
})
it('leaves the brain values that are legitimately objects', () => {
expect(
transformValuedBrainKeys({
output_schema: { type: 'object', properties: {} },
memory: { kind: 'auto', context_length: 20 },
provider: { kind: 'openai', model: 'gpt-4o' },
system_prompt: 'hi'
})
).toEqual([])
})
})
describe('agentEditorRefusal', () => {
it('opens an agent', () => {
expect(agentEditorRefusal('u/admin/agent', 'ai_agent')).toBeUndefined()
})
// A path with no deployed row answers with its own draft, and the generic resource editor writes
// no type: assuming `ai_agent` there would create that path as an agent on deploy.
it('refuses an unknown type rather than assuming one', () => {
expect(agentEditorRefusal('u/admin/db', undefined)).toContain('no deployed agent')
})
})
describe('summarizeAgentBrain', () => {
it('returns only set fields, in brain-key order, formatted', () => {
const rows = summarizeAgentBrain({
provider: { kind: 'openai', model: 'gpt-4o', resource: '$res:f/x/openai' } as any,
system_prompt: 'You are helpful',
temperature: 0.7,
streaming: true,
max_iterations: 10
})
expect(rows).toEqual([
{ label: 'Provider', value: 'openai · gpt-4o' },
{ label: 'System message', value: 'You are helpful' },
{ label: 'Stream the response', value: 'on' },
{ label: 'Temperature', value: '0.7' },
{ label: 'Max iterations', value: '10' }
])
})
it('skips empty/undefined fields', () => {
expect(summarizeAgentBrain({ system_prompt: '', provider: undefined as any })).toEqual([])
expect(summarizeAgentBrain(undefined)).toEqual([])
})
it('summarizes structured fields compactly', () => {
// memory is serialized with a `kind` tag (serde tag = "kind")
const rows = summarizeAgentBrain({
memory: { kind: 'auto', context_length: 20 } as any,
output_schema: { type: 'object' } as any
})
expect(rows).toEqual([
{ label: 'Managed memory', value: 'Last 20 messages' },
{ label: 'Output schema', value: 'configured' }
])
})
})
describe('inputTransformsToAgentConfig', () => {
it('captures only static brain values and carries tools', () => {
const config = inputTransformsToAgentConfig(
{
provider: { type: 'static', value: { kind: 'openai' } },
system_prompt: { type: 'static', value: 'hi' },
temperature: { type: 'javascript', expr: 'flow_input.t' }, // non-static → dropped
max_iterations: { type: 'static', value: undefined }, // undefined → dropped
user_message: { type: 'static', value: 'hello' } // not a brain key → dropped
} as any,
[{ id: 't1' }] as any
)
expect(config).toEqual({
tools: [{ id: 't1' }],
provider: { kind: 'openai' },
system_prompt: 'hi'
})
})
it('defaults tools to []', () => {
expect(inputTransformsToAgentConfig({}, undefined)).toEqual({ tools: [] })
})
})
describe('agentConfigToInputTransforms', () => {
it('wraps brain values as static transforms and round-trips through the config', () => {
const config = { provider: { kind: 'openai' }, system_prompt: 'hi', tools: [{ id: 't1' }] }
const its = agentConfigToInputTransforms(config as any)
expect(its).toEqual({
provider: { type: 'static', value: { kind: 'openai' } },
system_prompt: { type: 'static', value: 'hi' }
})
const back = inputTransformsToAgentConfig(its, config.tools as any)
expect(back).toEqual(config)
})
})
describe('nonStaticBrainKeys', () => {
it('lists brain keys with a non-static transform, in brain-key order', () => {
expect(
nonStaticBrainKeys({
provider: { type: 'static', value: {} },
temperature: { type: 'connected' },
system_prompt: { type: 'javascript', expr: 'x' }
} as any)
).toEqual(['system_prompt', 'temperature'])
})
it('flags a non-static provider (the save-blocking case) but not a static one', () => {
// provider is required on the resource; a non-static one gets dropped and must block saving
expect(nonStaticBrainKeys({ provider: { type: 'javascript', expr: 'x' } } as any)).toContain(
'provider'
)
expect(nonStaticBrainKeys({ provider: { type: 'static', value: {} } } as any)).not.toContain(
'provider'
)
})
})
describe('flowLocalInputs', () => {
it('keeps the step’s own inputs, dropping brain transforms', () => {
expect(
flowLocalInputs({
provider: { type: 'static', value: {} },
memory: { type: 'static', value: { kind: 'window', context_length: 10 } },
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] },
memory_id: { type: 'javascript', expr: 'flow_input.customer_id' },
previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] },
// The roster it narrows belongs to the agent, but which of it one flow may call does
// not: saving this into the resource would impose it on every flow linking the agent.
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
} as any)
).toEqual({
user_message: { type: 'static', value: 'hi' },
user_attachments: { type: 'static', value: [] },
memory_id: { type: 'javascript', expr: 'flow_input.customer_id' },
previous_messages: { type: 'static', value: [{ role: 'user', content: 'earlier' }] },
enabled_tools: { type: 'javascript', expr: 'flow_input.tools' }
})
})
it('handles undefined', () => {
expect(flowLocalInputs(undefined)).toEqual({})
})
})
describe('toolInputOverrides', () => {
const base = {
tenant: { type: 'javascript', expr: 'flow_input.tenant' },
query: { type: 'static', value: 'x' }
} as any
it('returns nothing when inputs equal the resource base (opening a tool is a no-op)', () => {
expect(toolInputOverrides(base, base)).toEqual({})
})
it('returns only the edited keys, so a revert to base drops back to nothing', () => {
const edited = { ...base, query: { type: 'static', value: 'y' } }
expect(toolInputOverrides(edited, base)).toEqual({ query: { type: 'static', value: 'y' } })
// reverting query back to the base value yields an empty override set again
expect(toolInputOverrides(base, base)).toEqual({})
})
it('includes keys absent from the base', () => {
expect(toolInputOverrides({ extra: { type: 'static', value: 1 } } as any, base)).toEqual({
extra: { type: 'static', value: 1 }
})
})
})