1009 lines
42 KiB
JavaScript
1009 lines
42 KiB
JavaScript
// A01 acceptance: a real task window must own the complete renderer send/stream
|
|
// path, including completion, user cancellation, persistence after the sending
|
|
// window closes, reopening an active run through the main task menu, and
|
|
// dual-window rendering/stop synchronization on the same selected task.
|
|
//
|
|
// This harness runs the production YCode main/preload/renderer bundle against
|
|
// a real OMP executable and a controlled OpenAI-compatible SSE server bound to
|
|
// 127.0.0.1. It never calls window.ycode.chat.send. Every turn starts through
|
|
// the visible task-window composer.
|
|
const fs = require('node:fs/promises')
|
|
const path = require('node:path')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const TASK_ID = 'task-window-stream-session'
|
|
const MODEL_ID = 'local-task-window-stream'
|
|
const DONE_PROMPT = 'TASK WINDOW DONE REQUEST'
|
|
const DONE_PARTIAL = 'TASKWINDOWDONEPARTIAL'
|
|
const DONE_FINAL = 'TASKWINDOWDONEFINAL'
|
|
const CANCEL_PROMPT = 'TASK WINDOW CANCEL REQUEST'
|
|
const CANCEL_PARTIAL = 'TASKWINDOWCANCELPARTIAL'
|
|
const CLOSE_PROMPT = 'TASK WINDOW CLOSE REQUEST'
|
|
const CLOSE_PARTIAL = 'TASKWINDOWCLOSEPARTIAL'
|
|
const CLOSE_FINAL = 'TASKWINDOWCLOSEFINAL'
|
|
const REOPEN_PROMPT = 'TASK WINDOW REOPEN REQUEST'
|
|
const REOPEN_PARTIAL = 'TASKWINDOWREOPENPARTIAL'
|
|
const REOPEN_FINAL = 'TASKWINDOWREOPENFINAL'
|
|
const DUAL_PROMPT = 'TASK WINDOW DUAL REQUEST'
|
|
const DUAL_PARTIAL = 'TASKWINDOWDUALPARTIAL'
|
|
|
|
if (!process.versions.electron) {
|
|
;(async () => {
|
|
const temporaryRoot = path.resolve('.tmp')
|
|
await fs.mkdir(temporaryRoot, { recursive: true })
|
|
const directory = await fs.mkdtemp(path.join(temporaryRoot, 'task-window-stream-'))
|
|
const data = path.join(directory, 'data')
|
|
const home = path.join(directory, 'home')
|
|
const workspace = path.join(directory, 'workspace')
|
|
await Promise.all([
|
|
fs.mkdir(data, { recursive: true }),
|
|
fs.mkdir(home, { recursive: true }),
|
|
fs.mkdir(workspace, { recursive: true })
|
|
])
|
|
|
|
const ompPath = process.env.YCODE_OMP_PATH ||
|
|
path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe')
|
|
await fs.access(ompPath, require('node:fs').constants.X_OK)
|
|
|
|
const now = Date.now()
|
|
const session = {
|
|
id: TASK_ID,
|
|
title: 'Task Window Stream Acceptance',
|
|
cwd: workspace,
|
|
modelId: MODEL_ID,
|
|
createdAt: now - 2,
|
|
updatedAt: now - 1,
|
|
messages: [
|
|
{
|
|
id: 'task-window-fixture-user',
|
|
role: 'user',
|
|
content: 'Fixture history',
|
|
requestId: 'task-window-fixture-run',
|
|
createdAt: now - 2,
|
|
status: 'done'
|
|
},
|
|
{
|
|
id: 'task-window-fixture-assistant',
|
|
role: 'assistant',
|
|
content: 'Fixture response',
|
|
requestId: 'task-window-fixture-run',
|
|
createdAt: now - 1,
|
|
status: 'done',
|
|
turnDurationMs: 1,
|
|
tools: [],
|
|
parts: []
|
|
}
|
|
]
|
|
}
|
|
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [session] }, null, 2))
|
|
|
|
const env = {
|
|
...process.env,
|
|
YCODE_QA_DIRECTORY: directory,
|
|
YCODE_USER_DATA_DIR: data,
|
|
YCODE_OMP_HOME: home,
|
|
YCODE_QA_REAL_OMP: ompPath,
|
|
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', (bytes) => {
|
|
output += bytes
|
|
if (bytes.toString().includes('TASK_WINDOW_STREAM_')) process.stdout.write(bytes)
|
|
})
|
|
child.stderr.on('data', (bytes) => { output += bytes })
|
|
const timer = setTimeout(() => child.kill(), 360000)
|
|
child.once('exit', async (code) => {
|
|
clearTimeout(timer)
|
|
await fs.writeFile(path.join(directory, 'process.log'), output)
|
|
console.log(JSON.stringify({ directory, exitCode: code }))
|
|
if (code) console.error(output.slice(-12000))
|
|
process.exitCode = code ?? 1
|
|
})
|
|
})().catch((error) => {
|
|
console.error(error)
|
|
process.exitCode = 1
|
|
})
|
|
} else {
|
|
const { app, BrowserWindow } = require('electron')
|
|
const { createServer } = require('node:http')
|
|
const { pathToFileURL } = require('node:url')
|
|
|
|
const directory = process.env.YCODE_QA_DIRECTORY
|
|
const relative = path.relative(path.resolve('.tmp'), directory || '')
|
|
assert.ok(
|
|
relative.startsWith('task-window-stream-') &&
|
|
!relative.startsWith('..') &&
|
|
!path.isAbsolute(relative),
|
|
'isolated QA directory must stay under .tmp/task-window-stream-*'
|
|
)
|
|
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
|
|
app.setPath('userData', path.join(directory, 'data'))
|
|
app.setPath('sessionData', path.join(directory, 'data'))
|
|
app.setPath('home', path.join(directory, 'home'))
|
|
|
|
const checks = []
|
|
const snapshots = {}
|
|
const rendererErrors = []
|
|
const requestLog = []
|
|
const pendingTimers = new Set()
|
|
let main
|
|
let taskWindow
|
|
let server
|
|
let phase = 'boot'
|
|
let requestCount = 0
|
|
let doneTerminalSentAt = 0
|
|
let cancelStreamClosed = false
|
|
let closeStreamStarted = false
|
|
let closeTerminalSentAt = 0
|
|
let taskWindowClosedAt = 0
|
|
let reopenStreamStarted = false
|
|
let reopenTerminalSentAt = 0
|
|
let reopenFinalRelease
|
|
const reopenFinalGate = new Promise((resolve) => { reopenFinalRelease = resolve })
|
|
let dualStreamClosed = false
|
|
|
|
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
async function read(win, source) {
|
|
assert.ok(win && !win.isDestroyed(), 'renderer window must be alive while reading')
|
|
let timer
|
|
try {
|
|
return await Promise.race([
|
|
win.webContents.executeJavaScript(source),
|
|
new Promise((_, reject) => {
|
|
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 160))), 20000)
|
|
})
|
|
])
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
async function wait(value, label, win, attempts = 750) {
|
|
for (let index = 0; index < attempts; index++) {
|
|
const result = await read(win, value)
|
|
if (result) return result
|
|
await pause(40)
|
|
}
|
|
throw Error('Timeout: ' + label)
|
|
}
|
|
|
|
async function waitValue(readValue, label, attempts = 1000) {
|
|
for (let index = 0; index < attempts; index++) {
|
|
const result = await readValue()
|
|
if (result) return result
|
|
await pause(40)
|
|
}
|
|
throw Error('Timeout: ' + label)
|
|
}
|
|
|
|
async function step(label) {
|
|
phase = label
|
|
await fs.writeFile(path.join(directory, 'phase.txt'), label)
|
|
}
|
|
|
|
function check(label, actual, expected) {
|
|
const pass = typeof expected === 'function'
|
|
? expected(actual)
|
|
: JSON.stringify(actual) === JSON.stringify(expected)
|
|
checks.push({
|
|
label,
|
|
pass,
|
|
actual,
|
|
expected: typeof expected === 'function' ? expected.toString() : expected
|
|
})
|
|
}
|
|
|
|
function captureRendererErrors(win) {
|
|
win.webContents.on('console-message', (eventOrLevel, maybeLevel, maybeMessage) => {
|
|
const level = typeof eventOrLevel === 'object' ? eventOrLevel.level : maybeLevel
|
|
const message = typeof eventOrLevel === 'object' ? eventOrLevel.message : maybeMessage
|
|
if (level === 'error' || Number(level) >= 3) rendererErrors.push({ windowId: win.id, message })
|
|
})
|
|
}
|
|
|
|
async function findTaskWindow() {
|
|
for (let index = 0; index < 400; index++) {
|
|
const candidate = BrowserWindow.getAllWindows().find((win) => {
|
|
if (win === main || win.isDestroyed()) return false
|
|
try {
|
|
return new URL(win.webContents.getURL()).searchParams.get('window') === 'task'
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
if (candidate) return candidate
|
|
await pause(40)
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function ensureTaskSelected(win, label) {
|
|
await wait(
|
|
`!!document.querySelector('[data-session-id="${TASK_ID}"]')`,
|
|
`${label} task row`,
|
|
win
|
|
)
|
|
const selected = await read(win, `document.querySelector('[data-session-id="${TASK_ID}"]')?.classList.contains('selected')`)
|
|
if (!selected) {
|
|
await read(win, `(() => {
|
|
const row = document.querySelector('[data-session-id="${TASK_ID}"]')
|
|
if (!row) return false
|
|
row.click()
|
|
return true
|
|
})()`)
|
|
}
|
|
await wait(
|
|
`document.querySelector('[data-session-id="${TASK_ID}"]')?.classList.contains('selected')`,
|
|
`${label} selected task`,
|
|
win
|
|
)
|
|
await wait('!!document.querySelector(".wb-composer__input")', `${label} task composer`, win)
|
|
}
|
|
|
|
async function openTaskWindowFromTaskMenu() {
|
|
await ensureTaskSelected(main, 'main window')
|
|
await wait(
|
|
`!!document.querySelector('[data-session-id="${TASK_ID}"] .agent-card-more-button')`,
|
|
'task row menu button',
|
|
main
|
|
)
|
|
await read(main, `(() => {
|
|
const row = document.querySelector('[data-session-id="${TASK_ID}"]')
|
|
const button = row?.querySelector('.agent-card-more-button')
|
|
if (!row || !button) return false
|
|
button.click()
|
|
return true
|
|
})()`)
|
|
await wait('!!document.querySelector("[data-task-menu-id=openWindow]")', 'open-window task menu item', main)
|
|
const menuLabel = await read(
|
|
main,
|
|
'document.querySelector("[data-task-menu-id=openWindow] .cb-context-menu-item-label")?.textContent'
|
|
)
|
|
await read(main, `(() => {
|
|
const item = document.querySelector('[data-task-menu-id="openWindow"]')
|
|
if (!item) return false
|
|
item.click()
|
|
return true
|
|
})()`)
|
|
const opened = await findTaskWindow()
|
|
assert.ok(opened, 'independent task window')
|
|
captureRendererErrors(opened)
|
|
opened.once('closed', () => { taskWindowClosedAt = Date.now() })
|
|
await wait('!!window.ycode && !!document.querySelector(".wb-composer__input")', 'task-window renderer', opened)
|
|
await wait(
|
|
`new URLSearchParams(location.search).get('taskId') === ${JSON.stringify(TASK_ID)}`,
|
|
'task-window query identity',
|
|
opened
|
|
)
|
|
await ensureTaskSelected(opened, 'task window')
|
|
return { window: opened, menuLabel }
|
|
}
|
|
|
|
async function installChatEventProbe(win) {
|
|
const installed = await read(win, `(() => {
|
|
window.__a01ChatEventProbe?.dispose?.()
|
|
const events = []
|
|
const stopStarted = window.ycode.chat.onStarted((run) => {
|
|
if (run.sessionId === ${JSON.stringify(TASK_ID)}) events.push({
|
|
runId: run.runId, type: 'run-started', sequence: run.sequence,
|
|
messageId: run.assistantMessageId
|
|
})
|
|
})
|
|
const stopEvents = window.ycode.chat.onEvent((event) => {
|
|
if (event.sessionId !== ${JSON.stringify(TASK_ID)}) return
|
|
events.push({
|
|
runId: event.runId,
|
|
type: event.type,
|
|
sequence: event.sequence,
|
|
messageId: event.messageId,
|
|
delta: event.delta,
|
|
content: event.content,
|
|
message: event.message
|
|
})
|
|
})
|
|
window.__a01ChatEventProbe = { events, dispose: () => { stopStarted(); stopEvents() } }
|
|
return true
|
|
})()`)
|
|
assert.equal(installed, true, 'chat event probe must install through the production preload API')
|
|
}
|
|
|
|
async function readChatEventProbe(win) {
|
|
return read(win, `window.__a01ChatEventProbe?.events ?? []`)
|
|
}
|
|
|
|
function eventRunDiagnostics(events) {
|
|
const runStartedTypes = new Set(['started', 'run-started'])
|
|
const runs = new Map()
|
|
for (const event of events) {
|
|
if (!runs.has(event.runId)) runs.set(event.runId, [])
|
|
runs.get(event.runId).push(event)
|
|
}
|
|
return [...runs.entries()].map(([runId, runEvents]) => {
|
|
const sequences = runEvents.map((event) => event.sequence)
|
|
return {
|
|
runId,
|
|
types: runEvents.map((event) => event.type),
|
|
sequences,
|
|
allNonNegativeIntegers: sequences.every((value) => Number.isInteger(value) && value >= 0),
|
|
strictlyIncreasing: sequences.every((value, index) => index === 0 || value > sequences[index - 1]),
|
|
unique: new Set(sequences).size === sequences.length,
|
|
startsWithRunStarted: runStartedTypes.has(runEvents[0]?.type)
|
|
}
|
|
})
|
|
}
|
|
|
|
async function markerCount(win, marker) {
|
|
return read(win, `(() => {
|
|
const text = document.querySelector('.chat-scroll')?.innerText || document.body.innerText || ''
|
|
return text.split(${JSON.stringify(marker)}).length - 1
|
|
})()`)
|
|
}
|
|
|
|
function rememberTimer(timer) {
|
|
pendingTimers.add(timer)
|
|
return timer
|
|
}
|
|
|
|
function clearRememberedTimer(timer) {
|
|
clearTimeout(timer)
|
|
pendingTimers.delete(timer)
|
|
}
|
|
|
|
function sseChunk(body, payload) {
|
|
return `data: ${JSON.stringify({
|
|
id: 'local-task-window-stream',
|
|
object: 'chat.completion.chunk',
|
|
created: 1,
|
|
model: body.model,
|
|
...payload
|
|
})}\n\n`
|
|
}
|
|
|
|
function finishSse(res, body, marker) {
|
|
if (res.destroyed || res.writableEnded) return
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 16, completion_tokens: 8, total_tokens: 24 }
|
|
}))
|
|
res.write('data: [DONE]\n\n')
|
|
res.end()
|
|
return marker
|
|
}
|
|
|
|
async function readPersistedTurn(marker) {
|
|
let parsed
|
|
try {
|
|
parsed = JSON.parse(await fs.readFile(path.join(directory, 'data', 'sessions.json'), 'utf8'))
|
|
} catch (error) {
|
|
if (error && error.code === 'ENOENT') return null
|
|
throw error
|
|
}
|
|
const session = parsed.sessions?.find((item) => item.id === TASK_ID)
|
|
if (!session) return null
|
|
const index = session.messages?.findIndex((message) => message.role === 'user' && message.content === marker) ?? -1
|
|
if (index < 0) return null
|
|
return {
|
|
user: session.messages[index],
|
|
assistant: session.messages.slice(index + 1).find((message) => message.role === 'assistant') ?? null
|
|
}
|
|
}
|
|
|
|
async function waitPersistedStatus(marker, status) {
|
|
return waitValue(async () => {
|
|
const turn = await readPersistedTurn(marker)
|
|
return turn?.assistant?.status === status ? turn : null
|
|
}, `persisted ${status}: ${marker}`)
|
|
}
|
|
|
|
async function fillComposer(win, text) {
|
|
const filled = await read(win, `(() => {
|
|
const input = document.querySelector('.wb-composer__input')
|
|
if (!(input instanceof HTMLTextAreaElement)) return false
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set
|
|
setter.call(input, ${JSON.stringify(text)})
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
input.focus()
|
|
return true
|
|
})()`)
|
|
assert.equal(filled, true, 'task-window composer input must exist')
|
|
await wait(
|
|
`document.querySelector('.wb-composer__input')?.value === ${JSON.stringify(text)}`,
|
|
'composer value',
|
|
win
|
|
)
|
|
await wait(
|
|
`(() => {
|
|
const button = document.querySelector('.cr-send-button:not(.cr-send-button--stop)')
|
|
return button && !button.disabled
|
|
})()`,
|
|
'enabled task-window send button',
|
|
win
|
|
)
|
|
}
|
|
|
|
async function clickComposerSend(win) {
|
|
const clicked = await read(win, `(() => {
|
|
const button = document.querySelector('.cr-send-button:not(.cr-send-button--stop)')
|
|
if (!button || button.disabled) return false
|
|
button.click()
|
|
return true
|
|
})()`)
|
|
assert.equal(clicked, true, 'task-window send button must be clickable')
|
|
await wait('!!document.querySelector(".cr-send-button--stop")', 'task-window running stop button', win)
|
|
}
|
|
|
|
async function visibleState(win) {
|
|
return read(win, `(() => {
|
|
const body = document.body.innerText || ''
|
|
const latestAssistant = [...document.querySelectorAll('.msg--assistant')].at(-1)
|
|
const latestAssistantText = latestAssistant?.innerText || ''
|
|
const stopButton = document.querySelector('.cr-send-button--stop')
|
|
return {
|
|
body,
|
|
donePartial: latestAssistantText.includes(${JSON.stringify(DONE_PARTIAL)}),
|
|
doneFinal: latestAssistantText.includes(${JSON.stringify(DONE_FINAL)}),
|
|
cancelPartial: latestAssistantText.includes(${JSON.stringify(CANCEL_PARTIAL)}),
|
|
closePartial: latestAssistantText.includes(${JSON.stringify(CLOSE_PARTIAL)}),
|
|
reopenPartial: latestAssistantText.includes(${JSON.stringify(REOPEN_PARTIAL)}),
|
|
reopenFinal: latestAssistantText.includes(${JSON.stringify(REOPEN_FINAL)}),
|
|
dualPartial: latestAssistantText.includes(${JSON.stringify(DUAL_PARTIAL)}),
|
|
completed: latestAssistantText.includes('已完成'),
|
|
cancelled: latestAssistantText.includes('已取消'),
|
|
stop: !!stopButton,
|
|
stopEnabled: !!stopButton && !stopButton.disabled,
|
|
send: !!document.querySelector('.cr-send-button:not(.cr-send-button--stop)')
|
|
}
|
|
})()`)
|
|
}
|
|
|
|
;(async () => {
|
|
server = createServer(async (req, res) => {
|
|
requestCount += 1
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204, {
|
|
'access-control-allow-origin': '*',
|
|
'access-control-allow-headers': '*',
|
|
'access-control-allow-methods': '*'
|
|
})
|
|
res.end()
|
|
return
|
|
}
|
|
|
|
let raw = ''
|
|
for await (const part of req) raw += part
|
|
let body
|
|
try {
|
|
body = JSON.parse(raw || '{}')
|
|
} catch {
|
|
res.writeHead(400, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ error: { message: 'QA upstream expected JSON' } }))
|
|
return
|
|
}
|
|
|
|
// Later requests carry prior turns in messages[]. Select the marker with
|
|
// the greatest source offset so history cannot reclassify the new turn.
|
|
const marker = [
|
|
['done', raw.lastIndexOf(DONE_PROMPT)],
|
|
['cancel', raw.lastIndexOf(CANCEL_PROMPT)],
|
|
['close', raw.lastIndexOf(CLOSE_PROMPT)],
|
|
['reopen', raw.lastIndexOf(REOPEN_PROMPT)],
|
|
['dual', raw.lastIndexOf(DUAL_PROMPT)]
|
|
]
|
|
.sort((left, right) => right[1] - left[1])
|
|
.find((entry) => entry[1] >= 0)?.[0] ?? 'unknown'
|
|
requestLog.push({
|
|
marker,
|
|
method: req.method,
|
|
url: req.url,
|
|
stream: body.stream === true,
|
|
model: body.model,
|
|
bodyBytes: Buffer.byteLength(raw)
|
|
})
|
|
|
|
if (!body.stream || marker === 'unknown') {
|
|
res.writeHead(400, { 'content-type': 'application/json' })
|
|
res.end(JSON.stringify({ error: { message: `Unexpected QA request: ${marker}` } }))
|
|
return
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
'content-type': 'text/event-stream',
|
|
'cache-control': 'no-cache',
|
|
connection: 'keep-alive'
|
|
})
|
|
|
|
if (marker === 'done') {
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { role: 'assistant', content: DONE_PARTIAL }, finish_reason: null }]
|
|
}))
|
|
const timer = rememberTimer(setTimeout(() => {
|
|
pendingTimers.delete(timer)
|
|
if (res.destroyed || res.writableEnded) return
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { content: DONE_FINAL }, finish_reason: null }]
|
|
}))
|
|
finishSse(res, body, 'done')
|
|
doneTerminalSentAt = Date.now()
|
|
}, 900))
|
|
res.on('close', () => clearRememberedTimer(timer))
|
|
return
|
|
}
|
|
|
|
if (marker === 'cancel') {
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { role: 'assistant', content: CANCEL_PARTIAL }, finish_reason: null }]
|
|
}))
|
|
const keepalive = setInterval(() => {
|
|
if (!res.destroyed) res.write(': keepalive\n\n')
|
|
}, 500)
|
|
res.on('close', () => {
|
|
clearInterval(keepalive)
|
|
cancelStreamClosed = true
|
|
})
|
|
return
|
|
}
|
|
|
|
if (marker === 'reopen') {
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { role: 'assistant', content: REOPEN_PARTIAL }, finish_reason: null }]
|
|
}))
|
|
reopenStreamStarted = true
|
|
res.on('close', () => { reopenFinalRelease() })
|
|
await reopenFinalGate
|
|
if (res.destroyed || res.writableEnded) return
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { content: REOPEN_FINAL }, finish_reason: null }]
|
|
}))
|
|
finishSse(res, body, 'reopen')
|
|
reopenTerminalSentAt = Date.now()
|
|
return
|
|
}
|
|
|
|
if (marker === 'dual') {
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { role: 'assistant', content: DUAL_PARTIAL }, finish_reason: null }]
|
|
}))
|
|
const keepalive = setInterval(() => {
|
|
if (!res.destroyed) res.write(': keepalive\n\n')
|
|
}, 500)
|
|
res.on('close', () => {
|
|
clearInterval(keepalive)
|
|
dualStreamClosed = true
|
|
})
|
|
return
|
|
}
|
|
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { role: 'assistant', content: CLOSE_PARTIAL }, finish_reason: null }]
|
|
}))
|
|
closeStreamStarted = true
|
|
const timer = rememberTimer(setTimeout(() => {
|
|
pendingTimers.delete(timer)
|
|
if (res.destroyed || res.writableEnded) return
|
|
res.write(sseChunk(body, {
|
|
choices: [{ index: 0, delta: { content: CLOSE_FINAL }, finish_reason: null }]
|
|
}))
|
|
finishSse(res, body, 'close')
|
|
closeTerminalSentAt = Date.now()
|
|
}, 5000))
|
|
res.on('close', () => clearRememberedTimer(timer))
|
|
})
|
|
|
|
// Import the main bundle before Electron has another chance to reach ready,
|
|
// because the bundle applies YCODE_DISABLE_GPU synchronously at module load.
|
|
server.listen(0, '127.0.0.1')
|
|
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
|
await app.whenReady()
|
|
await new Promise((resolve) => server.listening ? resolve() : server.once('listening', resolve))
|
|
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
|
|
assert.equal(new URL(baseUrl).hostname, '127.0.0.1')
|
|
|
|
for (let index = 0; index < 500; index++) {
|
|
main = BrowserWindow.getAllWindows().find((win) => win.getTitle() === 'YCode')
|
|
if (main && !main.webContents.isLoading()) break
|
|
await pause(40)
|
|
}
|
|
assert.ok(main, 'main YCode window')
|
|
captureRendererErrors(main)
|
|
main.setSize(1336, 902)
|
|
main.showInactive()
|
|
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer', main)
|
|
await installChatEventProbe(main)
|
|
|
|
await step('configure-real-omp-local-upstream')
|
|
const appInfo = await read(main, 'window.ycode.app.info()')
|
|
check('production app detects the requested real OMP executable', appInfo.omp.available, true)
|
|
check('production app resolves OMP to the isolated fixture executable',
|
|
path.resolve(appInfo.omp.exePath || ''), path.resolve(process.env.YCODE_QA_REAL_OMP))
|
|
const savedModel = await read(main, `window.ycode.models.save(${JSON.stringify({
|
|
id: MODEL_ID,
|
|
name: 'Local Task Window Stream',
|
|
providerId: 'custom',
|
|
model: 'task-window-stream-model',
|
|
baseUrl,
|
|
apiKey: 'LOCAL_TEST_KEY',
|
|
supportsToolCall: true,
|
|
maxInputTokens: 64000,
|
|
maxOutputTokens: 4096
|
|
})})`)
|
|
check(
|
|
'model is saved through the real model IPC',
|
|
savedModel?.models?.some((model) => model.id === MODEL_ID),
|
|
true
|
|
)
|
|
await read(main, `window.ycode.models.select(${JSON.stringify(MODEL_ID)})`)
|
|
check(
|
|
'controlled upstream is localhost-only',
|
|
new URL(baseUrl).hostname === '127.0.0.1' && /^http:\/\/127\.0\.0\.1:\d+\/v1$/.test(baseUrl),
|
|
true
|
|
)
|
|
|
|
await step('open-task-window-from-task-menu')
|
|
const openedTask = await openTaskWindowFromTaskMenu()
|
|
taskWindow = openedTask.window
|
|
const menuLabel = openedTask.menuLabel
|
|
check('task menu exposes the independent-window action', menuLabel, '在独立窗口中打开')
|
|
await wait('document.body.innerText.includes("Fixture response")', 'fixture task history', taskWindow)
|
|
await installChatEventProbe(taskWindow)
|
|
snapshots.taskWindow = await read(taskWindow, `({
|
|
url: location.href,
|
|
taskId: new URLSearchParams(location.search).get('taskId'),
|
|
title: document.querySelector('.workbuddy-topbar-title')?.textContent,
|
|
hasFixtureHistory: document.body.innerText.includes('Fixture response')
|
|
})`)
|
|
check('task window carries the real task id', snapshots.taskWindow.taskId, TASK_ID)
|
|
check('task window selected the requested task', snapshots.taskWindow.hasFixtureHistory, true)
|
|
check('task window exposes the real composer', await read(taskWindow, `!!document.querySelector('.wb-composer__input')`), true)
|
|
|
|
await step('task-window-stream-to-completion')
|
|
await fillComposer(taskWindow, DONE_PROMPT)
|
|
await clickComposerSend(taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(DONE_PARTIAL)})`,
|
|
'streaming partial body',
|
|
taskWindow
|
|
)
|
|
const streamingComplete = await visibleState(taskWindow)
|
|
snapshots.streamingComplete = {
|
|
donePartial: streamingComplete.donePartial,
|
|
doneFinal: streamingComplete.doneFinal,
|
|
stop: streamingComplete.stop,
|
|
send: streamingComplete.send
|
|
}
|
|
check('task window receives the first SSE chunk while turn is running', streamingComplete.donePartial, true)
|
|
check('final chunk is still absent during the held stream', streamingComplete.doneFinal, false)
|
|
check('task window exposes stop while streaming', streamingComplete.stop, true)
|
|
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes("TASKWINDOWDONEFINAL") && !document.querySelector(".cr-send-button--stop")`,
|
|
'completed stream and restored send control',
|
|
taskWindow
|
|
)
|
|
await waitValue(() => doneTerminalSentAt > 0, 'first controlled terminal chunk')
|
|
const completedState = await visibleState(taskWindow)
|
|
snapshots.completedState = {
|
|
doneFinal: completedState.doneFinal,
|
|
completed: completedState.completed,
|
|
stop: completedState.stop,
|
|
send: completedState.send
|
|
}
|
|
check('task window renders the completed stream body', completedState.doneFinal, true)
|
|
check('task window replaces stop with send after completion', [completedState.stop, completedState.send], [false, true])
|
|
check('task window reports the completed terminal state', completedState.completed, true)
|
|
const doneDisk = await waitPersistedStatus(DONE_PROMPT, 'done')
|
|
check('completed turn is persisted with assistant status done', doneDisk.assistant.status, 'done')
|
|
check('persisted completed assistant contains the terminal text', doneDisk.assistant.content.includes(DONE_FINAL), true)
|
|
|
|
await step('stop-task-window-stream-and-persist-cancelled')
|
|
await fillComposer(taskWindow, CANCEL_PROMPT)
|
|
await clickComposerSend(taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(CANCEL_PARTIAL)})`,
|
|
'cancellable streaming body',
|
|
taskWindow
|
|
)
|
|
const streamingCancel = await visibleState(taskWindow)
|
|
check('second task-window stream renders before cancellation', streamingCancel.cancelPartial, true)
|
|
check('second task-window stream exposes stop', streamingCancel.stop, true)
|
|
const stopped = await read(taskWindow, `(() => {
|
|
const button = document.querySelector('.cr-send-button--stop')
|
|
if (!button) return false
|
|
button.click()
|
|
return true
|
|
})()`)
|
|
check('stop is triggered from the task-window composer', stopped, true)
|
|
await wait(
|
|
`!document.querySelector(".cr-send-button--stop") && [...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes("已取消")`,
|
|
'cancelled task-window state',
|
|
taskWindow
|
|
)
|
|
await waitValue(() => cancelStreamClosed, 'cancelled upstream connection close')
|
|
const cancelledState = await visibleState(taskWindow)
|
|
snapshots.cancelledState = {
|
|
cancelled: cancelledState.cancelled,
|
|
stop: cancelledState.stop,
|
|
send: cancelledState.send
|
|
}
|
|
check('task window reports the cancelled terminal state', cancelledState.cancelled, true)
|
|
check('task window replaces stop with send after cancellation', [cancelledState.stop, cancelledState.send], [false, true])
|
|
const cancelledDisk = await waitPersistedStatus(CANCEL_PROMPT, 'cancelled')
|
|
check('cancelled turn is persisted with assistant status cancelled', cancelledDisk.assistant.status, 'cancelled')
|
|
check('cancelled persistence retains the received partial text',
|
|
cancelledDisk.assistant.content.includes(CANCEL_PARTIAL), true)
|
|
check('cancelling stops the controlled upstream stream', cancelStreamClosed, true)
|
|
|
|
await step('close-sending-window-and-persist-background-terminal')
|
|
await fillComposer(taskWindow, CLOSE_PROMPT)
|
|
await clickComposerSend(taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(CLOSE_PARTIAL)})`,
|
|
'close-window streaming body',
|
|
taskWindow
|
|
)
|
|
const beforeCloseDisk = await readPersistedTurn(CLOSE_PROMPT)
|
|
snapshots.beforeWindowClose = beforeCloseDisk && {
|
|
assistantId: beforeCloseDisk.assistant?.id,
|
|
status: beforeCloseDisk.assistant?.status,
|
|
content: beforeCloseDisk.assistant?.content
|
|
}
|
|
check('third turn is persisted as streaming before the window closes',
|
|
beforeCloseDisk?.assistant?.status, 'streaming')
|
|
check('third upstream stream started before the window closes', closeStreamStarted, true)
|
|
check('third upstream stream is still running before window close', closeTerminalSentAt, 0)
|
|
taskWindow.close()
|
|
await waitValue(() => taskWindow.isDestroyed(), 'task-window destruction')
|
|
check('main application remains alive after the sending task window closes', main.isDestroyed(), false)
|
|
await waitValue(() => closeTerminalSentAt > taskWindowClosedAt, 'terminal SSE after task-window close')
|
|
const closeDisk = await waitPersistedStatus(CLOSE_PROMPT, 'done')
|
|
check('background turn terminates after its sending window closes', closeTerminalSentAt > taskWindowClosedAt, true)
|
|
check('background terminal is persisted with assistant status done', closeDisk.assistant.status, 'done')
|
|
check('background terminal persistence includes the final streamed text',
|
|
closeDisk.assistant.content.includes(CLOSE_FINAL), true)
|
|
|
|
await step('reopen-running-task-before-terminal')
|
|
taskWindow = undefined
|
|
const reopenSender = await openTaskWindowFromTaskMenu()
|
|
taskWindow = reopenSender.window
|
|
await wait('document.body.innerText.includes("Fixture response")', 'reopen sender history', taskWindow)
|
|
await fillComposer(taskWindow, REOPEN_PROMPT)
|
|
await clickComposerSend(taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_PARTIAL)})`,
|
|
'reopen sender partial',
|
|
taskWindow
|
|
)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_PARTIAL)})&&!!document.querySelector(".cr-send-button--stop")`,
|
|
'main window before reopen sender closes',
|
|
main
|
|
)
|
|
const reopenBeforeClose = await waitPersistedStatus(REOPEN_PROMPT, 'streaming')
|
|
check('reopened-run sender is streaming before its window closes',
|
|
reopenBeforeClose?.assistant?.status, 'streaming')
|
|
const reopenSenderClosedAt = Date.now()
|
|
taskWindow.close()
|
|
await waitValue(() => taskWindow.isDestroyed(), 'reopen sender destruction')
|
|
check('reopened-run sender closed before any terminal event',
|
|
[reopenStreamStarted, reopenTerminalSentAt], [true, 0])
|
|
taskWindow = undefined
|
|
|
|
const reopenRequestedAt = Date.now()
|
|
const reopenedTask = await openTaskWindowFromTaskMenu()
|
|
taskWindow = reopenedTask.window
|
|
check('running task can be reopened through the main task menu', reopenedTask.menuLabel, '在独立窗口中打开')
|
|
await installChatEventProbe(taskWindow)
|
|
await wait('document.body.innerText.includes("Fixture response")', 'reopened task history', taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_PARTIAL)})`,
|
|
'reopened window active-run partial',
|
|
taskWindow
|
|
)
|
|
const reopenVisibleAt = Date.now()
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_PARTIAL)})`,
|
|
'main-window active-run partial',
|
|
main
|
|
)
|
|
const reopenedState = await visibleState(taskWindow)
|
|
const mainReopenState = await visibleState(main)
|
|
snapshots.reopenBeforeTerminal = {
|
|
menuLabel: reopenedTask.menuLabel,
|
|
mainPartialCount: await markerCount(main, REOPEN_PARTIAL),
|
|
taskPartialCount: await markerCount(taskWindow, REOPEN_PARTIAL),
|
|
mainFinalCount: await markerCount(main, REOPEN_FINAL),
|
|
taskFinalCount: await markerCount(taskWindow, REOPEN_FINAL),
|
|
mainStop: mainReopenState.stop,
|
|
mainStopEnabled: mainReopenState.stopEnabled,
|
|
taskStop: reopenedState.stop,
|
|
taskStopEnabled: reopenedState.stopEnabled,
|
|
reopenTerminalSentAt,
|
|
senderClosedToReopenRequestedMs: reopenRequestedAt - reopenSenderClosedAt,
|
|
reopenedAfterMs: reopenVisibleAt - reopenRequestedAt
|
|
}
|
|
check('reopened window shows the live partial before terminal',
|
|
[reopenedState.reopenPartial, reopenedState.reopenFinal], [true, false])
|
|
check('reopened window immediately exposes a usable stop control', reopenedState.stopEnabled, true)
|
|
check('reopened window obtained the run without waiting for the destroyed sender',
|
|
[reopenStreamStarted, reopenTerminalSentAt], [true, 0])
|
|
check('main window also shows the running partial before terminal',
|
|
[mainReopenState.reopenPartial, mainReopenState.reopenFinal, mainReopenState.stopEnabled], [true, false, true])
|
|
check('main and reopened task render the partial exactly once',
|
|
[snapshots.reopenBeforeTerminal.mainPartialCount, snapshots.reopenBeforeTerminal.taskPartialCount], [1, 1])
|
|
check('terminal text is absent while the reopened run is still gated',
|
|
[snapshots.reopenBeforeTerminal.mainFinalCount, snapshots.reopenBeforeTerminal.taskFinalCount], [0, 0])
|
|
|
|
reopenFinalRelease()
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_FINAL)})&&!document.querySelector(".cr-send-button--stop")`,
|
|
'reopened window terminal',
|
|
taskWindow
|
|
)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(REOPEN_FINAL)})&&!document.querySelector(".cr-send-button--stop")`,
|
|
'main window terminal for reopened run',
|
|
main
|
|
)
|
|
const reopenedDoneState = await visibleState(taskWindow)
|
|
const mainReopenDoneState = await visibleState(main)
|
|
check('reopened window updates from partial to completed terminal',
|
|
[reopenedDoneState.reopenFinal, reopenedDoneState.completed, reopenedDoneState.stop], [true, true, false])
|
|
check('main window receives the same completed terminal exactly once',
|
|
[mainReopenDoneState.reopenFinal, mainReopenDoneState.completed, mainReopenDoneState.stop], [true, true, false])
|
|
check('main and reopened task render the final text exactly once',
|
|
[await markerCount(main, REOPEN_FINAL), await markerCount(taskWindow, REOPEN_FINAL)], [1, 1])
|
|
check('main and reopened task still render the partial exactly once after completion',
|
|
[await markerCount(main, REOPEN_PARTIAL), await markerCount(taskWindow, REOPEN_PARTIAL)], [1, 1])
|
|
const reopenedDisk = await waitPersistedStatus(REOPEN_PROMPT, 'done')
|
|
check('reopened run persists the terminal text', reopenedDisk.assistant.content.includes(REOPEN_FINAL), true)
|
|
|
|
await step('dual-window-send-and-stop-sync')
|
|
await ensureTaskSelected(main, 'dual-window main')
|
|
await fillComposer(taskWindow, DUAL_PROMPT)
|
|
await clickComposerSend(taskWindow)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(DUAL_PARTIAL)})`,
|
|
'task-window dual partial',
|
|
taskWindow
|
|
)
|
|
await wait(
|
|
`[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes(${JSON.stringify(DUAL_PARTIAL)})&&!!document.querySelector(".cr-send-button--stop")`,
|
|
'main-window dual partial and stop',
|
|
main
|
|
)
|
|
const dualTask = await visibleState(taskWindow)
|
|
const dualMain = await visibleState(main)
|
|
snapshots.dualStreaming = {
|
|
mainPartialCount: await markerCount(main, DUAL_PARTIAL),
|
|
taskPartialCount: await markerCount(taskWindow, DUAL_PARTIAL),
|
|
mainStop: dualMain.stop,
|
|
mainStopEnabled: dualMain.stopEnabled,
|
|
taskStop: dualTask.stop,
|
|
taskStopEnabled: dualTask.stopEnabled
|
|
}
|
|
check('both selected windows receive the independent-window send',
|
|
[dualMain.dualPartial, dualTask.dualPartial], [true, true])
|
|
check('both selected windows render the streamed partial exactly once',
|
|
[snapshots.dualStreaming.mainPartialCount, snapshots.dualStreaming.taskPartialCount], [1, 1])
|
|
check('both selected windows expose stop while the shared run is active',
|
|
[dualMain.stopEnabled, dualTask.stopEnabled], [true, true])
|
|
const dualStopped = await read(taskWindow, `(() => {
|
|
const button = document.querySelector('.cr-send-button--stop')
|
|
if (!button) return false
|
|
button.click()
|
|
return true
|
|
})()`)
|
|
check('shared run stop is triggered from the sending task window', dualStopped, true)
|
|
await wait(
|
|
`!document.querySelector(".cr-send-button--stop")&&[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes("已取消")`,
|
|
'task-window dual cancellation',
|
|
taskWindow
|
|
)
|
|
await wait(
|
|
`!document.querySelector(".cr-send-button--stop")&&[...document.querySelectorAll('.msg--assistant')].at(-1)?.innerText.includes("已取消")`,
|
|
'main-window dual cancellation',
|
|
main
|
|
)
|
|
await waitValue(() => dualStreamClosed, 'dual upstream cancellation')
|
|
const dualTaskCancelled = await visibleState(taskWindow)
|
|
const dualMainCancelled = await visibleState(main)
|
|
check('both selected windows synchronize the stopped terminal state',
|
|
[dualMainCancelled.cancelled, dualTaskCancelled.cancelled], [true, true])
|
|
check('both selected windows restore send after the shared stop',
|
|
[dualMainCancelled.stop, dualMainCancelled.send, dualTaskCancelled.stop, dualTaskCancelled.send],
|
|
[false, true, false, true])
|
|
check('dual-window cancellation still renders the partial exactly once',
|
|
[await markerCount(main, DUAL_PARTIAL), await markerCount(taskWindow, DUAL_PARTIAL)], [1, 1])
|
|
const dualDisk = await waitPersistedStatus(DUAL_PROMPT, 'cancelled')
|
|
check('dual-window cancellation persists the cancelled status',
|
|
dualDisk.assistant.status, 'cancelled')
|
|
|
|
const mainRuns = eventRunDiagnostics(await readChatEventProbe(main))
|
|
const reopenedRuns = eventRunDiagnostics(await readChatEventProbe(taskWindow))
|
|
snapshots.chatEventSequences = { mainRuns, reopenedRuns }
|
|
check('main window receives run-started for every accepted turn',
|
|
mainRuns.length, 5)
|
|
check('main window run-started precedes each run event stream',
|
|
mainRuns.every((run) => run.startsWithRunStarted), true)
|
|
check('main window chat events carry non-negative integer sequences',
|
|
mainRuns.every((run) => run.allNonNegativeIntegers), true)
|
|
check('main window chat event sequences are strict and duplicate-free',
|
|
mainRuns.every((run) => run.strictlyIncreasing && run.unique), true)
|
|
check('reopened task window observes sequenced live events after hydration',
|
|
reopenedRuns.length >= 1 && reopenedRuns.every((run) => run.allNonNegativeIntegers && run.strictlyIncreasing && run.unique),
|
|
true)
|
|
|
|
check('all five turns reached the controlled local upstream',
|
|
requestLog.filter((entry) => entry.marker !== 'unknown').length, 5)
|
|
check('all upstream requests were streaming requests',
|
|
requestLog.filter((entry) => entry.marker !== 'unknown').every((entry) => entry.stream), true)
|
|
check('all upstream requests resolve to the isolated localhost fixture',
|
|
requestLog.filter((entry) => entry.marker !== 'unknown').every((entry) => entry.url?.startsWith('/v1/')), true)
|
|
check('no renderer error logs', rendererErrors, [])
|
|
|
|
const report = {
|
|
result: checks.every((item) => item.pass) ? 'PASS' : 'FAIL',
|
|
scope: 'Real isolated YCode App/preload/IPC/renderer, real OMP executable, main and independent task windows selected on the same task, a controlled localhost OpenAI-compatible SSE upstream, active-run reopen hydration, and sequenced broadcast event observation. Every send began via the visible task-window composer; window.ycode.chat.send was not invoked.',
|
|
isolation: {
|
|
directory,
|
|
userData: path.join(directory, 'data'),
|
|
ompHome: path.join(directory, 'home'),
|
|
workspace: path.join(directory, 'workspace'),
|
|
upstream: baseUrl
|
|
},
|
|
requestLog,
|
|
snapshots,
|
|
checks,
|
|
rendererErrors,
|
|
limitations: [
|
|
'The reopened-window scenario holds the SSE terminal behind a test gate, so the partial and stop control are asserted before any terminal event is sent.',
|
|
'Sequence/run-started checks use passive window.ycode.chat.onStarted and onEvent observers; production subscribers remain installed and no IPC or store listener is replaced.',
|
|
'The close-window terminal check observes the persisted sessions.json result, which is the product persistence boundary; the destroyed renderer cannot be queried for a late UI update.',
|
|
'The script requires a real OMP executable at YCODE_OMP_PATH or %LOCALAPPDATA%/Ygent/omp.exe and intentionally has no fallback to a remote or fake kernel.'
|
|
]
|
|
}
|
|
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
|
console.log('TASK_WINDOW_STREAM_' + report.result + ' ' + directory)
|
|
for (const failure of checks.filter((item) => !item.pass)) {
|
|
console.log('TASK_WINDOW_STREAM_FAILURE ' + JSON.stringify(failure))
|
|
}
|
|
for (const timer of pendingTimers) clearTimeout(timer)
|
|
server.closeAllConnections?.()
|
|
server.close()
|
|
app.exit(report.result === 'PASS' ? 0 : 1)
|
|
})().catch(async (error) => {
|
|
reopenFinalRelease?.()
|
|
for (const timer of pendingTimers) clearTimeout(timer)
|
|
server?.closeAllConnections?.()
|
|
server?.close()
|
|
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
|
|
phase,
|
|
error: String(error),
|
|
stack: error?.stack,
|
|
requestCount,
|
|
requestLog,
|
|
checks,
|
|
snapshots,
|
|
rendererErrors,
|
|
taskWindowClosedAt,
|
|
doneTerminalSentAt,
|
|
cancelStreamClosed,
|
|
closeTerminalSentAt,
|
|
reopenStreamStarted,
|
|
reopenTerminalSentAt,
|
|
dualStreamClosed
|
|
}, null, 2)).catch(() => undefined)
|
|
console.error(error)
|
|
app.exit(1)
|
|
})
|
|
}
|