feat: ship skill and MCP workflows with signed updater for v0.0.7
This commit is contained in:
1 parent
1b52df0603
commit
a01085c656
136 files changed
+18646
-217
No files matched your search
@@ -0,0 +1,761 @@
|
||||
// Real isolated YCode main/preload/renderer verification for native append delivery.
|
||||
// The production build is loaded unchanged. A loopback Anthropic-compatible server
|
||||
// holds the first response while two durable queue messages are sent as guidance.
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
if (!process.versions.electron) {
|
||||
;(async () => {
|
||||
const directory = await fs.mkdtemp(path.resolve('.tmp/claude-append-app-'))
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
await Promise.all([fs.mkdir(data, { recursive: true }), fs.mkdir(home, { recursive: true })])
|
||||
const probe = require('node:net').createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
probe.once('error', reject)
|
||||
probe.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = probe.address()
|
||||
if (!address || typeof address === 'string') throw new Error('failed to reserve loopback port')
|
||||
const appendPort = address.port
|
||||
await new Promise(resolve => probe.close(resolve))
|
||||
const env = {
|
||||
...process.env,
|
||||
YCODE_QA_DIRECTORY: directory,
|
||||
YCODE_USER_DATA_DIR: data,
|
||||
YCODE_APPEND_PORT: String(appendPort),
|
||||
YCODE_QA_OFFSCREEN: '1',
|
||||
YCODE_DISABLE_GPU: '1',
|
||||
YCODE_DISABLE_SYSTEM_INTEGRATION: '1',
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
DISABLE_TELEMETRY: '1',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '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('APPEND_E2E_')) process.stdout.write(bytes)
|
||||
})
|
||||
child.stderr.on('data', bytes => {
|
||||
output += bytes
|
||||
if (bytes.toString().includes('APPEND_E2E_')) process.stderr.write(bytes)
|
||||
})
|
||||
const timer = setTimeout(() => child.kill(), 180000)
|
||||
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 : 0
|
||||
})
|
||||
})().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 fsSync = require('node:fs')
|
||||
|
||||
const directory = process.env.YCODE_QA_DIRECTORY
|
||||
assert.ok(directory, 'YCODE_QA_DIRECTORY is required')
|
||||
const appendPort = Number(process.env.YCODE_APPEND_PORT)
|
||||
assert.ok(Number.isInteger(appendPort) && appendPort > 0, 'YCODE_APPEND_PORT is required')
|
||||
const relative = path.relative(path.resolve('.tmp'), path.resolve(directory))
|
||||
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'unsafe QA directory')
|
||||
|
||||
const data = path.join(directory, 'data')
|
||||
const home = path.join(directory, 'home')
|
||||
const workspace = path.join(home, 'YCodeWorkspace')
|
||||
const modelId = 'native-append-loopback'
|
||||
const modelBId = 'native-append-loopback-b'
|
||||
const modelCId = 'native-append-loopback-c'
|
||||
// Each profile keeps its own wire model id so the loopback server can prove
|
||||
// which model actually served a request.
|
||||
const wireModelA = 'claude-native-append-a'
|
||||
const wireModelB = 'claude-native-append-b'
|
||||
const wireModelC = 'claude-native-append-c'
|
||||
// Each profile gets its own base path so the request URL alone proves which
|
||||
// model served a turn, independently of the request body fields.
|
||||
const modelPathA = '/m-a'
|
||||
const modelPathB = '/m-b'
|
||||
const modelPathC = '/m-c'
|
||||
|
||||
app.disableHardwareAcceleration()
|
||||
app.setPath('userData', data)
|
||||
app.setPath('sessionData', data)
|
||||
app.setPath('home', home)
|
||||
|
||||
let hidingWindows = true
|
||||
app.on('browser-window-created', (_event, win) => {
|
||||
win.setSkipTaskbar(true)
|
||||
win.on('show', () => {
|
||||
if (!hidingWindows || win.isDestroyed()) return
|
||||
setImmediate(() => {
|
||||
if (!win.isDestroyed()) win.hide()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
const checks = []
|
||||
const rendererErrors = []
|
||||
const requests = []
|
||||
const serverEvents = []
|
||||
const queueTimeline = []
|
||||
const openSockets = new Set()
|
||||
let firstResponseGate
|
||||
let firstResponseReleased = false
|
||||
const firstResponse = new Promise(resolve => { firstResponseGate = resolve })
|
||||
let stage = 'boot'
|
||||
|
||||
function setStage(value) {
|
||||
stage = value
|
||||
serverEvents.push({ type: 'stage', stage: value })
|
||||
}
|
||||
|
||||
function releaseFirstResponse() {
|
||||
if (firstResponseReleased) return
|
||||
firstResponseReleased = true
|
||||
firstResponseGate()
|
||||
}
|
||||
|
||||
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 sse(res, event, value) {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)
|
||||
}
|
||||
|
||||
function messageStart(res, id) {
|
||||
sse(res, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: modelId,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 12, output_tokens: 1 }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function textBlock(res, text) {
|
||||
sse(res, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: 0,
|
||||
content_block: { type: 'text', text: '' }
|
||||
})
|
||||
sse(res, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index: 0,
|
||||
delta: { type: 'text_delta', text }
|
||||
})
|
||||
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
|
||||
}
|
||||
|
||||
function messageEnd(res) {
|
||||
sse(res, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 4 }
|
||||
})
|
||||
sse(res, 'message_stop', { type: 'message_stop' })
|
||||
res.end()
|
||||
}
|
||||
|
||||
function plainText(res, id, text) {
|
||||
messageStart(res, id)
|
||||
textBlock(res, text)
|
||||
messageEnd(res)
|
||||
}
|
||||
|
||||
function messageTexts(message) {
|
||||
if (!message || typeof message !== 'object') return []
|
||||
const content = message.content
|
||||
if (typeof content === 'string') return [content]
|
||||
if (!Array.isArray(content)) return []
|
||||
return content.flatMap(block => block && typeof block === 'object' &&
|
||||
block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])
|
||||
}
|
||||
|
||||
function userTexts(body) {
|
||||
if (!Array.isArray(body.messages)) return []
|
||||
return body.messages.flatMap(message => message?.role === 'user' ? messageTexts(message) : [])
|
||||
}
|
||||
|
||||
async function read(source, win) {
|
||||
let timer
|
||||
try {
|
||||
return await Promise.race([
|
||||
win.webContents.executeJavaScript(source),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(source, label, win, attempts = 500) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = await read(source, win)
|
||||
if (value) return value
|
||||
await pause(40)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function waitForMain(predicate, label, attempts = 500) {
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
const value = await predicate()
|
||||
if (value) return value
|
||||
await pause(40)
|
||||
}
|
||||
throw Error('Timeout: ' + label)
|
||||
}
|
||||
|
||||
async function readQueue(win, sessionId) {
|
||||
return read(`window.ycode.chat.queue.get(${JSON.stringify(sessionId)})`, win)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the send-now action of one specific queue row. The row is located by
|
||||
* its rendered preview text, which is unique per queued message, so a
|
||||
* concurrent send-now on another row can never be mistaken for this one.
|
||||
*/
|
||||
async function clickSendNowForRow(win, previewText) {
|
||||
const clicked = await read(`(()=>{
|
||||
const rows=[...document.querySelectorAll('.conversation-message-queue-item')];
|
||||
const row=rows.find(node=>node.querySelector('.conversation-queue-preview-text')?.textContent?.includes(${JSON.stringify(previewText)}));
|
||||
if(!row)return {ok:false,reason:'row-not-found',rows:rows.length};
|
||||
const button=row.querySelector('[aria-label="立即发送"]');
|
||||
if(!button)return {ok:false,reason:'no-button',status:row.textContent??''};
|
||||
if(button.disabled)return {ok:false,reason:'disabled'};
|
||||
button.click();
|
||||
return {ok:true}
|
||||
})()`, win)
|
||||
assert.equal(clicked.ok, true, `send-now for row ${previewText}: ${JSON.stringify(clicked)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* A row leaves the queue only after the native identity is acknowledged.
|
||||
* While the first model response is still gated, that acknowledgement cannot
|
||||
* arrive, so the row stays visible in the delivering state.
|
||||
*/
|
||||
async function waitForRowStatus(win, sessionId, queueId, status) {
|
||||
for (let index = 0; index < 500; index += 1) {
|
||||
const snapshot = await readQueue(win, sessionId)
|
||||
const item = snapshot.items.find(entry => entry.id === queueId)
|
||||
if (item?.status === status) return snapshot
|
||||
if (!item) {
|
||||
const error = new Error(`queue row ${queueId} left the queue before reaching ${status}`)
|
||||
error.snapshot = snapshot
|
||||
throw error
|
||||
}
|
||||
await pause(40)
|
||||
}
|
||||
throw Error(`Timeout: queue row ${queueId} did not reach ${status}`)
|
||||
}
|
||||
|
||||
async function setComposer(win, text) {
|
||||
const set = await read(`(()=>{
|
||||
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
|
||||
})()`, win)
|
||||
assert.equal(set, true, 'composer textarea must exist')
|
||||
await waitFor(`!document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)')?.disabled`,
|
||||
'composer accepts ' + text, win)
|
||||
}
|
||||
|
||||
async function clickComposerSend(win) {
|
||||
const clicked = await read(`(()=>{
|
||||
const button=document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)');
|
||||
if(!button||button.disabled)return false;
|
||||
button.click();
|
||||
return true
|
||||
})()`, win)
|
||||
assert.equal(clicked, true, 'composer send button must be enabled')
|
||||
}
|
||||
|
||||
async function queueFromUi(win, text, expectedCount) {
|
||||
await setComposer(win, text)
|
||||
await clickComposerSend(win)
|
||||
await waitFor(`document.querySelectorAll('.conversation-message-queue-item').length===${expectedCount}`,
|
||||
`queue count ${expectedCount} after ${text}`, win)
|
||||
}
|
||||
|
||||
async function screenshot(win, name) {
|
||||
win.webContents.invalidate()
|
||||
await pause(120)
|
||||
const image = await win.webContents.capturePage()
|
||||
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
|
||||
return !image.isEmpty()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setStage('start')
|
||||
const watchdog = setTimeout(() => {
|
||||
fsSync.writeFileSync(path.join(directory, 'watchdog.json'), JSON.stringify({
|
||||
stage,
|
||||
checks,
|
||||
queueTimeline,
|
||||
requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })),
|
||||
serverEvents
|
||||
}, null, 2))
|
||||
console.error('APPEND_E2E_ERROR watchdog timeout at stage ' + stage)
|
||||
app.exit(1)
|
||||
}, 150000)
|
||||
watchdog.unref?.()
|
||||
fsSync.mkdirSync(workspace, { recursive: true })
|
||||
fsSync.writeFileSync(path.join(workspace, 'README.md'), 'native append app verification\n', 'utf8')
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
openSockets.add(req.socket)
|
||||
req.socket.on('close', () => openSockets.delete(req.socket))
|
||||
let raw = ''
|
||||
req.on('data', chunk => { raw += chunk })
|
||||
req.on('end', async () => {
|
||||
let body = {}
|
||||
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
|
||||
if (!req.url?.includes('/messages')) {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{}')
|
||||
return
|
||||
}
|
||||
|
||||
const texts = userTexts(body)
|
||||
const servedModel = req.url?.includes(modelPathB) ? 'B'
|
||||
: req.url?.includes(modelPathC) ? 'C'
|
||||
: req.url?.includes(modelPathA) ? 'A' : 'unknown'
|
||||
const captured = {
|
||||
index: requests.length + 1,
|
||||
url: req.url,
|
||||
body,
|
||||
servedModel,
|
||||
wireModel: typeof body.model === 'string' ? body.model : null,
|
||||
userTexts: texts,
|
||||
completed: false,
|
||||
aborted: false
|
||||
}
|
||||
requests.push(captured)
|
||||
serverEvents.push({ type: 'request', index: captured.index, texts, servedModel })
|
||||
res.on('close', () => {
|
||||
if (!captured.completed) {
|
||||
captured.aborted = true
|
||||
serverEvents.push({ type: 'aborted', index: captured.index })
|
||||
}
|
||||
})
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
connection: 'keep-alive'
|
||||
})
|
||||
res.flushHeaders()
|
||||
|
||||
if (captured.index === 1) await firstResponse
|
||||
if (res.destroyed) return
|
||||
const hasA = texts.some(text => text.includes('E2E_APPEND_A'))
|
||||
const hasB = texts.some(text => text.includes('E2E_APPEND_B'))
|
||||
const afterSwitch = texts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))
|
||||
const answer = afterSwitch ? 'E2E_ANSWER_AFTER_SWITCH'
|
||||
: captured.index === 1 ? 'E2E_ANSWER_ONE'
|
||||
: hasA && hasB ? 'E2E_ANSWER_MERGED' : 'E2E_ANSWER_UNEXPECTED'
|
||||
captured.completed = true
|
||||
captured.answer = answer
|
||||
serverEvents.push({ type: 'response', index: captured.index, answer })
|
||||
plainText(res, `msg-native-append-${captured.index}`, answer)
|
||||
})
|
||||
})
|
||||
|
||||
const serverReady = new Promise((resolve, reject) => {
|
||||
server.once('listening', resolve)
|
||||
server.once('error', reject)
|
||||
})
|
||||
server.listen(appendPort, '127.0.0.1')
|
||||
const baseUrl = `http://127.0.0.1:${appendPort}`
|
||||
|
||||
fsSync.mkdirSync(data, { recursive: true })
|
||||
fsSync.writeFileSync(path.join(data, 'models.json'), JSON.stringify({
|
||||
version: 1,
|
||||
models: [
|
||||
{
|
||||
id: modelId,
|
||||
name: 'Native Append Loopback A',
|
||||
providerId: 'custom',
|
||||
model: wireModelA,
|
||||
baseUrl: baseUrl + modelPathA,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
},
|
||||
{
|
||||
id: modelBId,
|
||||
name: 'Native Append Loopback B',
|
||||
providerId: 'custom',
|
||||
model: wireModelB,
|
||||
baseUrl: baseUrl + modelPathB,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
},
|
||||
{
|
||||
id: modelCId,
|
||||
name: 'Native Append Loopback C',
|
||||
providerId: 'custom',
|
||||
model: wireModelC,
|
||||
baseUrl: baseUrl + modelPathC,
|
||||
authMode: 'api-key',
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
protocol: 'anthropic',
|
||||
supportsToolCall: true,
|
||||
supportsImages: false,
|
||||
supportsReasoning: false,
|
||||
contextWindowTokens: 200000,
|
||||
maxInputTokens: 180000,
|
||||
maxOutputTokens: 4096,
|
||||
temperature: 0,
|
||||
maxToolRounds: 1
|
||||
}
|
||||
],
|
||||
selectedModelId: modelId
|
||||
}, null, 2), 'utf8')
|
||||
fsSync.writeFileSync(path.join(data, 'config.json'), JSON.stringify({
|
||||
baseUrl,
|
||||
model: 'claude-native-append',
|
||||
temperature: 0,
|
||||
maxToolRounds: 1,
|
||||
apiKeyPlain: 'ycode-loopback-append-key',
|
||||
workspaceRoot: workspace,
|
||||
preventSleep: false,
|
||||
autoLaunch: false
|
||||
}, null, 2), 'utf8')
|
||||
|
||||
let main
|
||||
try {
|
||||
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
|
||||
await serverReady
|
||||
await app.whenReady()
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
|
||||
if (main && !main.webContents.isLoading()) break
|
||||
await pause(25)
|
||||
}
|
||||
assert.ok(main, 'main window')
|
||||
main.webContents.on('console-message', (...args) => {
|
||||
const event = args[0]
|
||||
const level = event?.level ?? args[1]
|
||||
const message = event?.message ?? args[2]
|
||||
if (level === 'error' || level === 3) rendererErrors.push(String(message))
|
||||
})
|
||||
main.setSize(1336, 902)
|
||||
await waitFor('!!window.ycode', 'preload bridge', main)
|
||||
await waitFor(`!!document.querySelector('.conversation-list')&&!!document.querySelector('.wb-composer__input')`,
|
||||
'renderer ready', main)
|
||||
check('all created windows stay hidden during verification', main.isVisible(), false)
|
||||
|
||||
setStage('create-session')
|
||||
const session = await read(`window.ycode.session.create('Native append E2E', ${JSON.stringify({
|
||||
cwd: workspace,
|
||||
modelId,
|
||||
agentMode: 'craft',
|
||||
permissionMode: 'default-sandbox'
|
||||
})})`, main)
|
||||
assert.ok(session?.id, 'session creation')
|
||||
await read(`(()=>{
|
||||
const url=new URL(location.href);
|
||||
url.searchParams.set('window','task');
|
||||
url.searchParams.set('taskId',${JSON.stringify(session.id)});
|
||||
location.href=url.href;
|
||||
return true
|
||||
})()`, main).catch(() => undefined)
|
||||
await waitFor(`document.querySelector('[data-session-id=${JSON.stringify(session.id)}]')?.getAttribute('data-selected')==='true'`,
|
||||
'created session selected', main)
|
||||
await waitFor(`!!document.querySelector('.wb-composer__input')`, 'composer after selection', main)
|
||||
|
||||
setStage('send-initial')
|
||||
await setComposer(main, 'E2E_INITIAL')
|
||||
await clickComposerSend(main)
|
||||
await waitForMain(() => requests[0], 'first model request')
|
||||
check('first request reaches the loopback model', requests[0].url.includes('/messages'), true)
|
||||
|
||||
setStage('queue-guidance')
|
||||
await queueFromUi(main, 'E2E_APPEND_A', 1)
|
||||
await queueFromUi(main, 'E2E_APPEND_B', 2)
|
||||
const beforeSendNow = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
|
||||
check('two queued guidance messages are visible in the composer', beforeSendNow.items.length, 2)
|
||||
check('queued guidance retains both texts', beforeSendNow.items.map(item => item.payload.text), ['E2E_APPEND_A', 'E2E_APPEND_B'])
|
||||
check('queue screenshot captured while the first response is active', await screenshot(main, 'append-queue'), true)
|
||||
|
||||
// Both guidance messages are handed to the running turn while the first
|
||||
// model response is still gated. A row only leaves the queue after the
|
||||
// native identity is acknowledged, so both rows must still be visible.
|
||||
setStage('send-now-a')
|
||||
await clickSendNowForRow(main, 'E2E_APPEND_A')
|
||||
const afterSendNowA = await waitForRowStatus(main, session.id, beforeSendNow.items[0].id, 'delivering')
|
||||
queueTimeline.push({ stage: 'after-send-now-a', items: afterSendNowA.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
|
||||
check('the first guidance message is handed to the running turn', afterSendNowA.items[0].status, 'delivering')
|
||||
check('the second guidance message is still queued while the first is delivering',
|
||||
afterSendNowA.items[1]?.status, 'pending')
|
||||
|
||||
setStage('send-now-b')
|
||||
await clickSendNowForRow(main, 'E2E_APPEND_B')
|
||||
const afterSendNowB = await waitForRowStatus(main, session.id, beforeSendNow.items[1].id, 'delivering')
|
||||
queueTimeline.push({ stage: 'after-send-now-b', items: afterSendNowB.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
|
||||
check('both guidance messages are delivered to the running turn concurrently',
|
||||
afterSendNowB.items.map(item => item.status), ['delivering', 'delivering'])
|
||||
check('no guidance row is removed before the native identity is acknowledged',
|
||||
afterSendNowB.items.length, 2)
|
||||
|
||||
// ---- Model switch while the turn is still running.
|
||||
// The active run must keep model A; only later requests may use the new choice.
|
||||
setStage('model-switch-b')
|
||||
const switchB = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelBId)} })`, main)
|
||||
check('the session accepts model B while a run is active', switchB?.modelId, modelBId)
|
||||
setStage('model-switch-c')
|
||||
const switchC = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelCId)} })`, main)
|
||||
check('the session accepts model C while a run is active', switchC?.modelId, modelCId)
|
||||
const modelSwitchRequests = requests.length
|
||||
|
||||
setStage('release-first-response')
|
||||
releaseFirstResponse()
|
||||
await waitForMain(() => requests.length >= 2 ? requests[1] : undefined, 'append model request')
|
||||
setStage('await-queue-drain')
|
||||
const drained = await waitForMain(async () => {
|
||||
const snapshot = await readQueue(main, session.id)
|
||||
return snapshot.items.length === 0 ? snapshot : undefined
|
||||
}, 'acknowledged guidance leaves the durable queue')
|
||||
const afterSendNow = drained
|
||||
await waitFor(`document.querySelector('.chat-thread')?.textContent.includes('E2E_ANSWER_MERGED')`,
|
||||
'merged answer rendered', main, 800)
|
||||
await waitForMain(async () => {
|
||||
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
|
||||
return active === null
|
||||
}, 'native run completed')
|
||||
check('final conversation screenshot captured', await screenshot(main, 'append-final'), true)
|
||||
|
||||
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
const finalQueue = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
|
||||
const users = messages.filter(message => message.role === 'user')
|
||||
const assistants = messages.filter(message => message.role === 'assistant')
|
||||
const queuedIds = beforeSendNow.items.map(item => item.id)
|
||||
const finalText = await read(`document.querySelector('.chat-thread')?.textContent??''`, main)
|
||||
|
||||
const secondTexts = requests[1].userTexts
|
||||
const initialPosition = secondTexts.findIndex(text => text.includes('E2E_INITIAL'))
|
||||
const aPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_A'))
|
||||
const bPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_B'))
|
||||
const lastUser = [...requests[1].body.messages].reverse().find(message => message?.role === 'user')
|
||||
const lastUserText = messageTexts(lastUser).join('\n')
|
||||
const lastUserBlocks = Array.isArray(lastUser?.content) ? lastUser.content : []
|
||||
const guidanceBlocks = lastUserBlocks.filter(block => block?.type === 'text' &&
|
||||
(block.text?.includes('E2E_APPEND_A') || block.text?.includes('E2E_APPEND_B')))
|
||||
|
||||
check('first request was not interrupted by queue guidance', requests[0].aborted, false)
|
||||
check('first request completed before the append request', requests[0].completed, true)
|
||||
check('the append guidance produced a second model request', requests.length >= 2, true)
|
||||
check('append request contains the initial message before both guidance messages',
|
||||
initialPosition >= 0 && aPosition > initialPosition && bPosition > aPosition, true)
|
||||
check('both guidance messages are merged into one native user content group',
|
||||
lastUserText.includes('E2E_APPEND_A') && lastUserText.includes('E2E_APPEND_B'), true)
|
||||
check('the merged guidance arrives as separate text blocks in one user message',
|
||||
guidanceBlocks.length >= 2, true)
|
||||
check('the second model request is the append request', requests[1].answer, 'E2E_ANSWER_MERGED')
|
||||
check('the merged answer is the second assistant turn', assistants.at(-1)?.content?.includes('E2E_ANSWER_MERGED'), true)
|
||||
check('the initial answer remains in history', assistants.some(message => message.content?.includes('E2E_ANSWER_ONE')), true)
|
||||
check('both queued user message identities persist in history',
|
||||
queuedIds.every(id => users.some(message => message.id === id)), true)
|
||||
check('queued user messages keep their guidance text',
|
||||
queuedIds.map(id => users.find(message => message.id === id)?.content),
|
||||
['E2E_APPEND_A', 'E2E_APPEND_B'])
|
||||
check('both appended messages share the owning run identity',
|
||||
new Set(users.filter(message => queuedIds.includes(message.id)).map(message => message.requestId)).size, 1)
|
||||
check('final durable queue is empty', finalQueue.items.length, 0)
|
||||
check('both guidance messages were acknowledged and removed from the queue',
|
||||
beforeSendNow.items.every(item => !finalQueue.items.some(entry => entry.id === item.id)), true)
|
||||
check('final renderer shows both guidance messages and the merged answer',
|
||||
['E2E_APPEND_A', 'E2E_APPEND_B', 'E2E_ANSWER_MERGED'].every(text => finalText.includes(text)), true)
|
||||
check('no renderer errors were logged', rendererErrors, [])
|
||||
|
||||
// ---- Independent message after the switch must use the last selected model.
|
||||
setStage('model-switch-followup')
|
||||
await setComposer(main, 'E2E_AFTER_MODEL_SWITCH')
|
||||
await clickComposerSend(main)
|
||||
// The follow-up message is queued while the run is active and dispatched
|
||||
// afterwards, so wait for the request that actually carries its text
|
||||
// instead of trusting the request count.
|
||||
const followUp = await waitForMain(() => requests.find(request =>
|
||||
request.index > modelSwitchRequests && request.userTexts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))),
|
||||
'independent message after the model switch')
|
||||
check('the independent message after switching is served by the last selected model',
|
||||
followUp.servedModel, 'C')
|
||||
await waitForMain(async () => {
|
||||
const list = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
return list.some(message => message.role === 'assistant' && message.modelId === modelCId &&
|
||||
message.status === 'done' && String(message.content ?? '').includes('E2E_ANSWER_AFTER_SWITCH')) ? list : undefined
|
||||
}, 'post-switch assistant persisted')
|
||||
// The composer must also release the running state before the final captures.
|
||||
await waitForMain(async () => {
|
||||
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
|
||||
return active === null
|
||||
}, 'post-switch run released')
|
||||
await waitFor(`!document.querySelector('.cr-send-button--stop')&&!document.querySelector('[aria-label="停止"]')`,
|
||||
'composer stop control released', main)
|
||||
|
||||
const switchMessages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
|
||||
const switchAssistants = switchMessages.filter(message => message.role === 'assistant')
|
||||
const lastAssistant = switchAssistants.at(-1)
|
||||
check('the post-switch assistant records the last selected model identity',
|
||||
lastAssistant?.modelId, modelCId)
|
||||
check('the running turn kept the original model identity',
|
||||
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_ONE'))?.modelId, modelId)
|
||||
check('the guidance turn kept the original model identity',
|
||||
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_MERGED'))?.modelId, modelId)
|
||||
check('the running turn request was served by model A', requests[0].servedModel, 'A')
|
||||
check('the guidance turn request was served by model A', requests[1].servedModel, 'A')
|
||||
check('the post-switch request was served by model C', followUp.servedModel, 'C')
|
||||
|
||||
// ---- Dark and narrow captures of the real composer.
|
||||
setStage('captures')
|
||||
await read(`(()=>{
|
||||
const key='ycode:theme';
|
||||
localStorage.setItem(key,'dark');
|
||||
window.dispatchEvent(new StorageEvent('storage',{key,newValue:'dark'}));
|
||||
return true
|
||||
})()`, main)
|
||||
// The composer and every other themed surface must follow the shared
|
||||
// theme state, not just the document element.
|
||||
await waitFor(`document.querySelector('.chat-pane[data-cr-theme="dark"]')!==null`, 'dark theme applied to the chat pane', main)
|
||||
check('the dark theme is applied to the real renderer',
|
||||
await read(`document.querySelector('.chat-pane')?.getAttribute('data-cr-theme')??null`, main), 'dark')
|
||||
check('dark conversation screenshot captured', await screenshot(main, 'append-final-dark'), true)
|
||||
|
||||
main.setSize(720, 900)
|
||||
await pause(300)
|
||||
check('narrow composer keeps the permission control visible and unobscured', await read(`(()=>{
|
||||
const button=document.querySelector('.yc-source-permission-trigger .cr-permission-setting');
|
||||
if(!button)return false;
|
||||
const box=button.getBoundingClientRect();
|
||||
const hit=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);
|
||||
return box.width>0&&box.height>0&&box.x>=0&&box.right<=innerWidth&&!!hit&&button.contains(hit);
|
||||
})()`, main), true)
|
||||
check('narrow conversation screenshot captured', await screenshot(main, 'append-final-narrow'), true)
|
||||
main.setSize(1336, 902)
|
||||
await pause(200)
|
||||
|
||||
const report = {
|
||||
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
|
||||
scope: 'Production out/main, out/preload, and out/renderer; isolated Electron userData/home; local loopback Anthropic SSE; real Claude SDK/CLI; two UI-queued guidance messages sent through chat.queue.sendNow while the first response was active.',
|
||||
sessionId: session.id,
|
||||
requests: requests.map(request => ({
|
||||
index: request.index,
|
||||
url: request.url,
|
||||
servedModel: request.servedModel,
|
||||
wireModel: request.wireModel,
|
||||
userTexts: request.userTexts,
|
||||
messageRoles: Array.isArray(request.body.messages) ? request.body.messages.map(message => message?.role) : [],
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
answer: request.answer
|
||||
})),
|
||||
beforeSendNow,
|
||||
afterSendNow,
|
||||
queueTimeline,
|
||||
finalQueue,
|
||||
messages,
|
||||
switchMessages,
|
||||
serverEvents,
|
||||
checks,
|
||||
rendererErrors,
|
||||
screenshots: ['append-queue.png', 'append-final.png', 'append-final-dark.png', 'append-final-narrow.png'],
|
||||
limitations: [
|
||||
'Native result user_message_uuids are verified indirectly through persisted guidance identities, merged request content, the second assistant segment, and an empty queue.',
|
||||
'The loopback server returns plain text only; tool calls, images, cancellation, and upstream provider failures are outside this E2E scope.'
|
||||
]
|
||||
}
|
||||
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
|
||||
console.log('APPEND_E2E_' + report.result + ' ' + directory)
|
||||
for (const failure of checks.filter(item => !item.pass)) {
|
||||
console.log('APPEND_E2E_FAILURE ' + JSON.stringify(failure))
|
||||
}
|
||||
clearTimeout(watchdog)
|
||||
app.exit(report.result === 'PASS' ? 0 : 1)
|
||||
} finally {
|
||||
clearTimeout(watchdog)
|
||||
hidingWindows = false
|
||||
// A gated SSE response keeps the socket open, so close() would wait for
|
||||
// it. Destroy the sockets explicitly instead of hanging the harness.
|
||||
for (const socket of openSockets) socket.destroy()
|
||||
openSockets.clear()
|
||||
await new Promise(resolve => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(async error => {
|
||||
hidingWindows = false
|
||||
try {
|
||||
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
|
||||
stage,
|
||||
error: String(error),
|
||||
stack: error.stack,
|
||||
checks,
|
||||
queueTimeline,
|
||||
requests: requests.map(request => ({
|
||||
index: request.index,
|
||||
userTexts: request.userTexts,
|
||||
completed: request.completed,
|
||||
aborted: request.aborted,
|
||||
answer: request.answer
|
||||
})),
|
||||
serverEvents,
|
||||
rendererErrors
|
||||
}, null, 2))
|
||||
} catch { /* The failure report is best effort. */ }
|
||||
console.error('APPEND_E2E_ERROR', error)
|
||||
app.exit(1)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user