Files
ycode/scripts/subagent-app-regression.cjs
T

328 lines
28 KiB
JavaScript

// Actual Electron preload/IPC + installed OMP and HTTP workers, using loopback model responses only.
const fs = require('node:fs/promises'), path = require('node:path')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-app-'))
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
await fs.writeFile(path.join(directory, 'workspace', 'fixture.txt'), 'PROJECT_FIXTURE_CONTENT')
require('esbuild').buildSync({ stdin: { resolveDir: process.cwd(), contents: [
"export * from './src/main/subagents/scheduler';", "export * from './src/main/subagents/worker';",
"export * from './src/main/agent/loop';", "export * from './src/main/gateway';",
"export * from './src/shared/message-parts';"
].join('\n') }, bundle: true, platform: 'node', format: 'cjs', external: ['electron'], outfile: path.join(directory, 'host.cjs'), logLevel: 'warning' })
const env = { ...process.env, YCODE_AGENTS_TEST_DIRECTORY: directory, YCODE_USER_DATA_DIR: path.join(directory, 'data'),
YCODE_OMP_HOME: path.join(directory, 'home'), YCODE_DISABLE_SYSTEM_INTEGRATION: '1', YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'), ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data; process.stdout.write(data) })
child.stderr.on('data', data => { output += data })
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer); await fs.writeFile(path.join(directory, 'process.log'), output)
const report = JSON.parse(await fs.readFile(path.join(directory, 'report.json'), 'utf8').catch(() => '{"result":"MISSING"}'))
console.log(JSON.stringify({ directory, result: report.result, exitCode: code }))
if (code || report.result !== 'PASS') { console.error(output.slice(-8000)); process.exitCode = 1 }
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron'), assert = require('node:assert/strict'), http = require('node:http')
const directory = process.env.YCODE_AGENTS_TEST_DIRECTORY, workspaceRoot = path.join(directory, 'workspace')
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
app.on('window-all-closed', () => {})
BrowserWindow.prototype.show = function () {}
const checks = [], requests = [], models = new Map(), rendererErrors = []
let server, main, phase = 'boot', apiBase, host, direct
const pause = ms => new Promise(done => setTimeout(done, ms))
function check(label, condition = true) { assert.ok(condition, label); checks.push(label); console.log('AGENT_CHECK ' + label) }
function sse(res, model, call, final) {
res.writeHead(200, { 'content-type': 'text/event-stream' })
const delta = call ? { role: 'assistant', tool_calls: [{ index: 0, id: model + '-call-' + Math.random(), type: 'function', function: { name: call.name, arguments: JSON.stringify(call.args) } }] }
: { role: 'assistant', content: final }
res.end([{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta, finish_reason: null }] },
{ id: 'controlled', object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: call ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 } }]
.map(row => 'data: ' + JSON.stringify(row) + '\n\n').join('') + 'data: [DONE]\n\n')
}
const read = (source, window = main) => window.webContents.executeJavaScript(source)
const invoke = (namespace, method, ...args) => read('window.ycode[' + JSON.stringify(namespace) + '][' + JSON.stringify(method) + '](...' + JSON.stringify(args) + ')')
async function until(operation, label, attempts = 600) {
for (let index = 0; index < attempts; index++) { const value = await operation(); if (value) return value; await pause(30) }
throw Error('Timeout: ' + label)
}
const profile = model => ({ id: model, name: model, model, providerId: 'custom', protocol: 'openai', baseUrl: apiBase + '/v1', apiKey: 'loopback-only',
endpoint: { api: 'openai-completions', baseUrl: apiBase + '/v1', checkedAt: Date.now() }, supportsToolCall: true,
supportsImages: false, supportsReasoning: false, contextWindowTokens: 32000, maxOutputTokens: 1024, tokenBudgetVersion: 2 })
async function start(model, permissionMode = 'full-access') {
models.set(model, { stage: 0, childActive: 0, peak: 0, held: false, rootAfterOff: false })
await invoke('models', 'save', profile(model))
const workspace = await invoke('workspace', 'attach', workspaceRoot)
const session = await invoke('session', 'create', model, { modelId: model, workspaceId: workspace.attachedWorkspaceId })
const run = await invoke('chat', 'send', { sessionId: session.id, text: 'ROOT_ONLY_SECRET_MARKER ' + model, permissionMode })
return { session, run }
}
async function finish(session) {
return until(async () => {
const assistant = (await invoke('session', 'messages', session.id)).filter(message => message.role === 'assistant').at(-1)
return assistant && assistant.status !== 'streaming' && !await invoke('chat', 'activeRun', session.id) ? assistant : null
}, 'terminal assistant ' + session.id, 1600)
}
async function taskWindow(session) {
await invoke('session', 'openWindow', session.id)
const window = await until(() => BrowserWindow.getAllWindows().find(item => item !== main && item.getTitle() === 'YCode 任务'), 'task window')
window.setContentSize(1440, 900)
await until(() => read('!!document.querySelector(".composer__area .conversation-team-collaboration__trigger")', window), 'input-area members')
window.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
return window
}
async function handler(req, res) {
try {
let raw = ''; for await (const chunk of req) raw += chunk
const body = JSON.parse(raw || '{}'), model = body.model, state = models.get(model)
assert.ok(state, 'known controlled model')
if (req.url !== '/v1/chat/completions') { res.writeHead(404); res.end('{}'); return }
const users = body.messages.filter(message => message.role === 'user')
const worker = users.some(message => JSON.stringify(message.content).includes('你是 YCode 主智能体委派的执行成员'))
const serialized = JSON.stringify(body.messages), toolResults = body.messages.filter(message => message.role === 'tool')
requests.push({ model, worker, body })
if (worker) {
assert.ok(!serialized.includes('ROOT_ONLY_SECRET_MARKER'), 'child does not inherit private root history')
const names = body.tools.map(tool => tool.function.name)
assert.ok(!names.includes('spawn_agent') && !names.includes('execute_command') && !names.includes('browser'), 'worker schema is bounded')
const isA = serialized.includes('CHILD_A'), write = isA || model.includes('approval')
const marker = isA ? 'CHILD_A_RESULT' : 'CHILD_B_RESULT'
if (model.includes('held')) {
state.held = true
return // Held response is aborted by the application's child cancellation.
}
if (toolResults.length === 0) {
state.childActive++; state.peak = Math.max(state.peak, state.childActive)
await pause(250)
state.childActive--
if (write) {
assert.ok(names.includes('write_file'), 'declared writer receives write tools')
return sse(res, model, { name: 'write_file', args: { path: model.includes('approval') ? 'approval.txt' : 'agent-a.txt', content: 'WRITTEN_BY_CHILD_A' } })
}
assert.ok(!names.includes('write_file'), 'read-only child never receives write tools')
return sse(res, model, { name: 'read_file', args: { path: 'fixture.txt' } })
}
assert.ok(write ? model.includes('approval') ? serialized.includes('拒绝') || serialized.includes('未批准') : serialized.includes('已写入')
: serialized.includes('PROJECT_FIXTURE_CONTENT'), 'actual child tool result returns to model')
return sse(res, model, undefined, marker)
}
if (model.includes('disabled')) {
assert.ok(!body.tools.some(tool => tool.function.name === 'spawn_agent'), 'disabled switch hides scheduling')
if (state.stage++ === 0) return sse(res, model, { name: 'spawn_agent', args: { name: 'forbidden', task: 'forbidden' } })
assert.ok(serialized.includes('禁用') || serialized.includes('Tool spawn_agent not found'), 'stale spawn name is rejected by host or native tool registry')
return sse(res, model, undefined, 'DISABLED_PARENT_COMPLETE')
}
const held = model.includes('held'), approval = model.includes('approval')
if (model.includes('followup')) {
const next = state.stage++
if (next === 0) return sse(res, model, { name: 'spawn_agent', args: { name: '持续核对', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
if (next === 1 || next === 3) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
if (next === 2) {
const content = toolResults[0].content
const result = JSON.parse(typeof content === 'string' ? content : content.map(part => part.text || '').join(''))
return sse(res, model, { name: 'send_agent_message', args: { id: result.id, message: 'FOLLOWUP_CONTEXT 再核对一次,沿用先前结果' } })
}
assert.ok(serialized.includes('CHILD_B_RESULT'), 'follow-up result reaches parent')
return sse(res, model, undefined, 'FOLLOWUP_PARENT_COMPLETE')
}
if (state.stage++ === 0) {
assert.ok(body.tools.some(tool => tool.function.name === 'spawn_agent'), 'enabled root sees scheduling')
return sse(res, model, { name: 'spawn_agent', args: { name: '文件分析 A', task: 'CHILD_A 独立完成任务并报告验收结果',
access: held ? 'read-only' : 'write', ...(held ? {} : { files: [approval ? 'approval.txt' : 'agent-a.txt'] }) } })
}
if (held || approval) {
if (state.stage === 2) return sse(res, model, { name: 'wait_agent', args: { timeoutMs: 30000 } })
if (model.includes('toggle-held')) {
state.rootAfterOff = !body.tools.some(tool => tool.function.name === 'spawn_agent')
assert.ok(state.rootAfterOff, 'next native model request loses spawn after disabling')
}
return sse(res, model, undefined, 'CONTROL_PARENT_COMPLETE')
}
if (state.stage === 2) return sse(res, model, { name: 'spawn_agent', args: { name: '资料核对 B', task: 'CHILD_B 读取 fixture.txt 并报告结果' } })
if (state.stage === 3) return sse(res, model, undefined, 'EARLY_CANDIDATE')
assert.ok(serialized.includes('本地调度器已等待子任务') && serialized.includes('CHILD_A_RESULT') && serialized.includes('CHILD_B_RESULT'), 'host requires synthesis with actual child results')
return sse(res, model, undefined, 'MAIN_INTEGRATED_RESULT')
} catch (error) {
res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: String(error) } }))
}
}
app.whenReady().then(async () => {
host = require(path.join(directory, 'host.cjs'))
server = http.createServer(handler)
await new Promise(done => server.listen(0, '127.0.0.1', done))
apiBase = 'http://127.0.0.1:' + server.address().port
phase = 'http-root-and-workers'
const model = 'agent-http', config = { api: 'openai-completions', baseUrl: apiBase + '/v1', model, apiKey: 'loopback-only', temperature: 0, maxToolRounds: 10 }
models.set(model, { stage: 0, childActive: 0, peak: 0 })
const controller = new AbortController(), events = []
direct = new host.SubagentScheduler({ runId: 'http-parent', workspaceRoot, modelName: model, enabled: true, signal: controller.signal,
emit: event => events.push(event), capabilities: { specs: [], execute: async () => undefined },
worker: (task, tools) => host.createSubagentWorker({ task, tools, model: { profile: profile(model), config }, workspaceRoot,
parentRunId: 'http-parent', permissionMode: 'full-access', native: false, emitParent: event => events.push(event) })
})
const result = await host.runTurn({ runId: 'http-parent', sessionId: 'http-session', workspaceRoot, history: [], userText: 'ROOT_ONLY_SECRET_MARKER',
gateway: new host.Gateway(config), model, maxToolRounds: 10, signal: controller.signal, permissionMode: 'full-access',
capabilityTools: direct.tools, beforeFinish: () => direct.beforeFinish(), emit: event => events.push(event) })
check('HTTP: separate model requests execute and integrate both child results', result.content.includes('MAIN_INTEGRATED_RESULT'))
check('HTTP: actual child provider requests overlap', models.get(model).peak >= 2)
check('HTTP: real delegated write reaches the declared file', await fs.readFile(path.join(workspaceRoot, 'agent-a.txt'), 'utf8') === 'WRITTEN_BY_CHILD_A')
await direct.close(); direct = undefined
phase = 'native-cancelled-write-drain'
const drainModel = 'agent-drain', drainEvents = [], drainController = new AbortController()
models.set(drainModel, { stage: 0, childActive: 0, peak: 0 })
let finishWrite, writeStarted = false, workerSettled = false
const drainWorker = await host.createSubagentWorker({
task: { id: 'drain-child', name: 'write drain', task: 'CHILD_A', access: 'write', files: ['agent-a.txt'], modelName: drainModel, status: 'running', createdAt: Date.now(), events: [], tools: [] },
tools: { specs: [], filterSpecs: specs => specs.filter(spec => ['read_file', 'list_dir', 'write_file', 'edit_file'].includes(spec.function.name)),
async execute(name, args) {
if (name !== 'write_file') return undefined
writeStarted = true
return new Promise(resolve => { finishWrite = async () => {
const target = path.join(workspaceRoot, args.path), oldContent = await fs.readFile(target, 'utf8')
await fs.writeFile(target, 'DRAINED_WRITE')
resolve({ ok: true, content: '已写入', data: { path: target, oldContent, newContent: 'DRAINED_WRITE' } })
} })
}
},
model: { profile: profile(drainModel), config: { ...config, model: drainModel } }, workspaceRoot,
parentRunId: 'drain-parent', native: true, permissionMode: 'full-access', emitParent() {}
})
const draining = drainWorker.run('CHILD_A 写入后报告', drainController.signal, event => drainEvents.push(event))
draining.then(() => { workerSettled = true }, () => { workerSettled = true })
await until(() => writeStarted, 'write already in progress')
drainController.abort(); await pause(80)
check('native child cancellation does not release an unfinished local write', !workerSettled)
await finishWrite(); await draining
check('cancelled native child retains actual file before/after data for rewind', drainEvents.some(event => event.type === 'tool_result' && event.call.result?.newContent === 'DRAINED_WRITE'))
await drainWorker.dispose()
phase = 'production-boot'
delete process.env.YCODE_DISABLE_GPU; delete process.env.YCODE_DISABLE_GPU_SANDBOX
await import(require('node:url').pathToFileURL(path.resolve('out/main/index.mjs')).href)
main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main app')
await until(() => read('!!window.ycode'), 'preload')
await read('window.__agentEvents=[];window.ycode.chat.onEvent(event=>window.__agentEvents.push({runId:event.runId,type:event.type,sequence:event.sequence}));true')
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push(event.message) })
phase = 'production-native'
await invoke('agents', 'update', { teamManagement: true })
const normal = await start('agent-native')
const assistant = await finish(normal.session)
check('APP: installed OMP completes the root and both child model conversations', assistant.status === 'done' && assistant.content.includes('MAIN_INTEGRATED_RESULT'))
const emitted = await read('window.__agentEvents.filter(event=>event.runId===' + JSON.stringify(normal.run.runId) + ')')
check('APP: terminal event follows every child snapshot with a strictly newer sequence', emitted.at(-1).type === 'done'
&& emitted.every((event, index) => index === 0 || event.sequence > emitted[index - 1].sequence))
const agents = assistant.parts.filter(part => part.type === 'subagent').map(part => part.agent)
check('APP: two completed children retain separate transcripts and tool results', agents.length === 2 && agents.every(agent => agent.status === 'completed' && agent.tools.some(tool => tool.status === 'ok')))
check('APP: root and children keep the same selected custom model', agents.every(agent => agent.modelName === 'agent-native'))
const nativeCount = requests.filter(request => request.model === 'agent-native').length
check('APP: reported usage sums all root and child requests without replacing context usage', assistant.runUsage?.total.totalTokens === nativeCount * 110
&& assistant.runUsage.main.totalTokens > 0 && assistant.runUsage.subagents.totalTokens > 0 && assistant.contextUsage?.usage.totalTokens === 110)
check('APP: isolated observer transport still reports prepared request categories', assistant.contextBreakdown?.counts.system > 0 && assistant.contextBreakdown?.counts.tools > 0)
const disk = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
check('APP: agent details and aggregated usage survive disk persistence', disk.sessions.find(session => session.id === normal.session.id).messages.some(message => message.id === assistant.id && message.runUsage?.total.totalTokens === nativeCount * 110 && message.parts.filter(part => part.type === 'subagent').length === 2))
const window = await taskWindow(normal.session)
check('UI: completed team has one input-area entry and no reply duplicates', await read('document.querySelectorAll(".composer__area .conversation-team-collaboration__trigger").length===1&&!document.querySelector(".chat-scroll .conversation-team-collaboration")&&document.querySelector(".conversation-team-collaboration__trigger").textContent.includes("2 位团队成员")&&!document.querySelector(".yc-subagent")&&!!document.querySelector(".yc-run-usage")', window))
await read('document.querySelector(".conversation-team-collaboration__trigger").click();document.querySelector(".yc-run-usage>summary").click();true', window)
await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===2', window), 'two members')
await read('document.querySelector(".conversation-team-collaboration__member").click();true', window)
await until(() => read('!!document.querySelector(".conversation-member-detail .msg--assistant")', window), 'member conversation detail')
check('UI: selected member opens its child conversation in the existing right detail surface', await read('!!document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId===document.querySelector(".conversation-member-detail")?.dataset.subagentId', window))
await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true', window)
check('UI: task assignment is separately expandable and child tools use the normal renderer', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("CHILD_A")&&!!document.querySelector(".conversation-member-detail .cr-captured-tool")&&!document.querySelector(".yc-subagent__event")', window))
await pause(350)
await fs.writeFile(path.join(directory, 'agents-light.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
await read('localStorage.setItem("ycode:theme","dark");window.dispatchEvent(new StorageEvent("storage",{key:"ycode:theme",newValue:"dark"}));true', window)
await until(() => read('document.documentElement.dataset.theme==="dark"&&document.querySelector(".chat-pane")?.dataset.crTheme==="dark"', window), 'dark conversation theme')
await pause(400)
await fs.writeFile(path.join(directory, 'agents-dark.png'), (await window.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
window.destroy()
phase = 'disabled-host-gate'
await invoke('agents', 'update', { teamManagement: false })
const disabled = await start('agent-disabled')
const disabledMessage = await finish(disabled.session)
check('APP: disabled switch hides tools and rejects an explicit stale tool name', disabledMessage.status === 'done'
&& disabledMessage.content.includes('DISABLED_PARENT_COMPLETE') && !disabledMessage.parts.some(part => part.type === 'subagent'))
phase = 'toggle-during-run'
await invoke('agents', 'update', { teamManagement: true })
const toggled = await start('agent-toggle-held')
await until(() => models.get('agent-toggle-held').held, 'held child request')
await invoke('agents', 'update', { teamManagement: false })
const toggledMessage = await finish(toggled.session)
check('APP: switch-off cancels the running native child while the parent continues', toggledMessage.status === 'done'
&& toggledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled') && models.get('agent-toggle-held').rootAfterOff)
phase = 'parent-cancel'
await invoke('agents', 'update', { teamManagement: true })
const cancelled = await start('agent-cancel-held')
await until(() => models.get('agent-cancel-held').held, 'cancellable child')
await invoke('chat', 'cancel', cancelled.run.runId)
const cancelledMessage = await finish(cancelled.session)
check('APP: cancelling the parent also cancels its native children', cancelledMessage.status === 'cancelled'
&& cancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
phase = 'individual-cancel'
const single = await start('agent-single-held')
await until(() => models.get('agent-single-held').held, 'individual child')
const snapshot = await invoke('chat', 'activeRun', single.session.id)
const childId = snapshot.messages.at(-1).parts.find(part => part.type === 'subagent').agent.id
check('APP: child cancel IPC rejects an unknown identifier', !(await invoke('chat', 'cancelAgent', single.run.runId, 'unknown')).cancelled)
const liveWindow = await taskWindow(single.session)
await read('document.querySelector(".conversation-team-collaboration__trigger").click();true', liveWindow)
await until(() => read('!!document.querySelector(".conversation-team-collaboration__member[data-status=running]")', liveWindow), 'running member')
await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true', liveWindow)
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=running] .yc-member-stop")', liveWindow), 'live member detail')
check('UI: running child is reachable above the input before the parent finishes', await read('!!document.querySelector(".composer__area .conversation-team-collaboration__member[data-status=running]")', liveWindow))
await read('document.querySelector(".yc-member-stop").click();true', liveWindow)
const singleMessage = await finish(single.session)
check('APP: individual cancellation leaves the parent able to finish', singleMessage.status === 'done' && singleMessage.content.includes('CONTROL_PARENT_COMPLETE'))
await until(() => read('!!document.querySelector(".conversation-member-detail[data-status=cancelled]")&&!document.querySelector(".yc-member-stop")&&document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow), 'live terminal UI')
check('UI: stopping a child updates the open member detail and completes the main reply')
await read('document.querySelector(".conversation-member-detail__close").click();true', liveWindow)
await until(() => read('!document.querySelector(".conversation-member-detail")', liveWindow), 'close member detail')
check('UI: closing member detail leaves the completed parent conversation intact', await read('document.body.textContent.includes("CONTROL_PARENT_COMPLETE")', liveWindow))
liveWindow.destroy()
phase = 'default-permission-approval'
const approval = await start('agent-approval', 'default-sandbox')
const pending = await until(async () => {
const message = (await invoke('session', 'messages', approval.session.id)).at(-1)
return message.interactions?.find(item => item.kind === 'approval' && item.status === 'pending')
}, 'child write approval')
check('APP: child write approval appears under the parent run with the child name', pending.runId === approval.run.runId && pending.title.includes('文件分析 A'))
await invoke('chat', 'respond', approval.run.runId, pending.id, { kind: 'approval', optionId: 'reject-once' })
const approvedMessage = await finish(approval.session)
check('APP: refusing child approval prevents the write and returns control', approvedMessage.status === 'done'
&& !await fs.stat(path.join(workspaceRoot, 'approval.txt')).then(() => true, () => false))
phase = 'cancel-pending-approval'
const approvalCancel = await start('agent-approval-cancel', 'default-sandbox')
await until(async () => (await invoke('session', 'messages', approvalCancel.session.id)).at(-1).interactions?.some(item => item.status === 'pending'), 'approval to cancel')
await invoke('agents', 'update', { teamManagement: false })
const approvalCancelledMessage = await finish(approvalCancel.session)
check('APP: disabling during child approval cancels the prompt without hanging the parent', approvalCancelledMessage.status === 'done'
&& approvalCancelledMessage.interactions.every(item => item.status !== 'pending')
&& approvalCancelledMessage.parts.some(part => part.type === 'subagent' && part.agent.status === 'cancelled'))
phase = 'native-followup'
await invoke('agents', 'update', { teamManagement: true })
const followup = await start('agent-followup')
const followupMessage = await finish(followup.session)
check('APP: a completed native child accepts follow-up instructions in its own context', followupMessage.status === 'done'
&& followupMessage.content.includes('FOLLOWUP_PARENT_COMPLETE')
&& requests.some(request => request.model === 'agent-followup' && request.worker && JSON.stringify(request.body.messages).includes('FOLLOWUP_CONTEXT')
&& JSON.stringify(request.body.messages).includes('CHILD_B_RESULT')))
phase = 'delete-native-history'
const transcriptDirectory = path.join(directory, 'data', 'subagent-sessions', 'session-' + Buffer.from(normal.session.id).toString('base64url'))
check('APP: native child transcripts belong to the parent session directory', await fs.stat(transcriptDirectory).then(info => info.isDirectory(), () => false))
await invoke('session', 'remove', normal.session.id)
check('APP: deleting the parent removes its native child transcripts', !await fs.stat(transcriptDirectory).then(() => true, () => false))
check('UI: no renderer errors', rendererErrors.length === 0)
phase = 'done'
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, requestCounts: [...models.keys()].map(model => ({ model, requests: requests.filter(row => row.model === model).length })), rendererErrors }, null, 2))
console.log('SUBAGENT_APP_PASS ' + directory)
server.closeAllConnections(); server.close(); app.quit()
}).catch(async error => {
console.error(error)
await direct?.close().catch(() => undefined)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'FAIL', phase, error: String(error.stack || error), checks, requests }, null, 2))
server?.closeAllConnections(); server?.close(); app.exit(1)
})
}