Files
windmill/frontend/src/lib/components/flows/linkedToolsFetchGuard.test.ts
T
hugocasaandClaude Opus 5 7feaf619cf feat: run a linked AI agent's draft when testing a flow, and offer to deploy it (#10993)
* feat(frontend): run a linked agent's draft when testing a flow, and offer to deploy it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): settle an agent's autosave before reading it, and refresh its card on a draft save

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): deploy the agent draft that was validated, and make the draft-tools flag explicit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a stale agent deploy, and warn when a never-deployed agent is kept as a draft

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* docs: record what inlining an agent draft puts in a preview job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): name the draft-changes dialog after what it lists

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse a draft deploy when the draft row is gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): apply the missing-draft refusal to raw apps too

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): stop reading a deployed resource row as a draft on deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): do not mistake an outage or a vanished draft for a deploy

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refuse an agent read whose pending draft save failed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): space the trigger badges and right-align the agent actions

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): surface a failed agent-draft read instead of dropping it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the agent draft delete a baseline so a newer edit survives

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): drop the agent draft cell locally instead of deleting twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pass the withDraft flag the guard tests were missing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* refactor(frontend): deploy agent drafts the way Review & Deploy does

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): give the read-only flow graph its own linked-tools bucket

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): base the resource draft delete on the read that promoted it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): refresh every step linking an agent when its draft is saved

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* fix(frontend): write nothing at all when a resource draft has gone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

* test(frontend): pin that the resource draft delete follows its baseline seed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017B4omp8dRgmLitbpQEqFMp

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:04:38 +02:00

87 lines
3.3 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
// flowState pulls in the flow-module loaders; only the fetch-generation guard is under test here.
vi.mock('$lib/gen', () => ({ ResourceService: { getResource: vi.fn() } }))
vi.mock('./flowStateUtils.svelte', () => ({ loadFlowModuleState: vi.fn() }))
vi.mock('./utils.svelte', () => ({ emptyFlowModuleState: () => ({}) }))
vi.mock('./agentToolUtils', () => ({
isFlowModuleTool: () => false,
agentToolToFlowModule: (t: unknown) => t
}))
vi.mock('$lib/stores', () => ({
workspaceStore: { subscribe: (f: (v: string) => void) => (f('ws'), () => {}) }
}))
import {
claimLinkedToolsFetch,
invalidateLinkedToolsFetches,
publishLinkedAgentTools
} from './flowState'
import {
getLinkedAgentTools,
linkedToolsScope,
setLinkedAgentTools
} from './linkedAgentToolsStore.svelte'
import { ResourceService } from '$lib/gen'
import type { AgentTool } from './agentToolUtils'
const tool = (id: string) => ({ id, value: { tool_type: 'flowmodule' } }) as AgentTool
let seq = 0
const scopeFor = (name: string) => linkedToolsScope(`ws${seq++}`, name)
describe('linked tools fetch guard', () => {
it('lets only the newest fetch for a (scope, module) publish', async () => {
const scope = scopeFor('flow')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource)
.mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
.mockResolvedValueOnce({ value: { tools: [tool('new')] } } as never)
const stale = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
await publishLinkedAgentTools('f/a/new', 'ws', scope, 'step', false)
release?.({ value: { tools: [tool('old')] } })
await stale
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['new'])
})
// A rename migrates the bucket to a new scope. A fetch still running against the old scope holds
// a valid generation for that key, so without invalidation it publishes there and the doc-scope
// sweep carries it forward over the link resolved since.
it('invalidates every in-flight fetch for a scope', async () => {
const scope = scopeFor('before-rename')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource).mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
setLinkedAgentTools(scope, 'step', [tool('kept')], 'u/admin/a')
invalidateLinkedToolsFetches(scope)
release?.({ value: { tools: [tool('stale')] } })
await inFlight
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['kept'])
})
it('claiming supersedes an in-flight fetch for that module', async () => {
const scope = scopeFor('claimed')
let release: ((v: unknown) => void) | undefined
vi.mocked(ResourceService.getResource).mockImplementationOnce(
() => new Promise((r) => (release = r)) as ReturnType<typeof ResourceService.getResource>
)
const inFlight = publishLinkedAgentTools('f/a/old', 'ws', scope, 'step', false)
claimLinkedToolsFetch(scope, 'step')
setLinkedAgentTools(scope, 'step', [tool('direct')], 'u/admin/a')
release?.({ value: { tools: [tool('stale')] } })
await inFlight
expect(getLinkedAgentTools(scope, 'step').map((t) => t.id)).toEqual(['direct'])
})
})