Why: src/contracts.ts is regenerated from Agent-System-Core by the Dev
composite; the scratch field added there is dropped on regeneration and
tsc then fails in make build.
Evidence: contracts.ts equals the Core-generated output; npx tsc -b
--noEmit, npx vitest run (685 tests) and check-knip pass locally (SELF).
Impact: ConversationView (Conversation & { scratch?: boolean }) in
api.ts carries the server-local flag, mirroring the Backend
conversationView; no behavior change.
Refs: Agent-System-Frontend#123, Agent-System-Dev#691;
agent-exec receipt u3f-run (cursor, success)
Co-authored-by: Cursor <cursoragent@cursor.com>
408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { auth } from './auth'
|
|
import {
|
|
PROJECT_SYNC_INTERVAL_MS, PROJECT_SYNC_RENDER_BUDGET_MS, PROJECT_SYNC_REQUEST_BUDGET_MS,
|
|
createProjectAutoDiscover, projectSyncDelay, store, type ProjectSyncEnv, type ProjectSyncStatus,
|
|
} from './store'
|
|
import type { ConversationView } from './api'
|
|
import type { Project } from './contracts'
|
|
|
|
const project = (id: string, owner = 'alice'): Project => ({ id, owner, name: id, createdAt: '', updatedAt: '' })
|
|
const task = (id: string, projectId: string): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt: '' })
|
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status })
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
let reject!: (reason: unknown) => void
|
|
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
/** 可控的页面环境:可见性、联网与 wake/sleep 事件由测试驱动。 */
|
|
function fakeEnv() {
|
|
const env = {
|
|
visible: true,
|
|
online: true,
|
|
handlers: null as null | { wake: () => void; sleep: (status: 'paused' | 'offline') => void },
|
|
}
|
|
const port: ProjectSyncEnv = {
|
|
visible: () => env.visible,
|
|
online: () => env.online,
|
|
subscribe: handlers => { env.handlers = handlers; return () => { env.handlers = null } },
|
|
}
|
|
return {
|
|
port,
|
|
hide() { env.visible = false; env.handlers?.sleep('paused') },
|
|
show() { env.visible = true; env.handlers?.wake() },
|
|
offline() { env.online = false; env.handlers?.sleep('offline') },
|
|
online() { env.online = true; env.handlers?.wake() },
|
|
focus() { env.handlers?.wake() },
|
|
}
|
|
}
|
|
|
|
let projects: Project[]
|
|
let tasks: ConversationView[]
|
|
let requests: { path: string; init?: RequestInit }[]
|
|
let route: (path: string, init?: RequestInit) => Promise<Response>
|
|
|
|
beforeEach(async () => {
|
|
projects = [project('host-a')]
|
|
tasks = [task('conv-a', 'host-a')]
|
|
requests = []
|
|
const saved = new Map<string, string>()
|
|
vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { saved.set(key, value) },
|
|
removeItem: (key: string) => { saved.delete(key) } })
|
|
route = async (path, init) => {
|
|
if (path === '/api/projects' && !init?.method) return response(projects)
|
|
if (path === '/api/conversations' && !init?.method) return response(tasks.filter(t => projects.some(p => p.id === t.projectId)))
|
|
if (path === '/api/conversations/conv-a') return response({ conversation: tasks[0], messages: [], runs: [], activeHitl: [], lastSeq: 0 })
|
|
if (path === '/api/me') return response({ owner: 'bob', name: 'Bob', auth: true, systemActions: [] })
|
|
if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null)
|
|
if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([])
|
|
if (path.endsWith('/cards')) return response({ cards: [] })
|
|
return response({})
|
|
}
|
|
vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => {
|
|
requests.push({ path, init })
|
|
return route(path, init)
|
|
})
|
|
vi.stubGlobal('EventSource', class { addEventListener() {} close() {} })
|
|
auth.beginSession()
|
|
await store.refreshMe()
|
|
await store.refreshProjects()
|
|
await store.openTask('conv-a')
|
|
requests = []
|
|
})
|
|
|
|
afterEach(() => {
|
|
store.stopProjectSync()
|
|
vi.useRealTimers()
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
async function advanceUntil(check: () => boolean, limitMs: number, stepMs = 250): Promise<number> {
|
|
let elapsed = 0
|
|
while (!check() && elapsed < limitMs) {
|
|
await vi.advanceTimersByTimeAsync(stepMs)
|
|
elapsed += stepMs
|
|
}
|
|
return elapsed
|
|
}
|
|
|
|
describe('AutoDiscover project list', () => {
|
|
it('keeps the visible-page budget inside 5 seconds', () => {
|
|
expect(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS + PROJECT_SYNC_RENDER_BUDGET_MS).toBeLessThanOrEqual(5000)
|
|
expect(projectSyncDelay(0)).toBe(PROJECT_SYNC_INTERVAL_MS)
|
|
})
|
|
|
|
it('surfaces a project the member was added to within 5 seconds without switching the current work', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
store.startProjectSync(env.port)
|
|
projects = [project('host-a'), project('host-b', 'carol')]
|
|
const elapsed = await advanceUntil(() => store.getState().projects.some(p => p.id === 'host-b'), 5000)
|
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a', 'host-b'])
|
|
expect(elapsed).toBeLessThanOrEqual(5000)
|
|
expect(store.getState()).toMatchObject({ currentProjectId: 'host-a', currentConvId: 'conv-a', projectsLoaded: true })
|
|
expect(requests.map(r => r.path)).not.toContain('/api/conversations/conv-a')
|
|
expect(store.getState().projectSync.status).toBe('ok')
|
|
})
|
|
|
|
it('clears a removed current project within 5 seconds and keeps the remaining projects', async () => {
|
|
projects = [project('host-a'), project('host-b', 'carol')]
|
|
await store.refreshProjects()
|
|
vi.useFakeTimers()
|
|
store.startProjectSync(fakeEnv().port)
|
|
projects = [project('host-b', 'carol')]
|
|
const elapsed = await advanceUntil(() => store.getState().currentProjectId !== 'host-a', 5000)
|
|
expect(elapsed).toBeLessThanOrEqual(5000)
|
|
expect(store.getState()).toMatchObject({ currentProjectId: 'host-b', currentConvId: null })
|
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-b'])
|
|
expect(store.getState().tasks.map(t => t.id)).toEqual([])
|
|
})
|
|
|
|
it('does not re-render or re-open anything when the list is unchanged', async () => {
|
|
vi.useFakeTimers()
|
|
store.startProjectSync(fakeEnv().port)
|
|
const before = store.getState()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 3)
|
|
expect(requests.filter(r => r.path === '/api/projects')).toHaveLength(3)
|
|
expect(store.getState().projects).toBe(before.projects)
|
|
expect(requests.map(r => r.path)).not.toContain('/api/conversations')
|
|
})
|
|
})
|
|
|
|
describe('RefreshSingleFlight', () => {
|
|
it('never overlaps requests and folds wake-ups during a request into one immediate follow-up', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
let concurrent = 0
|
|
let peak = 0
|
|
const calls: ReturnType<typeof deferred<void>>[] = []
|
|
const fetchOnce = vi.fn(() => {
|
|
concurrent++; peak = Math.max(peak, concurrent)
|
|
const call = deferred<void>()
|
|
calls.push(call)
|
|
return call.promise.finally(() => { concurrent-- })
|
|
})
|
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
|
sync.start()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
env.focus(); env.online(); env.show()
|
|
await vi.advanceTimersByTimeAsync(500)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
calls[0].resolve()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
|
calls[1].resolve()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS - 1)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
|
expect(peak).toBe(1)
|
|
sync.stop()
|
|
})
|
|
|
|
it('schedules the next round only after the current one finishes', async () => {
|
|
vi.useFakeTimers()
|
|
const slow = deferred<void>()
|
|
const fetchOnce = vi.fn().mockReturnValueOnce(slow.promise).mockResolvedValue(undefined)
|
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, () => {})
|
|
sync.start()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + 1500)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
slow.resolve()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
|
sync.stop()
|
|
})
|
|
})
|
|
|
|
describe('RefreshBackoff', () => {
|
|
it('reports a sync failure, backs off exponentially and returns to the 2-second rhythm on success', async () => {
|
|
vi.useFakeTimers()
|
|
const statuses: [ProjectSyncStatus, number][] = []
|
|
let fail = true
|
|
const fetchOnce = vi.fn(async () => { if (fail) throw new Error('503: unavailable') })
|
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, (status, failures) => statuses.push([status, failures]))
|
|
sync.start()
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
expect(statuses.at(-1)).toEqual(['degraded', 1])
|
|
await vi.advanceTimersByTimeAsync(3999)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(2)
|
|
expect(statuses.at(-1)).toEqual(['degraded', 2])
|
|
fail = false
|
|
await vi.advanceTimersByTimeAsync(8000)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(3)
|
|
expect(statuses.at(-1)).toEqual(['ok', 0])
|
|
await vi.advanceTimersByTimeAsync(2000)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(4)
|
|
expect(projectSyncDelay(10)).toBe(30000)
|
|
sync.stop()
|
|
})
|
|
|
|
it('aborts a request that exceeds its 2-second budget and counts it as a failure', async () => {
|
|
vi.useFakeTimers()
|
|
let signal: AbortSignal | undefined
|
|
const fetchOnce = vi.fn((s: AbortSignal) => { signal = s; return new Promise<void>(() => {}) })
|
|
const statuses: ProjectSyncStatus[] = []
|
|
const sync = createProjectAutoDiscover(fetchOnce, fakeEnv().port, status => statuses.push(status))
|
|
sync.start()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS + PROJECT_SYNC_REQUEST_BUDGET_MS)
|
|
expect(signal?.aborted).toBe(true)
|
|
expect(statuses.at(-1)).toBe('degraded')
|
|
sync.stop()
|
|
})
|
|
|
|
it('shows the store sync state as degraded while the list endpoint fails', async () => {
|
|
vi.useFakeTimers()
|
|
const previous = route
|
|
route = (path, init) => path === '/api/projects' ? Promise.resolve(response({ error: 'down' }, 503)) : previous(path, init)
|
|
store.startProjectSync(fakeEnv().port)
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
|
expect(store.getState().projectSync).toEqual({ status: 'degraded', failures: 1 })
|
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-a'])
|
|
})
|
|
})
|
|
|
|
describe('RefreshHiddenPause', () => {
|
|
it('pauses on a hidden page and resumes immediately when it becomes visible again', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
const statuses: ProjectSyncStatus[] = []
|
|
const fetchOnce = vi.fn(async () => {})
|
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
|
sync.start()
|
|
env.hide()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 10)
|
|
expect(fetchOnce).not.toHaveBeenCalled()
|
|
expect(statuses.at(-1)).toBe('paused')
|
|
env.show()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
expect(statuses.at(-1)).toBe('ok')
|
|
sync.stop()
|
|
})
|
|
|
|
it('does not start when the page is already hidden', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
env.hide()
|
|
const fetchOnce = vi.fn(async () => {})
|
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, () => {})
|
|
sync.start()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
|
expect(fetchOnce).not.toHaveBeenCalled()
|
|
sync.stop()
|
|
})
|
|
})
|
|
|
|
describe('RefreshReconnectCompensate', () => {
|
|
it('stops while offline and re-checks immediately when the network returns', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
const statuses: ProjectSyncStatus[] = []
|
|
const fetchOnce = vi.fn(async () => {})
|
|
const sync = createProjectAutoDiscover(fetchOnce, env.port, status => statuses.push(status))
|
|
sync.start()
|
|
env.offline()
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS * 5)
|
|
expect(fetchOnce).not.toHaveBeenCalled()
|
|
expect(statuses.at(-1)).toBe('offline')
|
|
env.online()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(fetchOnce).toHaveBeenCalledTimes(1)
|
|
sync.stop()
|
|
})
|
|
|
|
it('re-checks immediately on focus instead of waiting for the next interval', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
store.startProjectSync(env.port)
|
|
projects = [project('host-a'), project('host-b', 'carol')]
|
|
env.focus()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(store.getState().projects.map(p => p.id)).toContain('host-b')
|
|
})
|
|
|
|
it('drops a late list from the previous identity after a reconnect under another account', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
const late = deferred<Response>()
|
|
const previous = route
|
|
route = (path, init) => path === '/api/projects' ? late.promise : previous(path, init)
|
|
store.startProjectSync(env.port)
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
|
auth.beginSession()
|
|
route = previous
|
|
projects = [project('host-c', 'dave')]
|
|
await store.refreshProjects()
|
|
late.resolve(response([project('host-a'), project('alice-secret')]))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(store.getState().projects.map(p => p.id)).toEqual(['host-c'])
|
|
})
|
|
})
|
|
|
|
function routeScratchSession() {
|
|
const previous = route
|
|
route = (path, init) => {
|
|
if (path === '/api/scratch-sessions' && init?.method === 'POST') {
|
|
return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } }))
|
|
}
|
|
if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 }))
|
|
return previous(path, init)
|
|
}
|
|
}
|
|
|
|
describe('DefaultProjectDisabled', () => {
|
|
it('does not create a default project when the member has no projects yet', async () => {
|
|
projects = []
|
|
await store.refreshProjects()
|
|
routeScratchSession()
|
|
requests = []
|
|
await store.createTask()
|
|
const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path)
|
|
expect(posts).toEqual(['/api/scratch-sessions'])
|
|
expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' })
|
|
})
|
|
|
|
it('does not treat a list that has not arrived as empty', async () => {
|
|
auth.beginSession()
|
|
const pending = deferred<Response>()
|
|
route = path => path === '/api/projects' ? pending.promise : Promise.resolve(response({}))
|
|
void store.refreshProjects()
|
|
await expect(store.createTask()).rejects.toThrow(/尚未加载/)
|
|
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
|
pending.resolve(response([]))
|
|
})
|
|
|
|
it('points a creator to explicit project creation instead of creating one silently', async () => {
|
|
const previous = route
|
|
route = (path, init) => path === '/api/me'
|
|
? Promise.resolve(response({ owner: 'bob', name: 'Bob', auth: true, systemActions: ['project.create'],
|
|
linkedCapabilities: { projectCreate: true, source: 'role', linkedActions: [], authorityEpoch: 1 } }))
|
|
: previous(path, init)
|
|
await store.refreshMe()
|
|
projects = []
|
|
await store.refreshProjects()
|
|
routeScratchSession()
|
|
requests = []
|
|
await store.createTask()
|
|
expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([])
|
|
expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions'])
|
|
expect(store.getState().projects).toEqual([])
|
|
})
|
|
|
|
it('surfaces the stage-specific project error before any POST is sent', async () => {
|
|
projects = [{ ...project('host-archived'), settings: { 'rdm.state': 'archived' } } as Project]
|
|
await store.refreshProjects()
|
|
requests = []
|
|
await expect(store.createTask()).rejects.toThrow(/请先新建项目|归档/)
|
|
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('AutoDiscover sync ownership on lifecycle events', () => {
|
|
it('surfaces sync start and stop without leaking listeners across re-starts', () => {
|
|
const env = fakeEnv()
|
|
const release: Array<() => void> = []
|
|
const port: ProjectSyncEnv = { ...env.port, subscribe: handlers => {
|
|
const releaseFn = () => {}
|
|
release.push(releaseFn)
|
|
env.port.subscribe(handlers)
|
|
return () => { releaseFn() }
|
|
} }
|
|
store.startProjectSync(port)
|
|
store.stopProjectSync()
|
|
store.startProjectSync(port)
|
|
expect(release.length).toBe(2)
|
|
store.stopProjectSync()
|
|
})
|
|
|
|
it('keeps the store sync state idle after a clean stop and ready to re-start', () => {
|
|
store.startProjectSync(fakeEnv().port)
|
|
store.stopProjectSync()
|
|
expect(store.getState().projectSync.status).toBe('idle')
|
|
expect(store.getState().projectSync.failures).toBe(0)
|
|
})
|
|
|
|
it('drops the late response when an identity reset interleaves a sync', async () => {
|
|
vi.useFakeTimers()
|
|
const env = fakeEnv()
|
|
const pending = deferred<Response>()
|
|
const previous = route
|
|
route = (path, init) => path === '/api/projects' ? pending.promise : previous(path, init)
|
|
store.startProjectSync(env.port)
|
|
await vi.advanceTimersByTimeAsync(PROJECT_SYNC_INTERVAL_MS)
|
|
store.resetIdentity()
|
|
pending.resolve(response([project('host-late')]))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(store.getState().projects.find(p => p.id === 'host-late')).toBeUndefined()
|
|
store.stopProjectSync()
|
|
})
|
|
})
|