Files
ycode/scripts/subagent-display-regression.ts
T

57 lines
5.6 KiB
TypeScript

import assert from 'node:assert/strict'
import type { ChatMessage } from '../src/shared/types'
import type { SubagentSnapshot } from '../src/shared/subagents'
import { collectTurnMembers, projectMemberMessage, selectComposerTeam, sortMembers } from '../src/renderer/src/workbuddy/subagents/member-view'
import { useWorkbenchStore } from '../src/renderer/src/stores/workbench'
let checks = 0
const check = (name: string, condition: unknown): void => { assert.ok(condition, name); checks++; console.log('PASS ' + name) }
const base: SubagentSnapshot = { id: 'a', name: '分析', task: '检查消息', status: 'running', access: 'read-only', files: [], modelName: 'local', createdAt: 1, events: [], tools: [] }
const parent: ChatMessage = { id: 'parent-a', role: 'assistant', content: 'parent', createdAt: 1, parts: [
{ type: 'subagent', agent: { ...base, status: 'completed' } },
{ type: 'subagent', agent: { ...base, id: 'b', name: '验收', status: 'queued' } },
{ type: 'subagent', agent: base }
] }
const members = collectTurnMembers(parent)
check('same child snapshot upserts without duplicating membership', members.length === 2 && members[0].agent === base)
check('membership remains scoped to its own assistant turn', collectTurnMembers({ ...parent, id: 'next-turn', parts: [] }).length === 0)
const next: ChatMessage = { ...parent, id: 'next-turn', parts: [], status: 'streaming' }
check('input activity belongs to the active assistant rather than an older team', selectComposerTeam([parent, next], next.id) === undefined)
check('an idle latest reply without children does not resurrect an earlier team', selectComposerTeam([parent, { ...next, status: 'done' }]) === undefined)
check('hydration with a missing active assistant never falls back to stale members', selectComposerTeam([parent], 'not-loaded') === undefined)
check('latest completed team remains reachable from the input area', selectComposerTeam([{ ...parent, status: 'done' }])?.assistantId === parent.id && selectComposerTeam([parent])?.members.length === 2)
const completed = [{ ...members[0], agent: { ...base, status: 'completed' as const } }, members[1]]
const sorted = sortMembers(completed)
check('running or queued members sort first without changing task ordinals', sorted[0].agent.id === 'b' && sorted[0].ordinal === 2 && completed[0].agent.id === 'a')
const tool = { id: '1:tool', name: 'read_file', args: { path: 'src/a.ts' }, argsRaw: '{"path":"src/a.ts"}', status: 'ok' as const, output: 'REAL_FILE_OUTPUT' }
const child = { ...base, events: [
{ id: 'thought', kind: 'reasoning' as const, text: '先核对入口' },
{ id: tool.id, kind: 'tool' as const, name: tool.name, status: 'ok' as const, text: 'DISPLAY_SUMMARY' },
{ id: 'reply', kind: 'text' as const, text: 'partial reply' }
], tools: [tool], result: 'complete reply', status: 'completed' as const, finishedAt: 2000 }
const before = JSON.stringify(child)
const projected = projectMemberMessage(child)
check('detail preserves thinking, tool and reply order', projected.parts?.map(part => part.type).join(',') === 'reasoning,tool,text')
check('detail uses actual child tool arguments and output', projected.tools?.[0] === tool && projected.tools[0].output === 'REAL_FILE_OUTPUT')
check('complete child result replaces its truncated streaming tail exactly once', projected.content === 'complete reply' && projected.parts?.filter(part => part.type === 'text').length === 1)
check('projection never mutates the persisted child snapshot', JSON.stringify(child) === before)
check('member reasoning is finished only for a terminal child', projected.parts?.[0].type === 'reasoning' && projected.parts[0].finished === true && projectMemberMessage({ ...child, status: 'running' }).status === 'streaming')
check('legacy snapshots retain real tool records when events are absent', projectMemberMessage({ ...base, tools: [tool] }).tools?.[0] === tool)
check('cancelled children remain cancelled in the detail view', projectMemberMessage({ ...base, status: 'cancelled' }).status === 'cancelled')
const api = useWorkbenchStore.getState()
api.openView('session-a', 'fileTree')
const tab = useWorkbenchStore.getState().sessions['session-a'].activeTabId!
api.openMember('session-a', 'parent-a', 'a')
check('opening a member preserves existing workbench tabs and opens its surface', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-a'].open && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 1)
api.openMember('session-b', 'parent-b', 'b')
check('member selection is isolated between conversations', useWorkbenchStore.getState().sessions['session-a'].member?.agentId === 'a' && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
api.select('session-a', tab)
check('selecting a workbench tab exits the member surface without removing child history', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].activeTabId === tab && useWorkbenchStore.getState().sessions['session-b'].member?.agentId === 'b')
api.openMember('session-a', 'parent-a', 'a')
await api.openResource('session-a', 'session-a', 'artifact:demo', undefined, { kind: 'artifact', path: 'artifact:demo', name: '产物', content: 'content' })
check('opening an artifact from child tools replaces the member surface', !useWorkbenchStore.getState().sessions['session-a'].member && useWorkbenchStore.getState().sessions['session-a'].tabs.length === 2)
api.closeMember('session-b')
check('closing a member preserves its conversation workbench', !useWorkbenchStore.getState().sessions['session-b'].member)
console.log('SUBAGENT_DISPLAY_PASS ' + JSON.stringify({ checks }))