feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { fetchActivity } from '../src/api/activity'
|
||||
import { queryClient } from '../src/api/queryClient'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'activity-access-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('activity query contract', () => {
|
||||
it('preserves the backend entity reference for result navigation', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
|
||||
items: [{
|
||||
id: 42,
|
||||
type: 'TaskUpdated',
|
||||
message: 'Task moved to review.',
|
||||
at: '2026-07-31T10:00:00.000Z',
|
||||
entity: {
|
||||
type: 'task',
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
label: null,
|
||||
},
|
||||
}],
|
||||
totalCount: 1,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
totalPages: 1,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})))
|
||||
|
||||
const activity = await fetchActivity()
|
||||
|
||||
expect(activity.items[0]?.entity).toEqual({
|
||||
type: 'task',
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
label: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
canApproveAgentProposal,
|
||||
canRejectAgentProposal,
|
||||
canRetryAgentProposal,
|
||||
getAgentProposalStatusMeta,
|
||||
type AgentProposalDto,
|
||||
} from '../src/api/agentProposals'
|
||||
import { apiFetch } from '../src/services/api'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
const baseProposal: AgentProposalDto = {
|
||||
id: '0d2d33d7-bafa-4510-b135-c6a96021957a',
|
||||
source: 'manual',
|
||||
requestedName: 'Release Coordinator',
|
||||
requestedAgentId: 'release-coordinator',
|
||||
role: 'Delivery Operations',
|
||||
description: 'Coordinates releases.',
|
||||
model: null,
|
||||
emoji: null,
|
||||
avatar: null,
|
||||
workspace: '/managed/workspace-release-coordinator',
|
||||
files: [],
|
||||
status: 'awaiting_approval',
|
||||
requestedBy: 'bao',
|
||||
approvedBy: null,
|
||||
rejectedBy: null,
|
||||
rejectionReason: null,
|
||||
openClawAgentId: null,
|
||||
openClawWorkspace: null,
|
||||
error: null,
|
||||
revision: 1,
|
||||
createdAt: '2026-07-31T08:00:00.000Z',
|
||||
updatedAt: '2026-07-31T08:00:00.000Z',
|
||||
approvedAt: null,
|
||||
rejectedAt: null,
|
||||
completedAt: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'proposal-access-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('agent proposal frontend contract', () => {
|
||||
it('maps lifecycle states to safe owner actions', () => {
|
||||
expect(getAgentProposalStatusMeta('awaiting_approval').label).toBe('Freigabe offen')
|
||||
expect(canApproveAgentProposal(baseProposal)).toBe(true)
|
||||
expect(canRejectAgentProposal(baseProposal)).toBe(true)
|
||||
expect(canRetryAgentProposal(baseProposal)).toBe(false)
|
||||
|
||||
const failed = { ...baseProposal, status: 'failed' }
|
||||
expect(canApproveAgentProposal(failed)).toBe(false)
|
||||
expect(canRejectAgentProposal(failed)).toBe(false)
|
||||
expect(canRetryAgentProposal(failed)).toBe(true)
|
||||
expect(canRetryAgentProposal({ ...baseProposal, status: 'in_doubt' })).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves generated Request headers through the authenticated fetch boundary', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const request = new Request('http://localhost/api/v1/openclaw/agent-proposals', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Idempotency-Key': 'agent-proposal-create:test',
|
||||
'X-Correlation-ID': 'd4f72d7d-6331-4bcc-87e8-e74480315073',
|
||||
},
|
||||
body: JSON.stringify({ name: 'Release Coordinator' }),
|
||||
})
|
||||
|
||||
await apiFetch(request)
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Authorization')).toBe('Bearer proposal-access-token')
|
||||
expect(headers.get('Content-Type')).toBe('application/json')
|
||||
expect(headers.get('Idempotency-Key')).toBe('agent-proposal-create:test')
|
||||
expect(headers.get('X-Correlation-ID')).toBe('d4f72d7d-6331-4bcc-87e8-e74480315073')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
import { apiFetch } from '../src/services/api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('auth store', () => {
|
||||
it('shares the initial refresh across overlapping router guards', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useAuthStore()
|
||||
|
||||
let resolveRefresh!: (response: Response) => void
|
||||
const fetchMock = vi.fn(() => new Promise<Response>(resolve => {
|
||||
resolveRefresh = resolve
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const firstGuard = store.initialize()
|
||||
const secondGuard = store.initialize()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRefresh(new Response(JSON.stringify({
|
||||
accessToken: 'test-access-token',
|
||||
expiresAt: '2026-07-28T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await expect(Promise.all([firstGuard, secondGuard])).resolves.toEqual([true, true])
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.user?.displayName).toBe('Bao')
|
||||
})
|
||||
|
||||
it('uses the JWT without emitting a browser-controlled agent identity header', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useAuthStore()
|
||||
store.initialized = true
|
||||
store.applySession({
|
||||
accessToken: 'signed-jwt',
|
||||
expiresAt: '2026-07-28T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'iris-user',
|
||||
email: 'iris@nexus.local',
|
||||
displayName: 'Iris',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await apiFetch('/api/v1/tasks/board')
|
||||
|
||||
const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit
|
||||
const headers = new Headers(requestInit.headers)
|
||||
expect(headers.get('Authorization')).toBe('Bearer signed-jwt')
|
||||
expect(headers.has('X-Agent-Id')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
import { useMissionControlUiStore } from '../src/stores/missionControlUi'
|
||||
import { buildMissionControlContext } from '../src/utils/missionControlContext'
|
||||
import { reportOperationEnvelope } from '../src/services/operationResults'
|
||||
import { routeForEntity } from '../src/utils/entityNavigation'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('mission control context', () => {
|
||||
it('maps an object route into bounded Iris context', () => {
|
||||
const context = buildMissionControlContext({
|
||||
name: 'TaskDetail',
|
||||
path: '/tasks/98f927bb',
|
||||
params: { id: '98f927bb' },
|
||||
})
|
||||
|
||||
expect(context).toEqual({
|
||||
routeName: 'TaskDetail',
|
||||
path: '/tasks/98f927bb',
|
||||
surface: 'Task detail',
|
||||
entityType: 'task',
|
||||
entityId: '98f927bb',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps index routes free of invented entity identifiers', () => {
|
||||
const context = buildMissionControlContext({
|
||||
name: 'Run Control',
|
||||
path: '/runs',
|
||||
params: {},
|
||||
})
|
||||
|
||||
expect(context.surface).toBe('Run Control')
|
||||
expect(context.entityType).toBeNull()
|
||||
expect(context.entityId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mission control overlays', () => {
|
||||
it('keeps command and Iris dialogs mutually exclusive', () => {
|
||||
useAuthStore().applySession({
|
||||
accessToken: 'owner-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
const store = useMissionControlUiStore()
|
||||
|
||||
store.openCommand()
|
||||
expect(store.commandOpen).toBe(true)
|
||||
expect(store.irisOpen).toBe(false)
|
||||
|
||||
store.openIris()
|
||||
expect(store.commandOpen).toBe(false)
|
||||
expect(store.irisOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses to open the Iris mutation dialog for non-owners', () => {
|
||||
useAuthStore().applySession({
|
||||
accessToken: 'viewer-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'viewer',
|
||||
email: 'viewer@nexus.local',
|
||||
displayName: 'Viewer',
|
||||
role: 'viewer',
|
||||
},
|
||||
})
|
||||
const store = useMissionControlUiStore()
|
||||
|
||||
store.openCommand()
|
||||
store.openIris()
|
||||
|
||||
expect(store.commandOpen).toBe(true)
|
||||
expect(store.irisOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('publishes and dismisses a normalized mutation result', () => {
|
||||
const store = useMissionControlUiStore()
|
||||
|
||||
const result = reportOperationEnvelope({
|
||||
operation: {
|
||||
operationId: 'operation-9',
|
||||
status: 'updated',
|
||||
revision: '3',
|
||||
primaryRef: { type: 'task', id: 'task-9', label: 'Task 9' },
|
||||
affectedRefs: [{ type: 'agent', id: 'iris', label: 'Iris' }],
|
||||
traceId: null,
|
||||
},
|
||||
}, 'Task aktualisiert')
|
||||
|
||||
expect(result?.revision).toBe(3)
|
||||
expect(store.operationTitle).toBe('Task aktualisiert')
|
||||
expect(store.operationResult?.primaryRef?.id).toBe('task-9')
|
||||
|
||||
store.dismissOperation()
|
||||
expect(store.operationResult).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves OpenClaw runtime and agent-file references to existing routes', () => {
|
||||
expect(routeForEntity({ type: 'openclaw-task', id: 'runtime-1', label: null })).toEqual({
|
||||
name: 'Run Control',
|
||||
query: { task: 'runtime-1' },
|
||||
})
|
||||
expect(routeForEntity({ type: 'agent-file', id: 'iris/AGENTS.md', label: null })).toEqual({
|
||||
name: 'AgentDetail',
|
||||
params: { id: 'iris' },
|
||||
query: { file: 'AGENTS.md' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
fetchOpenClawModelAuthStatus,
|
||||
refreshOpenClawModelAuthStatus,
|
||||
type OpenClawModelAuthCollectionDto,
|
||||
} from '../src/api/openClawModels'
|
||||
import { queryClient, queryKeys } from '../src/api/queryClient'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
function collection(status: string): OpenClawModelAuthCollectionDto {
|
||||
return {
|
||||
state: 'ready',
|
||||
items: [{
|
||||
provider: 'openai',
|
||||
displayName: 'OpenAI',
|
||||
status,
|
||||
expiry: null,
|
||||
profiles: [{ type: 'oauth', status: 'ok', count: 1 }],
|
||||
apiKey: null,
|
||||
usage: { summary: 'Ready', plan: 'team' },
|
||||
}],
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
recovery: null,
|
||||
checkedAt: '2026-07-31T10:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
const raw = input instanceof Request ? input.url : String(input)
|
||||
return new URL(raw, 'http://localhost')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'models-access-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('OpenClaw model authentication query', () => {
|
||||
it('loads the generated sanitized contract without forcing a gateway refresh', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify(collection('ok')), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchOpenClawModelAuthStatus()).resolves.toEqual(collection('ok'))
|
||||
|
||||
const [input, init] = fetchMock.mock.calls[0]!
|
||||
const url = requestUrl(input)
|
||||
expect(url.pathname).toBe('/api/v1/openclaw/models/auth-status')
|
||||
expect(url.searchParams.get('refresh')).toBe('false')
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer models-access-token')
|
||||
})
|
||||
|
||||
it('forces a refresh through the same canonical query cache', async () => {
|
||||
queryClient.setQueryData(queryKeys.openClawModelAuth(), collection('stale'))
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify(collection('ok')), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(refreshOpenClawModelAuthStatus()).resolves.toEqual(collection('ok'))
|
||||
|
||||
const url = requestUrl(fetchMock.mock.calls[0]![0])
|
||||
expect(url.searchParams.get('refresh')).toBe('true')
|
||||
expect(queryClient.getQueryData(queryKeys.openClawModelAuth())).toEqual(collection('ok'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import { QueryClient, type InfiniteData } from '@tanstack/vue-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyOpenClawCronDetail,
|
||||
mergeOpenClawCronPages,
|
||||
} from '../src/api/openClawCron'
|
||||
import {
|
||||
applyOpenClawRunOperation,
|
||||
type OpenClawRunCollectionDto,
|
||||
type OpenClawRunDto,
|
||||
type OpenClawRunOperationDto,
|
||||
} from '../src/api/openClawRuns'
|
||||
import { queryKeys } from '../src/api/queryClient'
|
||||
import {
|
||||
invalidateOpenClawCronEventQueries,
|
||||
invalidateOpenClawRunEventQueries,
|
||||
} from '../src/services/domainEvents'
|
||||
import type {
|
||||
OpenClawCollection,
|
||||
OpenClawCronJob,
|
||||
OpenClawCronJobDetail,
|
||||
} from '../src/types/openclaw'
|
||||
|
||||
function run(overrides: Partial<OpenClawRunDto> = {}): OpenClawRunDto {
|
||||
return {
|
||||
id: '98f927bb-3df8-44d0-9ec9-cd98cdcf12db',
|
||||
title: 'Review task',
|
||||
prompt: 'Review the task.',
|
||||
agentId: 'iris',
|
||||
sessionKey: 'agent:iris:main',
|
||||
status: 'running',
|
||||
taskId: '931a9025-f2d6-4e48-8a41-24e8733807db',
|
||||
projectId: null,
|
||||
openClawRunId: 'oc-run-1',
|
||||
retriedFromRunId: null,
|
||||
correlationId: 'correlation-1',
|
||||
actor: 'bao',
|
||||
lastError: null,
|
||||
lastGatewaySequence: 1,
|
||||
sequenceGapDetected: false,
|
||||
canStop: true,
|
||||
canRetry: false,
|
||||
canResume: false,
|
||||
resumeCapabilityMessage: null,
|
||||
createdAt: '2026-07-31T10:00:00.000Z',
|
||||
updatedAt: '2026-07-31T10:01:00.000Z',
|
||||
startedAt: '2026-07-31T10:00:01.000Z',
|
||||
finishedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function runCollection(items: OpenClawRunDto[]): OpenClawRunCollectionDto {
|
||||
return {
|
||||
items,
|
||||
nextCursor: 'cursor-1',
|
||||
checkedAt: '2026-07-31T10:01:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function cronJob(id: string, enabled = true): OpenClawCronJob {
|
||||
return {
|
||||
id,
|
||||
name: `Job ${id}`,
|
||||
description: null,
|
||||
schedule: '0 7 * * *',
|
||||
timeZone: 'Europe/Berlin',
|
||||
enabled,
|
||||
status: 'idle',
|
||||
agentId: 'iris',
|
||||
sessionKey: 'agent:iris:main',
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastRunStatus: null,
|
||||
lastError: null,
|
||||
canRun: true,
|
||||
resourceHash: `hash-${id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function cronDetail(id: string, enabled = true): OpenClawCronJobDetail {
|
||||
return {
|
||||
id,
|
||||
name: `Updated ${id}`,
|
||||
displayName: null,
|
||||
description: null,
|
||||
enabled,
|
||||
deleteAfterRun: false,
|
||||
agentId: 'iris',
|
||||
sessionKey: 'agent:iris:main',
|
||||
sessionTarget: 'isolated',
|
||||
wakeMode: 'now',
|
||||
schedule: {
|
||||
kind: 'cron',
|
||||
expression: '0 8 * * *',
|
||||
timeZone: 'Europe/Berlin',
|
||||
at: null,
|
||||
everyMs: null,
|
||||
anchorMs: null,
|
||||
staggerMs: null,
|
||||
command: null,
|
||||
workingDirectory: null,
|
||||
},
|
||||
payload: {
|
||||
kind: 'agentTurn',
|
||||
text: null,
|
||||
message: 'Run a bounded check.',
|
||||
model: null,
|
||||
fallbacks: [],
|
||||
thinking: null,
|
||||
timeoutSeconds: null,
|
||||
allowUnsafeExternalContent: null,
|
||||
lightContext: null,
|
||||
toolsAllow: [],
|
||||
arguments: [],
|
||||
workingDirectory: null,
|
||||
environmentKeys: [],
|
||||
inputConfigured: false,
|
||||
noOutputTimeoutSeconds: null,
|
||||
outputMaxBytes: null,
|
||||
},
|
||||
delivery: null,
|
||||
trigger: null,
|
||||
failureAlert: null,
|
||||
createdAt: '2026-07-31T10:00:00.000Z',
|
||||
updatedAt: '2026-07-31T10:02:00.000Z',
|
||||
nextRunAt: null,
|
||||
lastRunAt: null,
|
||||
lastRunStatus: null,
|
||||
lastError: null,
|
||||
resourceHash: `updated-hash-${id}`,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
canRun: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenClaw Vue Query cache adapters', () => {
|
||||
it('patches generated run contracts across list and task-related caches', () => {
|
||||
const client = new QueryClient()
|
||||
const current = run()
|
||||
const other = run({
|
||||
id: '568f10e3-4fb0-4af4-acff-53030387ce72',
|
||||
taskId: null,
|
||||
title: 'Other run',
|
||||
})
|
||||
const listKey = queryKeys.openClawRuns({ limit: 50 })
|
||||
const taskKey = queryKeys.taskRuns(current.taskId!)
|
||||
const runningKey = queryKeys.openClawRuns({ limit: 50, status: 'running' })
|
||||
client.setQueryData(listKey, runCollection([current, other]))
|
||||
client.setQueryData(taskKey, runCollection([current]))
|
||||
client.setQueryData(runningKey, runCollection([current]))
|
||||
const completed = run({
|
||||
status: 'completed',
|
||||
canStop: false,
|
||||
updatedAt: '2026-07-31T10:03:00.000Z',
|
||||
finishedAt: '2026-07-31T10:03:00.000Z',
|
||||
})
|
||||
const operation: OpenClawRunOperationDto = {
|
||||
ok: true,
|
||||
state: 'completed',
|
||||
message: 'Completed.',
|
||||
run: completed,
|
||||
resultRun: null,
|
||||
completedAt: '2026-07-31T10:03:00.000Z',
|
||||
}
|
||||
|
||||
applyOpenClawRunOperation(client, operation)
|
||||
|
||||
expect(client.getQueryData<OpenClawRunCollectionDto>(listKey)?.items)
|
||||
.toEqual([completed, other])
|
||||
expect(client.getQueryData<OpenClawRunCollectionDto>(listKey)?.nextCursor)
|
||||
.toBe('cursor-1')
|
||||
expect(client.getQueryData<OpenClawRunCollectionDto>(taskKey)?.items)
|
||||
.toEqual([completed])
|
||||
expect(client.getQueryData<OpenClawRunCollectionDto>(runningKey)?.items)
|
||||
.toEqual([])
|
||||
expect(client.getQueryData(queryKeys.openClawRun(completed.id))).toEqual(completed)
|
||||
})
|
||||
|
||||
it('merges cron pages and updates only compatible cached lists', () => {
|
||||
const client = new QueryClient()
|
||||
const enabledKey = queryKeys.openClawCron({ includeDisabled: false, limit: 50 })
|
||||
const allKey = queryKeys.openClawCron({ includeDisabled: true, limit: 50 })
|
||||
const pages: InfiniteData<OpenClawCollection<OpenClawCronJob>, string | null> = {
|
||||
pages: [
|
||||
{
|
||||
state: 'ready',
|
||||
items: [cronJob('a'), cronJob('b')],
|
||||
nextCursor: 'next',
|
||||
message: null,
|
||||
recovery: null,
|
||||
checkedAt: '2026-07-31T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
state: 'ready',
|
||||
items: [cronJob('b'), cronJob('c')],
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
recovery: null,
|
||||
checkedAt: '2026-07-31T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
pageParams: [null, 'next'],
|
||||
}
|
||||
client.setQueryData(enabledKey, pages)
|
||||
client.setQueryData(allKey, pages)
|
||||
|
||||
applyOpenClawCronDetail(client, cronDetail('a', false))
|
||||
|
||||
const enabled = mergeOpenClawCronPages(client.getQueryData(enabledKey))
|
||||
const all = mergeOpenClawCronPages(client.getQueryData(allKey))
|
||||
expect(enabled?.items.map(item => item.id)).toEqual(['b', 'c'])
|
||||
expect(all?.items.find(item => item.id === 'a')).toMatchObject({
|
||||
name: 'Updated a',
|
||||
enabled: false,
|
||||
resourceHash: 'updated-hash-a',
|
||||
})
|
||||
expect(all?.items.map(item => item.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(all?.nextCursor).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates only the affected run and cron domains', async () => {
|
||||
const runId = '98f927bb-3df8-44d0-9ec9-cd98cdcf12db'
|
||||
const otherRunId = '568f10e3-4fb0-4af4-acff-53030387ce72'
|
||||
const cronId = 'cron-a'
|
||||
const client = (await import('../src/api/queryClient')).queryClient
|
||||
client.clear()
|
||||
client.setQueryData(queryKeys.openClawRuns({ limit: 50 }), runCollection([]))
|
||||
client.setQueryData(queryKeys.openClawRunHistory(runId), { marker: 'target' })
|
||||
client.setQueryData(queryKeys.openClawRunHistory(otherRunId), { marker: 'other' })
|
||||
client.setQueryData(queryKeys.openClawCron({ includeDisabled: true, limit: 50 }), { marker: 'list' })
|
||||
client.setQueryData(queryKeys.openClawCronDetail(cronId), cronDetail(cronId))
|
||||
client.setQueryData(queryKeys.openClawCronDetail('cron-b'), cronDetail('cron-b'))
|
||||
client.setQueryData(queryKeys.openClawAgents(), { marker: 'agents' })
|
||||
|
||||
await invalidateOpenClawRunEventQueries(runId)
|
||||
|
||||
expect(client.getQueryState(queryKeys.openClawRuns({ limit: 50 }))?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(queryKeys.openClawRunHistory(runId))?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(queryKeys.openClawRunHistory(otherRunId))?.isInvalidated).toBe(false)
|
||||
expect(client.getQueryState(queryKeys.openClawAgents())?.isInvalidated).toBe(false)
|
||||
|
||||
await invalidateOpenClawCronEventQueries(cronId)
|
||||
|
||||
expect(client.getQueryState(queryKeys.openClawCron({ includeDisabled: true, limit: 50 }))?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(queryKeys.openClawCronDetail(cronId))?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(queryKeys.openClawCronDetail('cron-b'))?.isInvalidated).toBe(false)
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
startOpenClawRun,
|
||||
type OpenClawRunDto,
|
||||
} from '../src/api/openClawRuns'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
const run: OpenClawRunDto = {
|
||||
id: '98f927bb-3df8-44d0-9ec9-cd98cdcf12db',
|
||||
title: 'Review the task',
|
||||
prompt: 'Review task 42 and propose the next action.',
|
||||
agentId: 'iris',
|
||||
sessionKey: 'agent:iris:main',
|
||||
status: 'running',
|
||||
taskId: null,
|
||||
projectId: null,
|
||||
openClawRunId: 'openclaw-run-42',
|
||||
retriedFromRunId: null,
|
||||
correlationId: 'correlation-42',
|
||||
actor: 'bao',
|
||||
lastError: null,
|
||||
lastGatewaySequence: 42,
|
||||
sequenceGapDetected: false,
|
||||
canStop: true,
|
||||
canRetry: false,
|
||||
canResume: false,
|
||||
resumeCapabilityMessage: 'Same-run resume is not advertised.',
|
||||
createdAt: '2026-07-30T10:00:00.000Z',
|
||||
updatedAt: '2026-07-30T10:00:01.000Z',
|
||||
startedAt: '2026-07-30T10:00:01.000Z',
|
||||
finishedAt: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'test-access-token',
|
||||
expiresAt: '2026-07-30T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenClaw durable runs', () => {
|
||||
it('sends idempotency, correlation and W3C trace metadata for a dispatch', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
ok: true,
|
||||
state: 'running',
|
||||
message: 'Run dispatched.',
|
||||
run,
|
||||
resultRun: null,
|
||||
completedAt: '2026-07-30T10:00:01.000Z',
|
||||
}), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await startOpenClawRun({
|
||||
prompt: run.prompt,
|
||||
agentId: run.agentId,
|
||||
sessionKey: run.sessionKey,
|
||||
})
|
||||
|
||||
expect(result.run.id).toBe(run.id)
|
||||
const [, init] = fetchMock.mock.calls[0]
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get('Authorization')).toBe('Bearer test-access-token')
|
||||
expect(headers.get('Idempotency-Key')).toMatch(/^openclaw-run-start:/)
|
||||
expect(headers.get('X-Correlation-ID')).toMatch(/^[0-9a-f-]{36}$/i)
|
||||
expect(headers.get('traceparent')).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
fetchOpenClawAgents,
|
||||
fetchOpenClawCapabilities,
|
||||
openClawOverviewQueryOptions,
|
||||
projectOpenClawAgentNodes,
|
||||
type OpenClawOverviewDto,
|
||||
} from '../src/api/openclawRuntime'
|
||||
import { queryClient } from '../src/api/queryClient'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
import type {
|
||||
OpenClawAgent,
|
||||
OpenClawCapability,
|
||||
OpenClawCollection,
|
||||
} from '../src/types/openclaw'
|
||||
|
||||
const now = '2026-07-31T10:00:00.000Z'
|
||||
|
||||
function collection<T>(items: T[]): OpenClawCollection<T> {
|
||||
return {
|
||||
state: 'ready',
|
||||
items,
|
||||
nextCursor: null,
|
||||
message: null,
|
||||
recovery: null,
|
||||
checkedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function overviewFixture(): OpenClawOverviewDto {
|
||||
return {
|
||||
connection: {
|
||||
state: 'connected',
|
||||
configured: true,
|
||||
credentialConfigured: true,
|
||||
connected: true,
|
||||
endpoint: 'ws://openclaw-gateway:18789',
|
||||
gatewayVersion: '2026.7.1',
|
||||
requiredVersion: '2026.7.1',
|
||||
versionPinned: true,
|
||||
versionMatches: true,
|
||||
protocolVersion: 4,
|
||||
grantedScopes: ['operator.read'],
|
||||
advertisedEvents: ['agent', 'task'],
|
||||
lastConnectedAt: now,
|
||||
lastEventAt: now,
|
||||
reconnectAttempts: 0,
|
||||
message: 'Connected',
|
||||
recovery: null,
|
||||
checkedAt: now,
|
||||
deviceId: 'nexus-test',
|
||||
pairingRequired: false,
|
||||
pairingRequestId: null,
|
||||
},
|
||||
capabilities: [],
|
||||
tasks: collection([{
|
||||
id: 'task-1',
|
||||
title: 'Prepare release',
|
||||
status: 'running',
|
||||
kind: 'agent',
|
||||
runtime: 'openclaw',
|
||||
agentId: 'iris',
|
||||
sessionKey: 'agent:iris:main',
|
||||
runId: 'run-1',
|
||||
flowId: null,
|
||||
parentTaskId: null,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
finishedAt: null,
|
||||
progress: 42,
|
||||
summary: 'Checks the release gates.',
|
||||
error: null,
|
||||
canCancel: true,
|
||||
}]),
|
||||
sessions: collection([{
|
||||
key: 'agent:iris:main',
|
||||
sessionId: 'session-1',
|
||||
agentId: 'iris',
|
||||
title: 'Release session',
|
||||
status: 'running',
|
||||
kind: 'agent',
|
||||
channel: null,
|
||||
model: 'openai/gpt-5.4',
|
||||
provider: 'openai',
|
||||
runId: 'run-1',
|
||||
updatedAt: now,
|
||||
inputTokens: 800,
|
||||
outputTokens: 400,
|
||||
totalTokens: 1_200,
|
||||
canAbort: true,
|
||||
}]),
|
||||
cronJobs: collection([]),
|
||||
approvals: collection([]),
|
||||
activity: collection([]),
|
||||
models: collection([{
|
||||
id: 'openai/gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
provider: 'openai',
|
||||
configured: true,
|
||||
available: true,
|
||||
contextWindow: 1_000_000,
|
||||
reason: null,
|
||||
}]),
|
||||
agents: collection([{
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
description: 'Coordinates the team.',
|
||||
model: null,
|
||||
provider: 'openai',
|
||||
workspace: '/managed/agents/iris',
|
||||
status: 'ready',
|
||||
}]),
|
||||
generatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
function authenticate(): void {
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'runtime-access-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function pathOf(input: RequestInfo | URL): string {
|
||||
const raw = input instanceof Request ? input.url : String(input)
|
||||
return new URL(raw, 'http://localhost').pathname
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
authenticate()
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('shared OpenClaw runtime queries', () => {
|
||||
it('deduplicates simultaneous overview consumers at the authenticated boundary', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify(overviewFixture()), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const options = openClawOverviewQueryOptions()
|
||||
const results = await Promise.all([
|
||||
queryClient.fetchQuery(options),
|
||||
queryClient.fetchQuery(options),
|
||||
queryClient.fetchQuery(options),
|
||||
])
|
||||
|
||||
expect(results.every(result => result.connection.connected)).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [input, init] = fetchMock.mock.calls[0]!
|
||||
expect(pathOf(input)).toBe('/api/v1/openclaw/overview')
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer runtime-access-token')
|
||||
})
|
||||
|
||||
it('uses the dedicated capability and agent contracts', async () => {
|
||||
const capabilities: OpenClawCapability[] = [{
|
||||
id: 'agents-list',
|
||||
label: 'List agents',
|
||||
method: 'agents.list',
|
||||
requiredScope: 'operator.read',
|
||||
available: true,
|
||||
state: 'ready',
|
||||
reason: null,
|
||||
}]
|
||||
const agents: OpenClawCollection<OpenClawAgent> = collection([
|
||||
overviewFixture().agents.items[0]!,
|
||||
])
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path = pathOf(input)
|
||||
return new Response(JSON.stringify(
|
||||
path.endsWith('/capabilities') ? capabilities : agents,
|
||||
), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchOpenClawCapabilities()).resolves.toEqual(capabilities)
|
||||
await expect(fetchOpenClawAgents()).resolves.toEqual(agents)
|
||||
expect(fetchMock.mock.calls.map(call => pathOf(call[0]))).toEqual([
|
||||
'/api/v1/openclaw/capabilities',
|
||||
'/api/v1/openclaw/agents',
|
||||
])
|
||||
})
|
||||
|
||||
it('projects task, session, model and token state without a dashboard facade', () => {
|
||||
expect(projectOpenClawAgentNodes(overviewFixture())).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'iris',
|
||||
status: 'work',
|
||||
statusLabel: 'Arbeitet',
|
||||
task: 'Prepare release',
|
||||
progress: 42,
|
||||
model: 'openai/gpt-5.4',
|
||||
tokens: '1.2k',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useOperationsStore } from '../src/stores/operations'
|
||||
|
||||
describe('operations store', () => {
|
||||
it('initializes with safe fallback structure', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useOperationsStore()
|
||||
// Fallback provides a valid structure even when API is down
|
||||
expect(store.snapshot).toBeDefined()
|
||||
expect(store.snapshot.runtime.runtime).toBe('OpenClaw')
|
||||
expect(store.snapshot.metrics).toBeDefined()
|
||||
expect(Array.isArray(store.snapshot.projects)).toBe(true)
|
||||
expect(Array.isArray(store.snapshot.tasks)).toBe(true)
|
||||
expect(Array.isArray(store.snapshot.activity)).toBe(true)
|
||||
expect(Array.isArray(store.snapshot.models)).toBe(true)
|
||||
})
|
||||
|
||||
it('initializes routing as empty array', () => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useOperationsStore()
|
||||
expect(Array.isArray(store.routing)).toBe(true)
|
||||
expect(store.routing.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { queryClient, queryKeys } from '../src/api/queryClient'
|
||||
import { applyTaskBoardCardDelta, type TaskBoardCardDto, type TaskBoardPageDto } from '../src/api/taskBoard'
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear()
|
||||
})
|
||||
|
||||
describe('canonical server-state query cache', () => {
|
||||
it('deduplicates three simultaneous consumers into one HTTP query', async () => {
|
||||
let resolveRequest: ((value: string[]) => void) | undefined
|
||||
const response = new Promise<string[]>(resolve => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
const queryFn = vi.fn(() => response)
|
||||
const options = {
|
||||
queryKey: queryKeys.projects(),
|
||||
queryFn,
|
||||
staleTime: 15_000,
|
||||
}
|
||||
|
||||
const consumers = [
|
||||
queryClient.fetchQuery(options),
|
||||
queryClient.fetchQuery(options),
|
||||
queryClient.fetchQuery(options),
|
||||
]
|
||||
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
resolveRequest?.(['project-1'])
|
||||
await expect(Promise.all(consumers)).resolves.toEqual([
|
||||
['project-1'],
|
||||
['project-1'],
|
||||
['project-1'],
|
||||
])
|
||||
expect(queryFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('moves a persisted task delta between board columns without a full snapshot', () => {
|
||||
const card: TaskBoardCardDto = {
|
||||
id: '0d2d33d7-bafa-4510-b135-c6a96021957a',
|
||||
title: 'Release review',
|
||||
detail: null,
|
||||
source: 'bao',
|
||||
state: 'Backlog',
|
||||
priority: 'Medium',
|
||||
assignedTo: 'bao',
|
||||
parentTaskId: null,
|
||||
projectId: null,
|
||||
dueDate: null,
|
||||
createdAt: '2026-07-31T08:00:00.000Z',
|
||||
updatedAt: '2026-07-31T08:00:00.000Z',
|
||||
isAgentTask: false,
|
||||
expectedFrom: null,
|
||||
lastActivityMessage: null,
|
||||
lastActivityAt: null,
|
||||
childTaskCount: 0,
|
||||
openChildTaskCount: 0,
|
||||
hasVisibleDelegation: false,
|
||||
}
|
||||
const page: TaskBoardPageDto = {
|
||||
revision: 'board-r1',
|
||||
offen: [card],
|
||||
inProgress: [],
|
||||
review: [],
|
||||
blocked: [],
|
||||
done: [],
|
||||
nextDoneCursor: null,
|
||||
hasMoreDone: false,
|
||||
}
|
||||
queryClient.setQueryData(queryKeys.taskBoard(50), {
|
||||
pages: [page],
|
||||
pageParams: [null],
|
||||
})
|
||||
|
||||
applyTaskBoardCardDelta({
|
||||
...card,
|
||||
state: 'Review',
|
||||
updatedAt: '2026-07-31T08:01:00.000Z',
|
||||
})
|
||||
|
||||
const result = queryClient.getQueryData<{
|
||||
pages: TaskBoardPageDto[]
|
||||
pageParams: Array<string | null>
|
||||
}>(queryKeys.taskBoard(50))
|
||||
expect(result?.pages[0]?.offen).toEqual([])
|
||||
expect(result?.pages[0]?.review.map(item => item.id)).toEqual([card.id])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { QueryClient } from '@tanstack/vue-query'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
applyAgentFileWrite,
|
||||
fetchAgentActivity,
|
||||
fetchAgentDetail,
|
||||
fetchAgentFile,
|
||||
fetchAgentFiles,
|
||||
fetchAgentSummary,
|
||||
type AgentFileCollectionDto,
|
||||
type AgentFileDto,
|
||||
type AgentFileWriteDto,
|
||||
} from '../src/api/agentDetail'
|
||||
import {
|
||||
fetchDoc,
|
||||
fetchDocs,
|
||||
fetchMemoryFile,
|
||||
fetchMemoryFiles,
|
||||
fetchMemorySearch,
|
||||
} from '../src/api/knowledge'
|
||||
import { fetchIncident, fetchIncidents } from '../src/api/incidents'
|
||||
import { queryKeys } from '../src/api/queryClient'
|
||||
import { fetchSecurityStatus } from '../src/api/security'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
const raw = input instanceof Request ? input.url : String(input)
|
||||
return new URL(raw, 'http://localhost')
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const file: AgentFileDto = {
|
||||
agentId: 'iris',
|
||||
name: 'SOUL.md',
|
||||
missing: false,
|
||||
size: 42,
|
||||
updatedAt: '2026-07-31T10:00:00.000Z',
|
||||
content: '# Iris',
|
||||
contentHash: 'hash-1',
|
||||
checkedAt: '2026-07-31T10:00:01.000Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'server-state-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('remaining server-state fetchers', () => {
|
||||
it('loads agent detail, activity, summary and generated OpenClaw file contracts', async () => {
|
||||
const responses = new Map<string, unknown>([
|
||||
['/api/v1/agents/iris', {
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
role: 'orchestrator',
|
||||
model: 'gpt-5',
|
||||
status: 'Online',
|
||||
}],
|
||||
['/api/v1/agents/iris/activity', [{
|
||||
id: 1,
|
||||
type: 'task',
|
||||
message: 'Delegated work',
|
||||
at: '2026-07-31T09:00:00.000Z',
|
||||
source: 'openclaw',
|
||||
}]],
|
||||
['/api/v1/agents/iris/summary', {
|
||||
now: { text: 'Working', source: 'openclaw' },
|
||||
today: { text: 'Completed review', source: 'openclaw' },
|
||||
generatedAt: '2026-07-31T10:00:00.000Z',
|
||||
}],
|
||||
['/api/v1/openclaw/agents/iris/files', {
|
||||
agentId: 'iris',
|
||||
files: [{
|
||||
name: file.name,
|
||||
missing: file.missing,
|
||||
size: file.size,
|
||||
updatedAt: file.updatedAt,
|
||||
contentHash: file.contentHash,
|
||||
}],
|
||||
checkedAt: file.checkedAt,
|
||||
}],
|
||||
['/api/v1/openclaw/agents/iris/files/SOUL.md', file],
|
||||
])
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const value = responses.get(requestUrl(input).pathname)
|
||||
return value === undefined
|
||||
? new Response(null, { status: 404 })
|
||||
: jsonResponse(value)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchAgentDetail('iris')).resolves.toMatchObject({ id: 'iris' })
|
||||
await expect(fetchAgentActivity('iris')).resolves.toHaveLength(1)
|
||||
await expect(fetchAgentSummary('iris')).resolves.toMatchObject({
|
||||
now: { text: 'Working' },
|
||||
})
|
||||
await expect(fetchAgentFiles('iris')).resolves.toMatchObject({
|
||||
files: [{ name: 'SOUL.md' }],
|
||||
})
|
||||
await expect(fetchAgentFile('iris', 'SOUL.md')).resolves.toEqual(file)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
for (const [, init] of fetchMock.mock.calls) {
|
||||
expect(new Headers(init?.headers).get('Authorization'))
|
||||
.toBe('Bearer server-state-token')
|
||||
}
|
||||
})
|
||||
|
||||
it('loads docs, memory, incidents and security through authenticated fetchers', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = requestUrl(input)
|
||||
if (url.pathname === '/api/v1/docs') return jsonResponse([])
|
||||
if (url.pathname === '/api/v1/docs/runbook%2Fdeploy.md') {
|
||||
return jsonResponse({ name: 'deploy.md', path: 'runbook/deploy.md', content: '# Deploy' })
|
||||
}
|
||||
if (url.pathname === '/api/v1/memory') return jsonResponse([])
|
||||
if (url.pathname === '/api/v1/memory/2026-07-31.md') {
|
||||
return jsonResponse({ name: '2026-07-31.md', content: 'Memory' })
|
||||
}
|
||||
if (url.pathname === '/api/v1/memory/search') {
|
||||
return jsonResponse([{ name: '2026-07-31.md', excerpt: 'Mission control' }])
|
||||
}
|
||||
if (url.pathname === '/api/v1/incidents') return jsonResponse([])
|
||||
if (url.pathname === '/api/v1/incidents/gateway.md') {
|
||||
return jsonResponse({ name: 'gateway.md', title: 'Gateway', content: '# Gateway' })
|
||||
}
|
||||
if (url.pathname === '/api/v1/security/status') {
|
||||
return jsonResponse({
|
||||
authMethod: 'JWT',
|
||||
tokenConfig: {
|
||||
issuer: 'nexus',
|
||||
audience: 'nexus-web',
|
||||
refreshTokenDays: 7,
|
||||
accessTokenMinutes: 15,
|
||||
},
|
||||
rateLimit: 'enabled',
|
||||
passwordPolicy: 'strong',
|
||||
cookieConfig: { httpOnly: true, secure: true, sameSite: 'Strict' },
|
||||
twoFactorEnabled: false,
|
||||
passkeyEnabled: false,
|
||||
})
|
||||
}
|
||||
return new Response(null, { status: 404 })
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchDocs()).resolves.toEqual([])
|
||||
await expect(fetchDoc('runbook/deploy.md')).resolves.toMatchObject({ name: 'deploy.md' })
|
||||
await expect(fetchMemoryFiles()).resolves.toEqual([])
|
||||
await expect(fetchMemoryFile('2026-07-31.md')).resolves.toMatchObject({
|
||||
content: 'Memory',
|
||||
})
|
||||
await expect(fetchMemorySearch('mission control')).resolves.toHaveLength(1)
|
||||
await expect(fetchIncidents()).resolves.toEqual([])
|
||||
await expect(fetchIncident('gateway.md')).resolves.toMatchObject({ title: 'Gateway' })
|
||||
await expect(fetchSecurityStatus()).resolves.toMatchObject({ authMethod: 'JWT' })
|
||||
|
||||
const urls = fetchMock.mock.calls.map(([input]) => requestUrl(input))
|
||||
expect(urls.find(url => url.pathname === '/api/v1/memory/search')
|
||||
?.searchParams.get('q')).toBe('mission control')
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent file cache synchronization', () => {
|
||||
it('patches and invalidates only the written file and its collection', async () => {
|
||||
const client = new QueryClient()
|
||||
const collection: AgentFileCollectionDto = {
|
||||
agentId: 'iris',
|
||||
files: [{
|
||||
name: file.name,
|
||||
missing: true,
|
||||
size: null,
|
||||
updatedAt: null,
|
||||
contentHash: null,
|
||||
}],
|
||||
checkedAt: '2026-07-31T09:00:00.000Z',
|
||||
}
|
||||
const previousFile: AgentFileDto = {
|
||||
...file,
|
||||
missing: true,
|
||||
size: null,
|
||||
updatedAt: null,
|
||||
content: null,
|
||||
contentHash: 'missing',
|
||||
checkedAt: collection.checkedAt,
|
||||
}
|
||||
const writeResult: AgentFileWriteDto = {
|
||||
ok: true,
|
||||
state: 'saved',
|
||||
message: 'Saved and verified.',
|
||||
file,
|
||||
verified: true,
|
||||
idempotencyKey: 'idem-1',
|
||||
correlationId: 'corr-1',
|
||||
completedAt: file.checkedAt,
|
||||
}
|
||||
client.setQueryData(queryKeys.agentFiles('iris'), collection)
|
||||
client.setQueryData(queryKeys.agentFile('iris', 'SOUL.md'), previousFile)
|
||||
client.setQueryData(queryKeys.agentFile('iris', 'TOOLS.md'), { marker: 'other' })
|
||||
|
||||
await applyAgentFileWrite(client, 'iris', writeResult)
|
||||
|
||||
expect(client.getQueryData(queryKeys.agentFile('iris', 'SOUL.md'))).toEqual(file)
|
||||
expect(client.getQueryData<AgentFileCollectionDto>(queryKeys.agentFiles('iris')))
|
||||
.toMatchObject({
|
||||
checkedAt: file.checkedAt,
|
||||
files: [{ name: 'SOUL.md', missing: false, contentHash: 'hash-1' }],
|
||||
})
|
||||
expect(client.getQueryState(queryKeys.agentFiles('iris'))?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(queryKeys.agentFile('iris', 'SOUL.md'))?.isInvalidated)
|
||||
.toBe(true)
|
||||
expect(client.getQueryState(queryKeys.agentFile('iris', 'TOOLS.md'))?.isInvalidated)
|
||||
.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { AuthenticatedSseHub, type SseConnectionState } from '../src/services/sseHub'
|
||||
import { useAuthStore } from '../src/stores/auth'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.initialized = true
|
||||
auth.applySession({
|
||||
accessToken: 'test-access-token',
|
||||
expiresAt: '2026-07-31T12:00:00.000Z',
|
||||
user: {
|
||||
id: 'bao',
|
||||
email: 'bao@nexus.local',
|
||||
displayName: 'Bao',
|
||||
role: 'owner',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('authenticated SSE hub', () => {
|
||||
it('backs off with jitter after a normal EOF instead of reconnecting immediately', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const fetchMock = vi.fn(async () => new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const states: SseConnectionState[] = []
|
||||
const hub = new AuthenticatedSseHub()
|
||||
const unsubscribe = hub.subscribe('/api/v1/events', () => undefined, {
|
||||
onStateChange: state => states.push(state),
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(states).toContain('open')
|
||||
expect(states.at(-1)).toBe('reconnecting')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
unsubscribe()
|
||||
hub.closeAll()
|
||||
})
|
||||
|
||||
it('reconnects a stalled stream when the heartbeat budget expires', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.5)
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
controller.error(init.signal?.reason ?? new Error('aborted'))
|
||||
}, { once: true })
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
})
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const states: SseConnectionState[] = []
|
||||
const hub = new AuthenticatedSseHub()
|
||||
const unsubscribe = hub.subscribe('/api/v1/events', () => undefined, {
|
||||
onStateChange: state => states.push(state),
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(45_000)
|
||||
expect(states).toContain('error')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
unsubscribe()
|
||||
hub.closeAll()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTaskAgentOptions,
|
||||
taskAgentLabel,
|
||||
} from '../src/utils/taskAgentOptions'
|
||||
|
||||
describe('live task assignee options', () => {
|
||||
it('combines only local assignment concepts with the live OpenClaw inventory', () => {
|
||||
const options = buildTaskAgentOptions([
|
||||
{ id: 'iris', name: 'Iris' },
|
||||
{ id: 'new-specialist', name: 'New Specialist' },
|
||||
])
|
||||
|
||||
expect(options).toEqual([
|
||||
{ id: '', label: 'Nicht zugewiesen' },
|
||||
{ id: 'bao', label: 'Bao' },
|
||||
{ id: 'iris', label: 'Iris' },
|
||||
{ id: 'new-specialist', label: 'New Specialist' },
|
||||
])
|
||||
expect(options.some(option => option.id === 'programmer')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves a persisted unknown assignee while OpenClaw is disconnected', () => {
|
||||
const options = buildTaskAgentOptions([], ['retired-agent'])
|
||||
|
||||
expect(options).toContainEqual({
|
||||
id: 'retired-agent',
|
||||
label: 'retired-agent',
|
||||
})
|
||||
expect(taskAgentLabel('retired-agent', options)).toBe('retired-agent')
|
||||
})
|
||||
|
||||
it('prefers the live display name for an already persisted agent id', () => {
|
||||
const options = buildTaskAgentOptions(
|
||||
[{ id: 'product-owner', name: 'Product Owner' }],
|
||||
['product-owner'],
|
||||
)
|
||||
|
||||
expect(taskAgentLabel('product-owner', options)).toBe('Product Owner')
|
||||
expect(options.filter(option => option.id === 'product-owner')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user